1185 lines
47 KiB
JavaScript
1185 lines
47 KiB
JavaScript
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));
|
|
}
|