import { cameraOf, iso, pathOf } from "./iso-kit.mjs"; const TAU = Math.PI * 2; const LIGHT_UP = 1.2; const BANDS = [0, 0.2, 0.42, 0.62]; export const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; export const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; export const mul3 = (a, k) => [a[0] * k, a[1] * k, a[2] * k]; export const len3 = (a) => Math.hypot(a[0], a[1], a[2]); export const unit3 = (a) => { const length = len3(a) || 1; return [a[0] / length, a[1] / length, a[2] / length]; }; export const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; export const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; export function viewOf(P) { const { sinA, cosA, sinE, cosE } = cameraOf(P); return [cosA * cosE, sinA * cosE, sinE]; } export function lightOf(P) { const { sinA, cosA } = cameraOf(P); return unit3([-sinA, cosA, LIGHT_UP]); } export const scaleOf = (P) => cameraOf(P).k; export function bandOf(score) { let tone = 0; while (tone < BANDS.length && score >= BANDS[tone]) tone++; return tone; } export const toneOf = (normal, P) => bandOf(dot3(unit3(normal), lightOf(P))); export function frameAlong(o, a) { const axis = unit3(a); const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1]; const u = unit3(cross3(helper, axis)); const v = cross3(axis, u); return { o, a: axis, u, v }; } export const frameOf = (o, a, u, v) => ({ o, a, u, v }); export function pointOf(F, s, r, angle) { const c = Math.cos(angle); const n = Math.sin(angle); return [ F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]), F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]), F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]), ]; } export const radialOf = (F, angle) => { const c = Math.cos(angle); const n = Math.sin(angle); return [c * F.u[0] + n * F.v[0], c * F.u[1] + n * F.v[1], c * F.u[2] + n * F.v[2]]; }; export const tangentOf = (F, angle) => { const c = Math.cos(angle); const n = Math.sin(angle); return [-n * F.u[0] + c * F.v[0], -n * F.u[1] + c * F.v[1], -n * F.u[2] + c * F.v[2]]; }; export const angleToward = (F, direction) => Math.atan2(dot3(direction, F.v), dot3(direction, F.u)); function hullTagged(points) { const sorted = [...points].sort((a, b) => a.p[0] - b.p[0] || a.p[1] - b.p[1]); if (sorted.length < 3) return sorted; const cross = (o, a, b) => (a.p[0] - o.p[0]) * (b.p[1] - o.p[1]) - (a.p[1] - o.p[1]) * (b.p[0] - o.p[0]); const lower = []; for (const point of sorted) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 1e-9) lower.pop(); lower.push(point); } const upper = []; for (let index = sorted.length - 1; index >= 0; index--) { const point = sorted[index]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 1e-9) upper.pop(); upper.push(point); } return [...lower.slice(0, -1), ...upper.slice(0, -1)]; } export const hullOf = (points) => hullTagged(points.map((p) => ({ p }))).map(({ p }) => p); export function runsOf(flags) { const count = flags.length; const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]); if (start < 0) return flags[0] ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : []; const runs = []; let run = null; for (let step = 0; step < count; step++) { const index = (start + step) % count; if (flags[index]) { if (!run) run = [index]; run.push((index + 1) % count); } else if (run) { runs.push(run); run = null; } } if (run) runs.push(run); return runs; } function groupsOf(keys) { const count = keys.length; const out = []; const start = keys.findIndex((key, index) => key !== keys[(index + count - 1) % count]); if (start < 0) { if (keys[0] !== null) out.push({ key: keys[0], run: Array.from({ length: count + 1 }, (_, index) => index % count) }); return out; } let current = null; for (let step = 0; step < count; step++) { const index = (start + step) % count; const key = keys[index]; if (current && current.key === key) { current.run.push((index + 1) % count); continue; } if (current && current.key !== null) out.push(current); current = { key, run: [index, (index + 1) % count] }; } if (current && current.key !== null) out.push(current); return out; } export const stepsFor = (radius, least = 16, most = 112, scale = 0) => Math.max(least, Math.min(most, scale ? Math.ceil((TAU * radius * scale) / 3) : Math.round((radius * 1.3) / 4) * 4)); function chains(edges) { const out = []; let current = null; for (const [a, b, keep] of edges) { if (!keep) { if (current) out.push(current); current = null; continue; } if (!current) current = [a]; current.push(b); } if (current) out.push(current); if (out.length > 1 && edges[0][2] && edges[edges.length - 1][2]) { const last = out.pop(); out[0] = [...last, ...out[0].slice(1)]; } return out; } function labelledArcs(nr, na, view, light, gr = nr, ga = na) { const cuts = []; if (Math.abs(gr) > 1e-9) { const visibleCut = -(ga * view.a) / (gr * view.flat); if (visibleCut > -1 && visibleCut < 1) { const d = Math.acos(visibleCut); cuts.push(view.phi - d, view.phi + d); } } if (Math.abs(nr) > 1e-9) { for (const threshold of BANDS) { const c = (threshold - na * light.a) / (nr * light.flat); if (c > -1 && c < 1) { const d = Math.acos(c); cuts.push(light.phi - d, light.phi + d); } } } const wrap = (angle) => ((angle % TAU) + TAU) % TAU; const sorted = [...new Set(cuts.map((angle) => Math.round(wrap(angle) * 1e9) / 1e9))].sort((a, b) => a - b); const label = (angle) => { const facing = gr * view.flat * Math.cos(angle - view.phi) + ga * view.a; if (facing <= 1e-9) return -1; return bandOf(nr * light.flat * Math.cos(angle - light.phi) + na * light.a); }; if (!sorted.length) { const tone = label(0); return tone < 0 ? [] : [{ tone, from: 0, to: TAU, full: true }]; } const arcs = sorted.map((from, index) => { const to = index + 1 < sorted.length ? sorted[index + 1] : sorted[0] + TAU; return { tone: label((from + to) / 2), from, to }; }); const merged = []; for (const arc of arcs) { const last = merged[merged.length - 1]; if (last && last.tone === arc.tone && Math.abs(last.to - arc.from) < 1e-9) last.to = arc.to; else merged.push({ ...arc }); } if (merged.length > 1 && merged[0].tone === merged[merged.length - 1].tone && Math.abs(merged[merged.length - 1].to - TAU - merged[0].from) < 1e-9) { const last = merged.pop(); merged[0].from = last.from - TAU; } return merged.filter((arc) => arc.tone >= 0); } function hermiteSlopes(run) { const n = run.length - 1; const h = Array.from({ length: n }, (_, j) => run[j + 1][0] - run[j][0]); const delta = h.map((value, j) => (run[j + 1][1] - run[j][1]) / value); const d = new Array(n + 1).fill(0); const end = (h0, h1, d0, d1) => { let value = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); if (Math.sign(value) !== Math.sign(d0)) value = 0; else if (Math.sign(d0) !== Math.sign(d1) && Math.abs(value) > Math.abs(3 * d0)) value = 3 * d0; return value; }; if (n === 1) d[0] = d[1] = delta[0]; else { d[0] = end(h[0], h[1], delta[0], delta[1]); d[n] = end(h[n - 1], h[n - 2], delta[n - 1], delta[n - 2]); } for (let k = 1; k < n; k++) { if (delta[k - 1] * delta[k] <= 0) continue; const w1 = 2 * h[k] + h[k - 1]; const w2 = h[k] + 2 * h[k - 1]; d[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]); } return d; } export function curveRuns(meridian) { const m = meridian.length; const segments = new Array(m - 1).fill(null); let start = 0; while (start < m - 1) { let end = start + 1; while (end < m - 1 && meridian[end][2] && Math.abs(meridian[end + 1][0] - meridian[end][0]) > 1e-9 && Math.abs(meridian[end][0] - meridian[end - 1][0]) > 1e-9) end++; const run = meridian.slice(start, end + 1); const n = run.length - 1; if (n >= 2 && run.every((point, j) => j === 0 || Math.abs(point[0] - run[j - 1][0]) > 1e-9)) { const d = hermiteSlopes(run); for (let j = 0; j < n; j++) { const [s0, r0] = run[j]; const [s1, r1] = run[j + 1]; const hj = s1 - s0; const d0 = d[j]; const d1 = d[j + 1]; const radius = (s) => { const t = (s - s0) / hj; return (2 * t ** 3 - 3 * t ** 2 + 1) * r0 + (t ** 3 - 2 * t ** 2 + t) * hj * d0 + (-2 * t ** 3 + 3 * t ** 2) * r1 + (t ** 3 - t ** 2) * hj * d1; }; const slope = (s) => { const t = (s - s0) / hj; return ((6 * t ** 2 - 6 * t) * r0 + (6 * t - 6 * t ** 2) * r1) / hj + (3 * t ** 2 - 4 * t + 1) * d0 + (3 * t ** 2 - 2 * t) * d1; }; segments[start + j] = { radius, slope }; } } start = end; } return segments; } function curved(meridian, P, segments, { error = 0.2, depth = 6 } = {}) { const k = scaleOf(P); const out = [meridian[0]]; for (let i = 0; i < meridian.length - 1; i++) { const curve = segments[i]; const [s0] = meridian[i]; const [s1] = meridian[i + 1]; if (curve) { const split = (a, b, level) => { const mid = (a + b) / 2; const chord = (curve.radius(a) + curve.radius(b)) / 2; if (level < depth && Math.abs(curve.radius(mid) - chord) * k > error) { split(a, mid, level + 1); out.push([mid, curve.radius(mid), 1]); split(mid, b, level + 1); } }; split(s0, s1, 0); } out.push(meridian[i + 1]); } return out; } function smoothRows(meridian, F, P, { rows = 6, slope = null, radius = null, steps = 48, error = 0.35, depth: deepest = 8, fineness = 0.1 } = {}) { const V = viewOf(P); const L = lightOf(P); const view = { a: dot3(F.a, V), flat: Math.hypot(dot3(F.u, V), dot3(F.v, V)), phi: Math.atan2(dot3(F.v, V), dot3(F.u, V)) }; const light = { a: dot3(F.a, L), flat: Math.hypot(dot3(F.u, L), dot3(F.v, L)), phi: Math.atan2(dot3(F.v, L), dot3(F.u, L)) }; const m = meridian.length; const faces = []; for (let i = 0; i < m - 1; i++) { const ds = meridian[i + 1][0] - meridian[i][0]; const dr = meridian[i + 1][1] - meridian[i][1]; const length = Math.hypot(ds, dr) || 1; faces.push([ds / length, -dr / length]); } const splines = slope ? null : curveRuns(meridian); const vertexNormal = (k, own) => { if (k <= 0 || k >= m - 1 || !meridian[k][2]) return own; const a = faces[k - 1]; const b = faces[k]; const x = a[0] + b[0]; const y = a[1] + b[1]; const length = Math.hypot(x, y) || 1; return [x / length, y / length]; }; const normalAt = (i, u) => { const [s0] = meridian[i]; const [s1] = meridian[i + 1]; const s = s0 + (s1 - s0) * u; const sign = Math.sign(s1 - s0) || 1; const g = slope ? slope(s) : splines[i] ? splines[i].slope(s) : null; if (g !== null) { const length = Math.hypot(1, g); return [sign / length, (-sign * g) / length]; } const a = vertexNormal(i, faces[i]); const b = vertexNormal(i + 1, faces[i]); const x = a[0] + (b[0] - a[0]) * u; const y = a[1] + (b[1] - a[1]) * u; const length = Math.hypot(x, y) || 1; return [x / length, y / length]; }; const pointAt = (i, u, angle) => { const [s0, r0] = meridian[i]; const [s1, r1] = meridian[i + 1]; const s = s0 + (s1 - s0) * u; return iso(pointOf(F, s, radius ? radius(s) : splines && splines[i] ? splines[i].radius(s) : r0 + (r1 - r0) * u, angle), P); }; const buckets = [[], [], [], [], []]; const fine = TAU / Math.max(24, Math.min(192, steps * 1.5)); const arcPoints = (i, u, from, to) => { const count = Math.max(1, Math.ceil((to - from) / fine)); return Array.from({ length: count + 1 }, (_, index) => pointAt(i, u, from + ((to - from) * index) / count)); }; const centreOf = (arc, reference) => { let mid = (arc.from + arc.to) / 2 - reference; mid = ((mid % TAU) + TAU) % TAU; return mid; }; const levelOf = (i, u) => { const [nr, na] = normalAt(i, u); const groups = new Map(); for (const arc of labelledArcs(nr, na, view, light, faces[i][0], faces[i][1])) { if (!groups.has(arc.tone)) groups.set(arc.tone, []); groups.get(arc.tone).push(arc); } for (const list of groups.values()) list.sort((x, y) => centreOf(x, light.phi) - centreOf(y, light.phi)); return { i, u, groups }; }; const same = (x, y) => { if (x.groups.size !== y.groups.size) return false; for (const [tone, list] of x.groups) if ((y.groups.get(tone) ?? []).length !== list.length) return false; return true; }; const align = (reference, arc) => { const out = { ...arc }; const centre = (reference.from + reference.to) / 2; while ((out.from + out.to) / 2 - centre > Math.PI) { out.from -= TAU; out.to -= TAU; } while (centre - (out.from + out.to) / 2 > Math.PI) { out.from += TAU; out.to += TAU; } return out; }; const strayOf = (i, a, mid, b) => { let worst = 0; for (const [tone, list] of mid.groups) { list.forEach((arc, index) => { const lo = align(arc, a.groups.get(tone)[index]); const hi = align(arc, b.groups.get(tone)[index]); for (const key of ["from", "to"]) { const p = pointAt(i, mid.u, arc[key]); const p0 = pointAt(i, a.u, lo[key]); const p1 = pointAt(i, b.u, hi[key]); worst = Math.max(worst, Math.hypot(p[0] - (p0[0] + p1[0]) / 2, p[1] - (p0[1] + p1[1]) / 2)); } }); } return worst; }; const sheet = (tone, levels) => { levels[0].groups.get(tone).forEach((arc, index) => { const chain = [arc]; for (let k = 1; k < levels.length; k++) chain.push(align(chain[k - 1], levels[k].groups.get(tone)[index])); const last = levels.length - 1; const points = [...arcPoints(levels[0].i, levels[0].u, chain[0].from, chain[0].to)]; for (let k = 1; k < last; k++) points.push(pointAt(levels[k].i, levels[k].u, chain[k].to)); points.push(...arcPoints(levels[last].i, levels[last].u, chain[last].from, chain[last].to).reverse()); for (let k = last - 1; k >= 1; k--) points.push(pointAt(levels[k].i, levels[k].u, chain[k].from)); buckets[tone].push(pathOf(oriented(points), true)); }); }; const overlap = (x, y) => Math.min(x.to, y.to) - Math.max(x.from, y.from); const strip = (tone, a, b) => { const below = a.groups.get(tone) ?? []; const above = (b.groups.get(tone) ?? []).map((arc) => ({ arc, used: false })); const quad = (lo, hi) => { const points = [...arcPoints(a.i, a.u, lo.from, lo.to), ...arcPoints(b.i, b.u, hi.from, hi.to).reverse()]; buckets[tone].push(pathOf(oriented(points), true)); }; for (const arc of below) { let best = null; let score = -Infinity; for (const entry of above) { if (entry.used) continue; const aligned = align(arc, entry.arc); const value = overlap(arc, aligned); if (value > score) (score = value), (best = entry); } if (best && score > -0.05) { best.used = true; quad(arc, align(arc, best.arc)); } else { const centre = (arc.from + arc.to) / 2; quad(arc, { from: centre, to: centre }); } } for (const entry of above) { if (entry.used) continue; const centre = (entry.arc.from + entry.arc.to) / 2; quad({ from: centre, to: centre }, entry.arc); } }; const k = scaleOf(P); let run = []; const flush = () => { const tones = [...new Set(run.flatMap((level) => [...level.groups.keys()]))].sort((x, y) => x - y); for (const tone of tones) { const countOf = (level) => (level.groups.get(tone) ?? []).length; let start = 0; for (let at = 1; at <= run.length; at++) { if (at < run.length && countOf(run[at - 1]) === countOf(run[at])) continue; if (at - 1 > start && countOf(run[start])) sheet(tone, run.slice(start, at)); if (at < run.length) strip(tone, run[at - 1], run[at]); start = at; } } run = []; }; for (let i = 0; i < m - 1; i++) { const ds = meridian[i + 1][0] - meridian[i][0]; if (Math.abs(ds) < 1e-9) { flush(); continue; } const count = Math.max(1, rows); const reach = (Math.hypot(ds, meridian[i + 1][1] - meridian[i][1]) * k) / count; const limit = Math.max(1, Math.min(deepest, Math.ceil(Math.log2(Math.max(2, reach / fineness))))); const coarse = Array.from({ length: count + 1 }, (_, index) => levelOf(i, index / count)); run.push(coarse[0]); const refine = (a, b, depth) => { const mid = levelOf(i, (a.u + b.u) / 2); const split = depth < limit && (!same(a, b) || !same(a, mid) || (depth < 6 && strayOf(i, a, mid, b) > error)); if (!split) { run.push(b); return; } refine(a, mid, depth + 1); refine(mid, b, depth + 1); }; for (let index = 1; index <= count; index++) refine(coarse[index - 1], coarse[index], 0); } flush(); return buckets; } function sideOf(meridian) { const m = meridian.length; let first = 0; let last = m - 1; if (meridian[0][1] <= 1e-9) first = 1; if (meridian[m - 1][1] <= 1e-9) last = m - 2; const side = meridian.slice(first, last + 1); for (let index = 1; index < side.length; index++) if (side[index][0] - side[index - 1][0] <= 1e-9) return null; return side.length >= 3 ? side : null; } function surfaceOf(side, F, P, rims, { samples = 96, slope = null } = {}) { const V = viewOf(P); const aV = dot3(F.a, V); const facing = Math.atan2(dot3(F.v, V), dot3(F.u, V)); const [left, right] = silhouettePoints(side, F, P, { samples, slope }); if (left.length <= samples || right.length <= samples) return null; const s0 = side[0][0]; const s1 = side[side.length - 1][0]; const angleOf = (point) => angleToward(F, sub3(point, add3(F.o, mul3(F.a, dot3(sub3(point, F.o), F.a))))); const wrap = (angle) => ((angle % TAU) + TAU) % TAU; const arc = (s, r, from, to, through) => { let sweep = wrap(to - from); if (wrap(through - from) > sweep) sweep -= TAU; const count = Math.max(4, Math.ceil(Math.abs(sweep) / (TAU / 96)), Math.ceil((Math.abs(sweep) * r * scaleOf(P)) / 1.5)); return Array.from({ length: count + 1 }, (_, index) => iso(pointOf(F, s, r, from + (sweep * index) / count), P)); }; const r0 = side[0][1]; const r1 = side[side.length - 1][1]; const bottom = -aV > 0 ? facing + Math.PI : facing; const top = aV > 0 ? facing + Math.PI : facing; const L = left.map((point) => iso(point, P)); const R = right.map((point) => iso(point, P)); const topArc = arc(s1, r1, angleOf(left[left.length - 1]), angleOf(right[right.length - 1]), top); const bottomArc = arc(s0, r0, angleOf(right[0]), angleOf(left[0]), bottom); const keepFirst = rims === "all" || rims === "first" || rims === "ends"; const keepLast = rims === "all" || rims === "last" || rims === "ends"; const fill = pathOf([...L, ...topArc, ...[...R].reverse(), ...bottomArc], true); const outline = [pathOf(L), pathOf(R), keepLast ? pathOf(topArc) : "", keepFirst ? pathOf(bottomArc) : ""].join(""); const hull = hullOf([...arc(s0, r0, 0, TAU - 1e-6, Math.PI), ...arc(s1, r1, 0, TAU - 1e-6, Math.PI), ...L, ...R]); let gap = 0; for (const p of [...L, ...R]) { let best = Infinity; for (let index = 0; index < hull.length; index++) { const a = hull[index]; const b = hull[(index + 1) % hull.length]; const dx = b[0] - a[0]; const dy = b[1] - a[1]; const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1))); best = Math.min(best, Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t)); } gap = Math.max(gap, best); } return { fill, outline, gap }; } export const softOf = (meridian) => meridian.some((point) => Boolean(point[2])); export function lathe(meridian, F, P, { steps = stepsFor(Math.max(...meridian.map(([, r]) => r)), 32, 160, scaleOf(P)), fill = "auto", hole = null, bevel = 0, rims = "all", smooth: asked = "auto", waist = 0.5 } = {}) { const smooth = asked === "auto" ? softOf(meridian) : asked; const V = viewOf(P); const L = lightOf(P); const aV = dot3(F.a, V); const uV = dot3(F.u, V); const vV = dot3(F.v, V); const aL = dot3(F.a, L); const uL = dot3(F.u, L); const vL = dot3(F.v, L); const n = steps; const m = meridian.length; const rings = meridian.map(([s, r]) => Array.from({ length: n }, (_, j) => iso(pointOf(F, s, r, (j / n) * TAU), P))); const midC = []; const midS = []; for (let j = 0; j < n; j++) { const t = ((j + 0.5) / n) * TAU; midC.push(Math.cos(t)); midS.push(Math.sin(t)); } const seen = []; const tones = []; for (let i = 0; i < m - 1; i++) { const ds = meridian[i + 1][0] - meridian[i][0]; const dr = meridian[i + 1][1] - meridian[i][1]; const length = Math.hypot(ds, dr); const row = []; const toneRow = []; for (let j = 0; j < n; j++) { if (length < 1e-9) { row.push(false); toneRow.push(0); continue; } const facing = ds * (midC[j] * uV + midS[j] * vV) - dr * aV; const lit = (ds * (midC[j] * uL + midS[j] * vL) - dr * aL) / length; row.push(facing > 1e-6 * length); toneRow.push(bandOf(lit)); } seen.push(row); tones.push(toneRow); } const buckets = [[], [], [], [], []]; const all = []; const smoothed = smooth ? smoothRows(meridian, F, P, { steps, ...(smooth === true ? {} : smooth) }) : null; if (smoothed) smoothed.forEach((list, tone) => list.forEach((d) => (buckets[tone].push(d), all.push(d)))); for (let i = 0; i < m - 1; i++) { if (smoothed && Math.abs(meridian[i + 1][0] - meridian[i][0]) > 1e-9) continue; const keys = seen[i].map((visible, j) => (visible ? tones[i][j] : null)); for (const { key, run } of groupsOf(keys)) { const a = run.map((j) => rings[i][j]); const b = run.map((j) => rings[i + 1][j]).reverse(); const d = pathOf(oriented([...a, ...b]), true); buckets[key].push(d); all.push(d); } } const phi = Math.atan2(vV, uV); const flatV = Math.hypot(uV, vV); const rangeOf = (i) => { const ds = meridian[i + 1][0] - meridian[i][0]; const dr = meridian[i + 1][1] - meridian[i][1]; if (Math.hypot(ds, dr) < 1e-9) return null; if (Math.abs(ds) < 1e-9 || flatV < 1e-9) return -dr * aV > 1e-9 ? [-1, 1] : null; const c = (dr * aV) / (ds * flatV); return ds > 0 ? [Math.max(-1, c), 1] : [-1, Math.min(1, c)]; }; const arcsOf = (ranges) => { if (ranges.some((range) => !range)) return []; const lo = Math.max(...ranges.map(([x]) => x)); const hi = Math.min(...ranges.map(([, x]) => x)); if (lo >= hi - 1e-9) return []; const near = Math.acos(Math.min(1, hi)); const far = Math.acos(Math.max(-1, lo)); if (hi >= 1 && lo <= -1) return [[phi, phi + TAU]]; if (hi >= 1) return [[phi - far, phi + far]]; if (lo <= -1) return [[phi + near, phi + TAU - near]]; return [[phi + near, phi + far], [phi - far, phi - near]]; }; const along = (s, r, [from, to]) => { const count = Math.max(2, Math.ceil(((to - from) * Math.max(r, 1e-6) * scaleOf(P)) / 1.5)); return Array.from({ length: count + 1 }, (_, j) => iso(pointOf(F, s, r, from + ((to - from) * j) / count), P)); }; const creases = []; for (let i = 1; i < m - 1; i++) { if (meridian[i][2] || meridian[i][1] <= 1e-9) continue; for (const arc of arcsOf([rangeOf(i - 1), rangeOf(i)])) creases.push(pathOf(along(meridian[i][0], meridian[i][1], arc))); } let bevelPath = ""; if (bevel > 0 && m > 2) { const ends = []; if (meridian[0][1] <= 1e-9) ends.push([0, 1, 1]); if (meridian[m - 1][1] <= 1e-9) ends.push([m - 2, m - 2, m - 3]); for (const [cap, rim, side] of ends) { const [s, r] = meridian[rim]; for (const arc of arcsOf([rangeOf(cap), rangeOf(side)])) { const inset = along(s, Math.max(0, r - bevel), arc); const edge = along(s, r, arc); bevelPath += pathOf([edge[0], ...inset.slice(1, -1), edge[edge.length - 1]]); } } } const { fill: fillPath, outline } = outlineOf(meridian, F, P, { steps: n, fill, hole, rims, smooth, waist, all }); return { fill: fillPath, outline, crease: creases.join(""), top: buckets[4].join(""), shades: buckets.slice(0, 4).map((list) => list.join("")), bevel: bevelPath || undefined, seamless: Boolean(smoothed), }; } function shapeOf(meridian, P, smooth) { if (smooth && smooth.radius) { const segments = meridian.slice(0, -1).map(([s0], index) => (Math.abs(meridian[index + 1][0] - s0) > 1e-9 ? { radius: smooth.radius, slope: smooth.slope } : null)); return curved(meridian, P, segments); } const curves = smooth && !smooth.slope ? curveRuns(meridian) : null; return curves && curves.some(Boolean) ? curved(meridian, P, curves) : meridian; } export function outlineOf(meridian, F, P, { steps = stepsFor(Math.max(...meridian.map(([, r]) => r)), 32, 160, scaleOf(P)), fill = "auto", hole = null, rims = "all", smooth: asked = "auto", waist = 0.5, all = null } = {}) { const smooth = asked === "auto" ? softOf(meridian) : asked; const n = steps; let fillPath; let outline; const shape = shapeOf(meridian, P, smooth); const side = fill === "auto" || fill === "surface" ? sideOf(shape) : null; const surface = side && !hole ? surfaceOf(side, F, P, rims, { slope: smooth && smooth.slope ? smooth.slope : null }) : null; if (surface && (fill === "surface" || surface.gap > waist)) { fillPath = surface.fill; outline = surface.outline; } else if (fill === "hull" || fill === "auto" || fill === "surface") { const most = Math.max(...shape.map(([, r]) => r)); const around = Math.min(320, Math.max(n, Math.ceil((TAU * most * scaleOf(P)) / 1.5))); const tagged = []; shape.forEach(([s, r], index) => { for (let j = 0; j < around; j++) tagged.push({ p: iso(pointOf(F, s, r, (j / around) * TAU), P), ring: index }); }); const hull = hullTagged(tagged); const holePath = hole ? pathOf(Array.from({ length: around }, (_, j) => iso(pointOf(F, hole[0], hole[1], (j / around) * TAU), P)), true) : ""; fillPath = pathOf(hull.map(({ p }) => p), true) + holePath; if (rims === "all") { outline = pathOf(hull.map(({ p }) => p), true) + holePath; } else { const keepFirst = rims === "first" || rims === "ends"; const keepLast = rims === "last" || rims === "ends"; const edges = hull.map((a, index) => { const b = hull[(index + 1) % hull.length]; const same = a.ring === b.ring; const keep = !same || (a.ring !== 0 && a.ring !== shape.length - 1) || (keepFirst && a.ring === 0) || (keepLast && a.ring === shape.length - 1); return [a.p, b.p, keep]; }); outline = chains(edges).map((points) => pathOf(points)).join("") + holePath; } } else { fillPath = all ? all.join("") : (({ shades, top }) => [...shades, top].join(""))(lathe(meridian, F, P, { steps: n, fill: "auto", hole, rims, smooth, waist })); outline = ""; } return { fill: fillPath, outline }; } const signedArea = (points) => points.reduce((sum, [x, y], index) => { const [nx, ny] = points[(index + 1) % points.length]; return sum + x * ny - nx * y; }, 0); const oriented = (points) => (signedArea(points) < 0 ? [...points].reverse() : points); export const saddleStart = (R, a, angle) => Math.sqrt(Math.max(0, R * R - a * a * Math.cos(angle) ** 2)); export function saddleOf(F, a, start, s1, P, { steps = stepsFor(a, 24) } = {}) { const V = viewOf(P); const L = lightOf(P); const n = steps; const angle = (j) => (j / n) * TAU; const from = typeof start === "function" ? start : (t) => saddleStart(start, a, t); const low = Array.from({ length: n }, (_, j) => iso(pointOf(F, from(angle(j)), a, angle(j)), P)); const high = Array.from({ length: n }, (_, j) => iso(pointOf(F, s1, a, angle(j)), P)); const seen = []; const tones = []; for (let j = 0; j < n; j++) { const normal = radialOf(F, angle(j + 0.5)); seen.push(dot3(normal, V) > 1e-6); tones.push(bandOf(dot3(normal, L))); } const buckets = [[], [], [], [], []]; const fills = []; const keys = seen.map((visible, j) => (visible ? tones[j] : null)); for (const { key, run } of groupsOf(keys)) { const d = pathOf(oriented([...run.map((j) => low[j]), ...run.map((j) => high[j]).reverse()]), true); buckets[key].push(d); fills.push(d); } const capSeen = dot3(F.a, V) > 1e-6; if (capSeen) { const d = pathOf(oriented(high), true); buckets[bandOf(dot3(F.a, L))].push(d); fills.push(d); } const front = runsOf(seen); const back = runsOf(seen.map((visible) => !visible)); const edges = []; for (const run of front) { edges.push(pathOf(run.map((j) => low[j]))); edges.push(pathOf([low[run[0]], high[run[0]]]), pathOf([low[run[run.length - 1]], high[run[run.length - 1]]])); } for (const run of capSeen ? back : front) edges.push(pathOf(run.map((j) => high[j]))); return { fill: fills.join(""), outline: edges.join(""), crease: capSeen ? front.map((run) => pathOf(run.map((j) => high[j]))).join("") : "", top: buckets[4].join(""), shades: buckets.slice(0, 4).map((list) => list.join("")), seamless: true, }; } export function prismOf(F, polygon, s0, s1, P) { const V = viewOf(P); const L = lightOf(P); const ring = oriented(polygon.map(([x, y]) => [x, y])); const n = ring.length; const at = (s, [x, y]) => [F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y, F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y, F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y]; const low = ring.map((point) => iso(at(s0, point), P)); const high = ring.map((point) => iso(at(s1, point), P)); const buckets = [[], [], [], [], []]; const seen = []; for (let j = 0; j < n; j++) { const [x0, y0] = ring[j]; const [x1, y1] = ring[(j + 1) % n]; const normal = unit3(add3(mul3(F.u, y1 - y0), mul3(F.v, -(x1 - x0)))); const visible = dot3(normal, V) > 1e-6; seen.push(visible); if (visible) buckets[bandOf(dot3(normal, L))].push(pathOf([low[j], low[(j + 1) % n], high[(j + 1) % n], high[j]], true)); } const ends = [[s0, low, mul3(F.a, -1)], [s1, high, F.a]]; const capSeen = ends.map(([, , normal]) => dot3(normal, V) > 1e-6); ends.forEach(([, points, normal], index) => capSeen[index] && buckets[bandOf(dot3(normal, L))].push(pathOf(points, true))); const hull = hullOf([...low, ...high]); const creases = []; for (let j = 0; j < n; j++) { const prev = (j - 1 + n) % n; const a = [ring[j][0] - ring[prev][0], ring[j][1] - ring[prev][1]]; const b = [ring[(j + 1) % n][0] - ring[j][0], ring[(j + 1) % n][1] - ring[j][1]]; const sharp = (a[0] * b[0] + a[1] * b[1]) / (Math.hypot(a[0], a[1]) * Math.hypot(b[0], b[1]) || 1) < Math.cos(0.35); if (sharp && seen[j] && seen[prev]) creases.push(pathOf([low[j], high[j]])); ends.forEach(([, points], index) => capSeen[index] && seen[j] && creases.push(pathOf([points[j], points[(j + 1) % n]]))); } return { fill: pathOf(hull, true), outline: pathOf(hull, true), crease: creases.join(""), top: buckets[4].join(""), shades: buckets.slice(0, 4).map((list) => list.join("")) }; } export function stadium(r0, r1, offset, steps = 32) { const circle = (cx, r) => Array.from({ length: steps }, (_, index) => [cx + r * Math.cos((index / steps) * TAU), r * Math.sin((index / steps) * TAU)]); return hullOf([...circle(0, r0), ...circle(offset, r1)]); } export function sphereOf(centre, r, P, { steps = 48 } = {}) { const V = viewOf(P); const L = lightOf(P); const F = frameAlong(centre, V); const flat = (p) => iso(p, P); const rim = Array.from({ length: steps }, (_, index) => pointOf(F, 0, r, (index / steps) * TAU)); const outline = pathOf(rim.map(flat), true); const capOf = (threshold) => { const points = rim.filter((p) => dot3(sub3(p, centre), L) / r >= threshold).map(flat); const c = dot3(L, V); const LF = frameAlong(add3(centre, mul3(L, r * threshold)), L); const radius = r * Math.sqrt(Math.max(0, 1 - threshold * threshold)); for (let index = 0; index < steps; index++) { const p = pointOf(LF, 0, radius, (index / steps) * TAU); if (dot3(sub3(p, centre), V) >= -1e-9) points.push(flat(p)); } if (c > threshold) points.push(flat(add3(centre, mul3(V, r)))); return points.length > 2 ? pathOf(hullOf(points), true) : ""; }; const shades = [outline, capOf(BANDS[1]), capOf(BANDS[2]), capOf(BANDS[3])]; return { fill: outline, outline, crease: "", top: "", shades, seamless: true }; } export const disc = (s0, s1, r, F, P, options = {}) => lathe( [ [s0, 0], [s0, r], [s1, r], [s1, 0], ], F, P, { steps: stepsFor(r, 32, 160, scaleOf(P)), ...options }, ); export const ringBand = (s0, s1, rIn, rOut, F, P, options = {}) => lathe( [ [s0, rIn], [s0, rOut], [s1, rOut], [s1, rIn], ], F, P, { steps: stepsFor(rOut, 32, 160, scaleOf(P)), hole: null, ...options }, ); export function solidOf(profile, F, P, options = {}) { const first = profile[0]; const last = profile[profile.length - 1]; const meridian = [[first[0], 0], ...profile, [last[0], 0]]; const most = Math.max(...profile.map(([, r]) => r)); return lathe(meridian, F, P, { steps: stepsFor(most, 32, 160, scaleOf(P)), ...options }); } export function bandOfProfile(profile, F, P, options = {}) { const most = Math.max(...profile.map(([, r]) => r)); return lathe(profile, F, P, { steps: stepsFor(most, 32, 160, scaleOf(P)), ...options }); } export function silhouettePoints(profile, F, P, { samples = 72, slope: given = null, radius = null } = {}) { const V = viewOf(P); const aV = dot3(F.a, V); const uV = dot3(F.u, V); const vV = dot3(F.v, V); const facing = Math.atan2(vV, uV); const flat = Math.hypot(uV, vV) || 1e-9; const s0 = profile[0][0]; const s1 = profile[profile.length - 1][0]; const radiusAt = radius ?? ((s) => { let index = 1; while (index < profile.length - 1 && profile[index][0] < s) index++; const [sa, ra] = profile[index - 1]; const [sb, rb] = profile[index]; return ra + ((rb - ra) * (s - sa)) / (sb - sa || 1); }); const side = (sign) => { const points = []; for (let step = 0; step <= samples; step++) { const s = s0 + ((s1 - s0) * step) / samples; const h = (s1 - s0) / samples / 2 || 0.01; const slope = given ? given(s) : (radiusAt(Math.min(s1, s + h)) - radiusAt(Math.max(s0, s - h))) / (Math.min(s1, s + h) - Math.max(s0, s - h) || 1); const c = (slope * aV) / flat; if (Math.abs(c) > 1) continue; points.push(pointOf(F, s, radiusAt(s), facing + sign * Math.acos(c))); } return points; }; return [side(1), side(-1)]; } export const silhouetteOf = (profile, F, P, options = {}) => silhouettePoints(profile, F, P, options) .map((points) => pathOf(points.map((point) => iso(point, P)))) .join(""); export function profileCurve(profile, { slope = null, radius = null } = {}) { for (let index = 1; index < profile.length; index++) if (profile[index][0] - profile[index - 1][0] <= 1e-9) throw new Error(`profileCurve: s must rise along the profile (point ${index} at s = ${profile[index][0]})`); const segments = curveRuns(profile); const s0 = profile[0][0]; const s1 = profile[profile.length - 1][0]; const find = (s) => { let lo = 0; let hi = profile.length - 2; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (profile[mid][0] <= s) lo = mid; else hi = mid - 1; } return lo; }; const linear = (index, s) => { const [sa, ra] = profile[index]; const [sb, rb] = profile[index + 1]; return [ra + ((rb - ra) * (s - sa)) / (sb - sa), (rb - ra) / (sb - sa)]; }; const radiusAt = radius ?? ((s) => { const at = Math.max(s0, Math.min(s1, s)); const index = find(at); return segments[index] ? segments[index].radius(at) : linear(index, at)[0]; }); const slopeAt = slope ?? ((s) => { const at = Math.max(s0, Math.min(s1, s)); const index = find(at); return segments[index] ? segments[index].slope(at) : linear(index, at)[1]; }); return { s0, s1, radius: radiusAt, slope: slopeAt, analytic: Boolean(slope && radius) }; } export function bandsOf(profile, cuts, F, P, { slope = null, radius = null, steps, rows = 6, rims = "none", fill = "auto", samples = 72 } = {}) { const curve = profileCurve(profile, { slope, radius }); const { s0, s1 } = curve; const stops = [s0, ...[...new Set(cuts)].filter((s) => s > s0 + 1e-6 && s < s1 - 1e-6).sort((a, b) => a - b), s1]; const most = Math.max(...profile.map(([, r]) => r), ...stops.map((s) => curve.radius(s))); const n = steps ?? stepsFor(most, 32, 160, scaleOf(P)); const knot = (s, soft) => (soft ? [s, curve.radius(s), 1] : [s, curve.radius(s)]); const meridianOf = (a, b) => [knot(a), ...profile.filter(([s]) => s > a + 1e-9 && s < b - 1e-9).map(([s, , soft]) => knot(s, soft)), knot(b)]; const shading = { slope: curve.slope, radius: curve.radius, rows }; const bands = stops.slice(0, -1).map((a, index) => { const b = stops[index + 1]; const meridian = meridianOf(a, b); const paths = lathe(meridian, F, P, { steps: n, rims, fill, smooth: shading }); const seam = index < stops.length - 2 ? arcOf(F, b, curve.radius(b), P, { slope: curve.slope(b) }) : ""; return { s0: a, s1: b, profile: meridian, paths: { ...paths, outline: "" }, outline: paths.outline, seam }; }); const sidesOf = (from = s0, to = s1, { ends = false, count = samples } = {}) => { const span = [Math.max(s0, from), Math.min(s1, to)]; const dense = Array.from({ length: count + 1 }, (_, index) => knot(span[0] + ((span[1] - span[0]) * index) / count, true)); const points = silhouettePoints(dense, F, P, { samples: count, slope: curve.slope, radius: curve.radius }); const rim = (s) => arcOf(F, s, curve.radius(s), P, { slope: curve.slope(s) }); const rimsAt = ends === true ? span : ends === "first" ? [span[0]] : ends === "last" ? [span[1]] : []; return { d: points.map((list) => pathOf(list.map((point) => iso(point, P)))).join("") + rimsAt.map(rim).join(""), points }; }; return { bands, sidesOf, radius: curve.radius, slope: curve.slope, steps: n, s0, s1 }; } export function arcOf(F, s, r, P, { slope = 0, steps = 0, least = 0.02, inward = false, from = 0, to = TAU, trim = 0.3 } = {}) { const V = viewOf(P); const aV = dot3(F.a, V); const uV = dot3(F.u, V); const vV = dot3(F.v, V); const flat = Math.hypot(uV, vV); const phi = Math.atan2(vV, uV); const norm = Math.hypot(1, slope); const full = Math.abs(to - from - TAU) < 1e-9; let visible; if (flat < 1e-9) visible = (inward ? slope * aV / norm > least : -slope * aV / norm > least) ? [[phi, phi + TAU]] : []; else if (inward) { const c = (slope * aV - least * norm) / flat; visible = c <= -1 ? [] : c >= 1 ? [[phi, phi + TAU]] : [[phi + Math.acos(c), phi + TAU - Math.acos(c)]]; } else { const c = (least * norm + slope * aV) / flat; visible = c >= 1 ? [] : c <= -1 ? [[phi, phi + TAU]] : [[phi - Math.acos(c), phi + Math.acos(c)]]; } const at = (t) => iso(pointOf(F, s, r, t), P); const speed = (t) => { const a = at(t - 1e-3); const b = at(t + 1e-3); return Math.hypot(b[0] - a[0], b[1] - a[1]) / 2e-3; }; const count = (span) => Math.max(2, Math.ceil(((steps || stepsFor(r, 24, 112)) * span) / TAU), Math.ceil((span * r * scaleOf(P)) / 1.5)); const out = []; for (const [a, b] of visible) { if (b - a >= TAU - 1e-9 && full) { const n = count(TAU); out.push(pathOf(Array.from({ length: n }, (_, j) => at(from + (j / n) * TAU)), true)); continue; } const pieces = full ? [[a, b, true, true]] : []; if (!full) for (let turn = -2; turn <= 2; turn++) { const lo = Math.max(a + turn * TAU, from); const hi = Math.min(b + turn * TAU, to); if (hi - lo > 1e-6) pieces.push([lo, hi, lo > from + 1e-9, hi < to - 1e-9]); } for (const [lo, hi, limb0, limb1] of pieces) { const span = hi - lo; const cut0 = limb0 && trim > 0 ? Math.min(span * 0.25, trim / (speed(lo) || 1)) : 0; const cut1 = limb1 && trim > 0 ? Math.min(span * 0.25, trim / (speed(hi) || 1)) : 0; const t0 = lo + cut0; const t1 = hi - cut1; const n = count(t1 - t0); out.push(pathOf(Array.from({ length: n + 1 }, (_, j) => at(t0 + ((t1 - t0) * j) / n)))); } } return out.join(""); } export function circleOf(F, s, r, P, steps = 0) { const count = steps || stepsFor(r, 24, 112); return pathOf( Array.from({ length: count }, (_, j) => iso(pointOf(F, s, r, (j / count) * TAU), P)), true, ); } export function spokesOf(F, s, r0, r1, count, P, { phase = 0, sweep = 0, from = 0, to = TAU } = {}) { const lines = []; for (let index = 0; index < count; index++) { const t = phase + from + (index / count) * (to - from); lines.push(pathOf([iso(pointOf(F, s, r0, t), P), iso(pointOf(F, s, r1, t + sweep), P)])); } return lines.join(""); } export const fadeOf = (value, [lo, hi]) => { const t = Math.max(0, Math.min(1, (value - lo) / (hi - lo || 1e-9))); return t * t * (3 - 2 * t); }; export function ribsOf(F, s0, s1, r, count, P, { phase = 0, twist = 0, least = 0.12, r1 = r, fade = null, seams = false } = {}) { const V = viewOf(P); const lines = []; const slope = (r1 - r) / (s1 - s0 || 1); for (let index = 0; index < count; index++) { const t = phase + (index / count) * TAU; const radial = radialOf(F, t + twist / 2); const normal = unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]); const facing = dot3(normal, V); const d = pathOf([iso(pointOf(F, s0, r, t), P), iso(pointOf(F, s1, r1, t + twist), P)]); if (fade) { const alpha = fadeOf(facing, fade); if (alpha > 0.02) lines.push({ d, alpha }); continue; } if (facing < least) continue; lines.push(d); } const ends = seams ? [seams === "last" ? "" : arcOf(F, s0, r, P, { slope }), seams === "first" ? "" : arcOf(F, s1, r1, P, { slope })].filter(Boolean) : []; if (fade) return [...lines, ...ends.map((d) => ({ d, alpha: 1 }))]; return [...lines, ...ends].join(""); } export function rodOut(F, from, to, r, P, { stroke = 0, ...options } = {}) { const pad = 0.35 / scaleOf(P); const ring = (s) => Array.from({ length: 48 }, (_, index) => iso(pointOf(F, s, r + pad, (index / 48) * TAU), P)); return { paths: disc(from - stroke, to, r, F, P, options), clip: pathOf(hullOf([...ring(from), ...ring(to + stroke)]), true), from, to, stroke, reach: to + stroke, }; } export function dotsOf(F, s, r, count, P, { phase = 0, slope = 0, least = 0.08, all = false, fade = null } = {}) { const V = viewOf(P); const out = []; for (let index = 0; index < count; index++) { const t = phase + (index / count) * TAU; let alpha = 1; if (!all) { const radial = radialOf(F, t); const normal = unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]); const facing = dot3(normal, V); if (fade) alpha = fadeOf(facing, fade); else if (facing < least) continue; if (alpha <= 0.02) continue; } const [x, y] = iso(pointOf(F, s, r, t), P); out.push(alpha < 1 ? [x, y, alpha] : [x, y]); } return out; } export function facingOf(F, angle, P, slope = 0) { const radial = radialOf(F, angle); return dot3(unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]), viewOf(P)); } export function fillet(points, radius, perArc = 7, { P = null, tube = 0, margin = 1.3 } = {}) { if (points.length < 3) return points.map((p) => [...p]); const V = P && tube ? viewOf(P) : null; const out = [[...points[0]]]; for (let index = 1; index < points.length - 1; index++) { const prev = points[index - 1]; const here = points[index]; const next = points[index + 1]; const into = unit3(sub3(here, prev)); const outOf = unit3(sub3(next, here)); const cosTurn = Math.max(-1, Math.min(1, dot3(into, outOf))); const turn = Math.acos(cosTurn); if (turn < 1e-3) { out.push([...here]); continue; } const room = Math.min(len3(sub3(here, prev)), len3(sub3(next, here))) * 0.48; const facing = V ? Math.abs(dot3(unit3(cross3(into, outOf)), V)) : 1; const bend = V ? Math.max(radius, (margin * tube) / (0.7 * Math.max(0.05, facing) ** 2)) : radius; const cut = Math.min(bend * Math.tan(turn / 2), room); const a = sub3(here, mul3(into, cut)); const b = add3(here, mul3(outOf, cut)); const count = Math.max(2, Math.ceil((perArc * turn) / (Math.PI / 2))); for (let step = 0; step <= count; step++) { const t = step / count; const p0 = lerp3(a, here, t); const p1 = lerp3(here, b, t); out.push(lerp3(p0, p1, t)); } } out.push([...points[points.length - 1]]); return out; } export function resample(points, spacing) { const lengths = [0]; for (let index = 1; index < points.length; index++) lengths.push(lengths[index - 1] + len3(sub3(points[index], points[index - 1]))); const total = lengths[lengths.length - 1]; const count = Math.max(1, Math.round(total / spacing)); const out = []; let segment = 1; for (let step = 0; step <= count; step++) { const at = (total * step) / count; while (segment < points.length - 1 && lengths[segment] < at) segment++; const a = points[segment - 1]; const b = points[segment]; const span = lengths[segment] - lengths[segment - 1] || 1; out.push(lerp3(a, b, Math.min(1, Math.max(0, (at - lengths[segment - 1]) / span)))); } return out; } export function pathLength(points) { let total = 0; for (let index = 1; index < points.length; index++) total += len3(sub3(points[index], points[index - 1])); return total; } export function cut(points, from, to) { const out = []; let walked = 0; for (let index = 1; index < points.length; index++) { const a = points[index - 1]; const b = points[index]; const length = len3(sub3(b, a)); const start = walked; const end = walked + length; if (end >= from && start <= to) { const t0 = Math.max(0, (from - start) / (length || 1)); const t1 = Math.min(1, (to - start) / (length || 1)); if (!out.length) out.push(lerp3(a, b, t0)); out.push(lerp3(a, b, t1)); } walked = end; } return out; } export const pointAlong = (points, at) => { const piece = cut(points, at, at + 0.01); return piece[0] ?? points[points.length - 1]; }; export function tangentAlong(points, at) { const total = pathLength(points); const a = pointAlong(points, Math.max(0, at - 0.6)); const b = pointAlong(points, Math.min(total, at + 0.6)); return unit3(sub3(b, a)); } export function spiral(F, s, r0, r1, a0, a1, steps = 28) { const out = []; for (let index = 0; index <= steps; index++) { const t = index / steps; out.push(pointOf(F, s, r0 + (r1 - r0) * t, a0 + (a1 - a0) * t)); } return out; } export function arcPoints(F, s, r, a0, a1, steps = 24) { return Array.from({ length: steps + 1 }, (_, index) => pointOf(F, s, r, a0 + ((a1 - a0) * index) / steps)); }