Public
Edited
Dec 5, 2021
6 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
viewof radius = html`<input type=range min=0.01 max=2 step=0.01 value=0.15>`
Insert cell
Insert cell
viewof minPointsProximity = html`<input type=range min=0 max=3 step=0.1 value=1.2>`
Insert cell
Insert cell
viewof sides = html`<input type=range min=4 max=12 step=1 value=5>`
Insert cell
Insert cell
viewof segmentCount = html`<input type=range min=4 max=12 step=1 value=5>`
Insert cell
Insert cell
viewof jointSegment = html`<input type=range min=1 max=${segmentCount} step=1 value=3>`
Insert cell
noTabSegments = [0, 2, 5]
Insert cell
Insert cell
Insert cell
Insert cell
function getFibonacciSpherePoints(samples, radius, randomize) {
// Via: https://gist.github.com/stephanbogner/a5f50548a06bec723dcb0991dcbb0856
// Translated from Python from https://stackoverflow.com/a/26127012
samples = samples || 1;
radius = radius || 1;
randomize = randomize || true;
var random = 1;
if (randomize === true) {
random = Math.random() * samples;
}

var points = []
var offset = 2 / samples
var increment = Math.PI * (3 - Math.sqrt(5));

for (var i = 0; i < samples; i++) {
var y = ((i * offset) - 1) + (offset / 2);
var distance = Math.sqrt(1 - Math.pow(y, 2));
var phi = ((i + random) % samples) * increment;
var x = Math.cos(phi) * distance;
var z = Math.sin(phi) * distance;
x = x * radius;
y = y * radius;
z = z * radius;
var point = {
'x': x,
'y': y,
'z': z
}
points.push(point);
}
return points;
}
Insert cell
Insert cell
convert3DPointToSphereCoordinate = (x, y, z) => {
return {
lng: Math.atan2(y, x) + Math.PI,
lat: Math.acos((z / radius)) * 2
}
}
Insert cell
Insert cell
sideWidthAtLatitude = d3.scaleLinear()
.domain(sideWidthDomainAndRange.domain)
.range(sideWidthDomainAndRange.range)
Insert cell
sideWidthDomainAndRange = {
const segments = d3.groups(sampleExteriorLines, l => l.segment).map((g, i) => {
const pair = g[1]
return {
lines: pair,
verticalLength: facets[i].height,
horizontalLength: facets[i].bottom
}
})
const totalVerticalLength = segments.reduce((memo, current) => { return memo + current.verticalLength }, 0)
let culmulativeVerticalLength = 0
const domain = segments.map(s => {
culmulativeVerticalLength += s.verticalLength
return culmulativeVerticalLength / totalVerticalLength * 2 * Math.PI
})
const range = segments.map(s => s.horizontalLength)
return {
domain: [0].concat(domain),
range: [0].concat(range)
}
}
Insert cell
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, 200]);
const count = 800
d3.range(count).map(i => {
const lat = i / count * Math.PI * 2
svg.append("rect")
.attr("width", 1)
.attr("height", sideWidthAtLatitude(lat))
.attr("x", i)
.attr("fill", d3.interpolateSpectral(i / 800))
})
return svg.node()
}
Insert cell
md`But that doesn't get me all the way there. sideWidthAtLatitude only gives me how far to offset from the spine. That offset (plus the vertical offset along the spine) needs to be added to the position and the rotation of a particular petal.

sphereToFlat(lat, lng) thus has to do the following:

1. Figure out which petal we're on by dividing the lng by the arc for a side.
2. Figure out the top of that petal's position, as well as its rotation (via a lookup)
3. Then apply both the spine-wise and width-wise offsets.`
Insert cell
sphereToFlat = (lat, lng) => {
const side = Math.min(sides - 1, Math.floor(lng / arcOfSide))
const s = propertiesPerSide[side]
const widthAtLat = sideWidthAtLatitude(lat)
//const remainder = lng - (side + 0.5) * 2 * Math.PI / sides
const proportionOfSide = (lng % arcOfSide) / (arcOfSide) - 0.5
const offsetFromTop = (lat / (2 * Math.PI)) * poleToPoleLength
const offsetFromMidline = proportionOfSide * widthAtLat
const ortho = -s.theta - Math.PI * 0.5
return {
proportionOfSide,
x: s.poles.top.x + Math.cos(-s.theta) * offsetFromTop + Math.cos(ortho) * offsetFromMidline,
y: s.poles.top.y + Math.sin(-s.theta) * offsetFromTop + Math.sin(ortho) * offsetFromMidline,
}
}
Insert cell
Insert cell
Insert cell
Insert cell
fieldPoints = getFibonacciSpherePoints(10000, radius).map(p => {
return {
...p,
...convert3DPointToSphereCoordinate(p.x, p.y, p.z),
noise: perlin3.gen(p.x + radius, p.y + radius, p.z + radius)
}
})
Insert cell
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, drawingExtent.x[1] + 10, drawingExtent.y[1] + 10]);
const cutLayer = svg.append("g")
const scoreLayer = svg.append("g")
linesToDraw.forEach((p) => {
switch (p.type) {
case "hanger":
hangerLine(cutLayer, p)
break;
case "fold":
foldCreaseLine(scoreLayer, p)
break;
default:
if (p.tabs) {
latchAttachmentLine(cutLayer, p)
} else {
regularLine(cutLayer, p)
}
}
})
fieldPoints.forEach(g => {
const p = sphereToFlat(g.lat, g.lng);
svg.append("circle")
.attr("cx", p.x)
.attr("cy", p.y)
.attr("r", 1)
.attr("fill", d3.interpolateSinebow(noiseScale(g.noise)))
})
return svg.node()
}
Insert cell
Insert cell
noiseToTheta = (fp) => {
const point = sphereToFlat(fp.lat, fp.lng)
const side = getSideIDGivenPoint(point)
const properties = propertiesPerSide[side]
return noiseScale(fp.noise) * Math.PI * 2 - properties.theta
}
Insert cell
noiseScale = d3.scaleLinear().domain(fieldExtents.noise)
Insert cell
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, drawingExtent.x[1] + 10, drawingExtent.y[1] + 10]);
const cutLayer = svg.append("g")
const scoreLayer = svg.append("g")
linesToDraw.forEach((p) => {
switch (p.type) {
case "hanger":
hangerLine(cutLayer, p)
break;
case "fold":
foldCreaseLine(scoreLayer, p)
break;
default:
if (p.tabs) {
latchAttachmentLine(cutLayer, p)
} else {
regularLine(cutLayer, p)
}
}
})
fieldPoints.forEach(fp => {
const p = sphereToFlat(fp.lat, fp.lng);
svg.append("polygon")
.attr("transform", `translate(${p.x}, ${p.y}) rotate(${noiseToTheta(fp) / Math.PI * 180})`)
.attr("points", "-1,0 1,0 0,-3")
.attr("fill", d3.interpolateSinebow(noiseScale(fp.noise)))
})
return svg.node()
}
Insert cell
md`With the field finally properly set up, now I can finally start drawing flow lines through them. Here though a new set of complications arise.

First -- looking up the field points repeatedly is slooow. Had to throw in a quadtree to make the look ups faster.`
Insert cell
fieldQuadTree = {
const field = fieldPoints.map(fp => {
const { x, y } = sphereToFlat(fp.lat, fp.lng)
return {
x,
y,
theta: noiseToTheta(fp)
}
})
const q = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.extent([drawingExtent.x, drawingExtent.y])
.addAll(field)
return q
}
Insert cell
Insert cell
isPointInsideOrnament = (point, linesToDraw) => {
let intersectionCount = 0
const p = point.x
const q = point.y
const r = point.x + 100000
const s = point.y
const checkIntersection = (a, b, c, d) => {
let det, gamma, lambda;
det = (c - a) * (s - q) - (r - p) * (d - b);
if (det === 0) {
return false;
} else {
lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det;
gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det;
return (0 < lambda && lambda < 1) && (0 < gamma && gamma < 1);
}
}
linesToDraw.forEach(l => {
let a = l.x1
let b = l.y1
let c = l.x2
let d = l.y2
if (checkIntersection(a, b, c, d)) {
intersectionCount++;
}
})
return (intersectionCount % 2 === 1)
}
Insert cell
isPointInsideOrnament({ x : 0, y: 0 }, ornamentBoundaryLines)
Insert cell
md`Again, diagnostic graphic. This helped me catch a bunch of bugs around how I defined what are considered the "exterior" edges. I had initially just used all of the cut lines, and it didn't work. The visual showed me that immediately.`
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, drawingExtent.x[1] + 10, drawingExtent.y[1] + 10]);
const cutLayer = svg.append("g").attr("id", "cut")
const drawLayer = svg.append("g").attr("id", "draw")
const scoreLayer = svg.append("g").attr("id", "score")
linesToDraw.forEach((p) => {
if (p.isExterior) {
regularLine(cutLayer, p)
}
})
const xCount = 100
const yCount = 100
d3.range(xCount).forEach((i) => {
d3.range(yCount).forEach((j) => {
/*
const x = Math.random() * drawingExtent.x[1]
const y = Math.random() * drawingExtent.y[1]
*/
const x = i / xCount * drawingExtent.x[1]
const y = j / yCount * drawingExtent.y[1]

const isInside = isPointInsideOrnament({ x, y }, ornamentBoundaryLines)
if (isInside) {
const side = getSideIDGivenPoint({ x, y })
svg.append("text")
.text(side)
.attr("x", x)
.attr("y", y)
.attr("font-size", 7)
} else {
svg.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 1)
.attr("fill", "red")
}
})
})
propertiesPerSide.forEach(side => {
svg.append("circle")
.attr("cx", side.poles.top.x)
.attr("cy", side.poles.top.y)
.attr("r", 5)
.attr("fill", "red")

svg.append("circle")
.attr("cx", side.poles.bottom.x)
.attr("cy", side.poles.bottom.y)
.attr("r", 5)
.attr("fill", "blue")
})
return svg.node()
}
Insert cell
md`With isPointInsideOrnament, I can stop flow lines from running outside of the boundaries. I also still needed to stop them from running into each other. This means that I have to:

1. Remember each point were previous flow lines have been.
2. Compare each new candidate point on a flow line to previous points.

This, of course, called for yet another quadtree.`
Insert cell
paths = {
const board = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.extent([drawingExtent.x, drawingExtent.y])
const tooCloseToExistingPoint = (next, last) => {
const nearest = board.find(next.x, next.y, stepLength * minPointsProximity)
if (!nearest) { return false }
const isJustPrevious = nearest.x === last.x && nearest.y === last.y
return !isJustPrevious
}
const pathTracer = (init) => {
const result = []
result.push(init)

while (result.length < 100) {
const l = result[result.length - 1]
const p = fieldQuadTree.find(l.x, l.y)

let next = {
x: l.x + Math.cos(p.theta) * stepLength,
y: l.y + Math.sin(p.theta) * stepLength
}
const isTooClose = (result.length && tooCloseToExistingPoint(next, result[result.length - 1]))

result.push(next)
if (isTooClose || Math.random() > 0.98 || result.length > 35) { break; }
if (!isPointInsideOrnament(next, ornamentBoundaryLines)) {
// draw last point outside of ornament, but then stop
break;
}
}

return result
}
const minPathLength = 4
const paths = []
let failureCount = 0
let acceptanceThreshold = 25
/*
getFibonacciSpherePoints(2000, radius, false).map((p, i) => {
const l = convert3DPointToSphereCoordinate(p.x, p.y, p.z)
const q = sphereToFlat(l.lat, l.lng)
*/
d3.range(30000).forEach((i) => {
const q = { x: Math.random() * drawingExtent.x[1], y: Math.random() * drawingExtent.y[1] }
if (!isPointInsideOrnament(q, exteriorLines) || getSideIDGivenPoint(q) === null) { return }
const path = pathTracer(q)
const keep = path.length > acceptanceThreshold
if (keep) {
board.addAll(path)
paths.push(path)
} else {
failureCount++
}
if (failureCount > 15) {
acceptanceThreshold = Math.max(3, acceptanceThreshold * 0.98)
failureCount = 0
}
})
return paths
}
Insert cell
md`Finally, I am generating paths, which finally leads us to the result.`
Insert cell
Insert cell
md`Oooof that's a lot of work, and then there's still all this secondary functions that are required to support all of this. Here ends the narration though.`
Insert cell
exteriorLinesPerSide = d3.group(linesToDraw.filter(l => l.side === "left" || l.side === "right"), l => l.face)
Insert cell
sampleExteriorLines = exteriorLinesPerSide.get(0)
Insert cell
getSideIDGivenPoint = ({ x, y }) => {
let result = null
exteriorLinesPerSide.forEach((lineSet, key) => {
if (isPointInsideOrnament({ x, y }, lineSet)) {
result = key
}
})
return result
}
Insert cell
getPolesGivenLinesOnSide = (lineSet) => {
let topLines = lineSet.filter(l => l.segment === 0)
let topX1Y1matches = (topLines[0].x1 === topLines[1].x1 && topLines[0].y1 === topLines[1].y1)
let topPoint = (topX1Y1matches) ? { x: topLines[0].x1, y: topLines[0].y1 } : { x: topLines[0].x2, y: topLines[0].y2 }
let bottomLines = lineSet.filter(l => l.segment === segmentCount + 1)
let bottomX1Y1matches = (bottomLines[0].x1 === bottomLines[1].x1 && bottomLines[0].y1 === bottomLines[1].y1)
let bottomPoint = (bottomX1Y1matches) ? { x: bottomLines[0].x1, y: bottomLines[0].y1 } : { x: bottomLines[0].x2, y: bottomLines[0].y2 }
return {
top: topPoint,
bottom: bottomPoint
}
}
Insert cell
poles = getPolesGivenLinesOnSide(exteriorLinesPerSide.get(0))
Insert cell
poleToPoleLength = Math.hypot(poles.top.x - poles.bottom.x, poles.top.y - poles.bottom.y)
Insert cell
propertiesPerSide = {
let properties = []
exteriorLinesPerSide.forEach(lineSet => {
const poles = getPolesGivenLinesOnSide(lineSet)
const theta = Math.atan2(poles.top.y - poles.bottom.y, poles.bottom.x - poles.top.x)
let property = {
theta,
poles
}
properties.push(property)
})
return properties
}
Insert cell
arcOfSide = (2 * Math.PI) / sides
Insert cell
ornamentBoundaryLines = linesToDraw.filter(l => {
if (l.segment > 0) {
return l.isExterior === true
} else {
return l.isExterior && (l.side !== "left" && l.side !== "right")
}
})
Insert cell
fieldExtents = {
return {
lng: d3.extent(fieldPoints, d => d.lat),
lat: d3.extent(fieldPoints, d => d.lng),
noise: d3.extent(fieldPoints, d => d.noise)
}
}
Insert cell
stepLength = 3
Insert cell
radius
Insert cell
lineDrawer = d3.line().x(d => d.x).y(d => d.y)
Insert cell
exteriorLines = linesToDraw.filter(l => l.isExterior)
Insert cell
import { linesToDraw, facets, drawingExtent, hangerLine, foldCreaseLine, foldCutLine, latchAttachmentLine, regularLine }
with { segmentCount, sides, noTabSegments, jointSegment }
from "282278e4ff9adb85"
Insert cell
perlin3 = new tumult.Perlin3("tyler_hobbs_2")
Insert cell
tumult = require('https://unpkg.com/tumult/dist/tumult.min.js')
Insert cell
d3 = require("d3@6")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more