Skill Tracer
Skills › Anatomy › anatomy › tube.mjs

tube.mjs

306 lines · kit/tube.mjs at 1482e84

1import { iso, pathOf } from "./iso-kit.mjs";
2import * as G from "./lathe.mjs";
3
4const LIGHT = (() => {
5 const length = Math.hypot(-0.55, -0.835);
6 return [-0.55 / length, -0.835 / length];
7})();
8
9const poly = (points, closed = true) => pathOf(points, closed);
11const area = (points) => points.reduce((sum, [x, y], index) => {
12 const [nx, ny] = points[(index + 1) % points.length];
13 return sum + x * ny - nx * y;
14}, 0);
16function sideOf(frames) {
17 const out = { left: [], right: [], shineA: [], shineB: [], shadeA: [], shadeB: [] };
18 for (const { p, nx, ny, half } of frames) {
19 out.left.push([p[0] + nx * half, p[1] + ny * half]);
20 out.right.push([p[0] - nx * half, p[1] - ny * half]);
21 const toward = nx * LIGHT[0] + ny * LIGHT[1];
22 const shineAt = half * 0.44 * toward;
23 const shineHalf = half * 0.24;
24 out.shineA.push([p[0] + nx * (shineAt + shineHalf), p[1] + ny * (shineAt + shineHalf)]);
25 out.shineB.push([p[0] + nx * (shineAt - shineHalf), p[1] + ny * (shineAt - shineHalf)]);
26 const shadeAt = -half * 0.62 * toward;
27 const shadeHalf = half * 0.2;
28 out.shadeA.push([p[0] + nx * (shadeAt + shadeHalf), p[1] + ny * (shadeAt + shadeHalf)]);
29 out.shadeB.push([p[0] + nx * (shadeAt - shadeHalf), p[1] + ny * (shadeAt - shadeHalf)]);
30 }
31 return out;
34function capOf(centre, tangent, radius, inward, P) {
35 const V = G.viewOf(P);
36 let n1 = G.cross3(tangent, V);
37 if (G.len3(n1) < 1e-6) n1 = G.frameAlong([0, 0, 0], tangent).u;
38 n1 = G.unit3(n1);
39 const n2 = G.unit3(G.cross3(tangent, n1));
40 const steps = 28;
41 const ring = Array.from({ length: steps }, (_, index) => {
42 const angle = (index / steps) * Math.PI * 2;
43 return iso(G.add3(centre, G.add3(G.mul3(n1, radius * Math.cos(angle)), G.mul3(n2, radius * Math.sin(angle)))), P);
44 });
45 const c = iso(centre, P);
46 const probe = iso(G.add3(centre, n2), P);
47 const along = (probe[0] - c[0]) * inward[0] + (probe[1] - c[1]) * inward[1];
48 const from = along > 0 ? steps / 2 : 0;
49 const half = (start) => Array.from({ length: steps / 2 + 1 }, (_, index) => ring[(start + index) % steps]);
50 return { ring, arc: half(from), inner: half((from + steps / 2) % steps) };
53function stripeEnd(half, centre, nx, ny, from, to) {
54 const offset = (p) => (p[0] - centre[0]) * nx + (p[1] - centre[1]) * ny;
55 const values = half.map(offset);
56 const lo = Math.min(from, to);
57 const hi = Math.max(from, to);
58 const points = [];
59 const cross = (k, level) => {
60 const t = (level - values[k]) / (values[k + 1] - values[k] || 1e-9);
61 return [half[k][0] + (half[k + 1][0] - half[k][0]) * t, half[k][1] + (half[k + 1][1] - half[k][1]) * t];
62 };
63 for (let k = 0; k < half.length; k++) {
64 if (values[k] >= lo && values[k] <= hi) points.push(half[k]);
65 if (k < half.length - 1) for (const level of [lo, hi]) if ((values[k] - level) * (values[k + 1] - level) < 0) points.push(cross(k, level));
66 }
67 const order = points.map((p) => offset(p));
68 const sorted = points.map((p, index) => [p, order[index]]).sort((x, y) => (from > to ? y[1] - x[1] : x[1] - y[1]));
69 return sorted.map(([p]) => p);
72function spansOf(total, gaps, breaks, maxLength, closed) {
73 const cuts = gaps
74 .map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
75 .filter(([a, b]) => b - a > 1e-6)
76 .sort((x, y) => x[0] - y[0]);
77 const runs = [];
78 let at = 0;
79 for (const [a, b] of cuts) {
80 if (a > at + 1e-6) runs.push([at, a, at > 0, true]);
81 at = Math.max(at, b);
82 }
83 if (total > at + 1e-6) runs.push([at, total, at > 0, cuts.length > 0 && cuts[cuts.length - 1][1] >= total - 1e-6]);
84 const pieces = [];
85 for (const [from, to, gapStart, gapEnd] of runs) {
86 const stops = [from, ...breaks.filter((b) => b > from + 1e-6 && b < to - 1e-6).sort((x, y) => x - y), to];
87 for (let index = 0; index < stops.length - 1; index++) {
88 const a = stops[index];
89 const b = stops[index + 1];
90 const count = Math.max(1, Math.ceil((b - a) / maxLength - 1e-9));
91 for (let c = 0; c < count; c++) {
92 pieces.push({
93 from: a + ((b - a) * c) / count,
94 to: a + ((b - a) * (c + 1)) / count,
95 open0: !(index === 0 && c === 0),
96 open1: !(index === stops.length - 2 && c === count - 1),
97 gap0: index === 0 && c === 0 && gapStart,
98 gap1: index === stops.length - 2 && c === count - 1 && gapEnd,
99 });
100 }
101 }
102 }
103 if (closed && !cuts.length && pieces.length) {
104 pieces[0].open0 = true;
105 pieces[pieces.length - 1].open1 = true;
106 }
107 return pieces;
110export function runsOf(total, gaps = []) {
111 const cuts = gaps
112 .map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
113 .filter(([a, b]) => b - a > 1e-6)
114 .sort((x, y) => x[0] - y[0]);
115 const runs = [];
116 let at = 0;
117 for (const [a, b] of cuts) {
118 if (a > at + 1e-6) runs.push([at, a]);
119 at = Math.max(at, b);
120 }
121 if (total > at + 1e-6) runs.push([at, total]);
122 return runs;
125export function crossingsOf(points, centre, axis, radius) {
126 const n = G.unit3(axis);
127 const out = [];
128 let walked = 0;
129 for (let index = 1; index < points.length; index++) {
130 const a = points[index - 1];
131 const b = points[index];
132 const length = G.len3(G.sub3(b, a));
133 const da = G.dot3(G.sub3(a, centre), n);
134 const db = G.dot3(G.sub3(b, centre), n);
135 if ((da <= 0 && db > 0) || (da >= 0 && db < 0)) {
136 const t = da / (da - db);
137 const p = G.lerp3(a, b, t);
138 if (G.len3(G.sub3(p, centre)) <= radius) out.push(walked + t * length);
139 }
140 walked += length;
141 }
142 return out;
145function ringsOf(dense, total, radiusAt, P, span, { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16 } = {}) {
146 const V = G.viewOf(P);
147 const [lo, hi] = span;
148 const margin = twist ? 0 : Math.min(pitch * 0.35, (hi - lo) / 2);
149 const from = Math.max(lo, span.real0 ? lo + margin : lo);
150 const to = Math.min(hi, span.real1 ? hi - margin : hi);
151 if (to <= from) return [];
152 const out = [];
153 const hands = twist ? (cross ? [1, -1] : [1]) : [0];
154 const reach = Math.abs(twist) * Math.max(...[0, 0.5, 1].map(radiusAt));
155 const first = Math.ceil((from - reach - phase) / pitch);
156 const last = Math.floor((to + reach - phase) / pitch);
157 const surface = (t, theta) => {
158 const at = Math.max(0, Math.min(total, t));
159 const c = G.pointAlong(dense, at);
160 const T = G.tangentAlong(dense, at);
161 let n1 = G.cross3(T, V);
162 if (G.len3(n1) < 1e-6) return null;
163 n1 = G.unit3(n1);
164 let n2 = G.unit3(G.cross3(T, n1));
165 if (G.dot3(n2, V) < 0) n2 = G.mul3(n2, -1);
166 const r = radiusAt(at / (total || 1));
167 const normal = G.add3(G.mul3(n1, Math.cos(theta)), G.mul3(n2, Math.sin(theta)));
168 return { p: iso(G.add3(c, G.mul3(normal, r)), P), facing: G.dot3(normal, V), r };
169 };
170 for (let index = first; index <= last; index++) {
171 const station = phase + index * pitch;
172 for (const hand of hands) {
173 const samples = [];
174 for (let step = 0; step <= steps; step++) {
175 const theta = (step / steps) * Math.PI;
176 const t = station + hand * twist * radiusAt(Math.max(0, Math.min(1, station / (total || 1)))) * (step / steps - 0.5);
177 samples.push({ t, theta });
178 }
179 for (let step = 0; step < steps; step++) {
180 let a = samples[step];
181 let b = samples[step + 1];
182 if ((a.t < from && b.t < from) || (a.t > to && b.t > to)) continue;
183 const clipTo = (x, y, edge) => ({ t: edge, theta: x.theta + ((y.theta - x.theta) * (edge - x.t)) / (y.t - x.t || 1e-9) });
184 if (a.t < from) a = clipTo(a, b, from);
185 if (b.t < from) b = clipTo(b, a, from);
186 if (a.t > to) a = clipTo(a, b, to);
187 if (b.t > to) b = clipTo(b, a, to);
188 const pa = surface(a.t, a.theta);
189 const pb = surface(b.t, b.theta);
190 if (!pa || !pb) continue;
191 const alpha = fade ? G.fadeOf((pa.facing + pb.facing) / 2, fade) : 1;
192 if (alpha <= 0.02) continue;
193 out.push({ d: pathOf([pa.p, pb.p]), alpha });
194 }
195 }
196 }
197 return out;
200export function tubePieces(points3, radius, P, { maxLength = 30, spacing = 2.2, closed = false, breaks = [], gaps = [], caps = true, stripes = "auto", rings = null } = {}) {
201 const dense = G.resample(points3, spacing);
202 const total = G.pathLength(dense);
203 const radiusAt = typeof radius === "function" ? radius : () => radius;
204 const k = G.scaleOf(P);
205 const wide = stripes === "auto" ? Math.max(...Array.from({ length: 33 }, (_, index) => radiusAt(index / 32))) * k > 1.3 : Boolean(stripes);
206 const lead = 0.5;
207 const out = [];
208 const looped = closed && !gaps.length;
209 const wrap = (at) => (looped ? ((at % total) + total) % total : Math.max(0, Math.min(total, at)));
210 const step = Math.max(0.05, Math.min(spacing, total / 4) * 0.5);
211 const frameAt = (at, p, width) => {
212 const a = iso(G.pointAlong(dense, wrap(at - step)), P);
213 const b = iso(G.pointAlong(dense, wrap(at + step)), P);
214 let tx = b[0] - a[0];
215 let ty = b[1] - a[1];
216 const length = Math.hypot(tx, ty) || 1;
217 tx /= length;
218 ty /= length;
219 return { p, nx: -ty, ny: tx, half: width };
220 };
221 const partOf = (start, end) => {
222 let points = G.cut(dense, Math.max(0, start), Math.min(total, end));
223 if (looped && start < 0) points = [...G.cut(dense, total + start, total), ...points.slice(1)];
224 if (looped && end > total) points = [...points, ...G.cut(dense, 0, end - total).slice(1)];
225 let walked = looped ? start : Math.max(0, start);
226 const along = points.map((point, index) => {
227 if (index > 0) walked += G.len3(G.sub3(point, points[index - 1]));
228 return walked;
229 });
230 const flat = points.map((point) => iso(point, P));
231 const widths = along.map((at) => radiusAt(looped ? wrap(at) / total : Math.min(1, Math.max(0, at / total))) * k);
232 const frames = flat.map((p, index) => frameAt(along[index], p, widths[index]));
233 return { points, flat, widths, frames };
234 };
235 for (const piece of spansOf(total, gaps, breaks, maxLength, closed)) {
236 const openStart = piece.open0 && (piece.from > 0 || looped);
237 const openEnd = piece.open1 && (piece.to < total || looped);
238 const body = partOf(openStart ? piece.from - lead : piece.from, openEnd ? piece.to + lead : piece.to);
239 if (body.points.length < 2) continue;
240 const stripe = openStart || openEnd ? partOf(openStart ? piece.from - 2 * lead : piece.from, openEnd ? piece.to + 2 * lead : piece.to) : body;
241 const { points: part, flat, widths, frames } = body;
242 const sides = sideOf(frames);
243 const bands = stripe === body ? sides : sideOf(stripe.frames);
244 let bodyPath = poly([...sides.left, ...sides.right.slice().reverse()]);
245 let edges = pathOf(sides.left) + pathOf(sides.right);
246 const ends = [!piece.open0 && !looped && (piece.from <= 1e-6 || piece.gap0), !piece.open1 && !looped && (piece.to >= total - 1e-6 || piece.gap1)];
247 const capped = [ends[0] && (piece.gap0 || [caps].flat()[0]), ends[1] && (piece.gap1 || [caps].flat().at(-1))];
248 const sign = Math.sign(area([...sides.left, ...sides.right.slice().reverse()]));
249 const tips = [null, null];
250 [0, 1].forEach((side) => {
251 if (!capped[side]) return;
252 const at = side ? part.length - 1 : 0;
253 const other = side ? part.length - 2 : 1;
254 const tangent = G.unit3(G.sub3(part[side ? at : other], part[side ? other : at]));
255 const outward = side ? tangent : G.mul3(tangent, -1);
256 const inward = [flat[other][0] - flat[at][0], flat[other][1] - flat[at][1]];
257 const { ring, arc, inner } = capOf(part[at], tangent, widths[at] / k, inward, P);
258 const oriented = Math.sign(area(ring)) === sign ? ring : ring.slice().reverse();
259 bodyPath += poly(oriented);
260 edges += pathOf(arc);
261 const open = G.dot3(outward, G.viewOf(P)) > 0;
262 if (open) edges += pathOf(inner);
263 tips[side] = { half: open ? inner : arc, frame: frames[at] };
264 });
265 const band = (a, b, offsets) => {
266 const head = tips[1] ? stripeEnd(tips[1].half, tips[1].frame.p, tips[1].frame.nx, tips[1].frame.ny, offsets(tips[1].frame)[0], offsets(tips[1].frame)[1]) : [];
267 const tail = tips[0] ? stripeEnd(tips[0].half, tips[0].frame.p, tips[0].frame.nx, tips[0].frame.ny, offsets(tips[0].frame)[1], offsets(tips[0].frame)[0]) : [];
268 return poly([...a, ...head, ...b.slice().reverse(), ...tail]);
269 };
270 const toward = ({ nx, ny }) => nx * LIGHT[0] + ny * LIGHT[1];
271 const shineOffsets = (frame) => [frame.half * 0.44 * toward(frame) + frame.half * 0.24, frame.half * 0.44 * toward(frame) - frame.half * 0.24];
272 const shadeOffsets = (frame) => [-frame.half * 0.62 * toward(frame) + frame.half * 0.2, -frame.half * 0.62 * toward(frame) - frame.half * 0.2];
273 const ringSpan = Object.assign([openStart ? piece.from - 2 * lead : piece.from, openEnd ? piece.to + 2 * lead : piece.to], { real0: !openStart, real1: !openEnd });
274 out.push({
275 rings: rings ? ringsOf(dense, total, radiusAt, P, ringSpan, rings) : [],
276 ringTone: rings?.tone ?? "lo",
277 mid: G.pointAlong(dense, (piece.from + piece.to) / 2),
278 body: bodyPath,
279 shine: band(bands.shineA, bands.shineB, shineOffsets),
280 shade: band(bands.shadeA, bands.shadeB, shadeOffsets),
281 edges,
282 wide,
283 points: part,
284 radii: widths.map((width) => width / k),
285 ends,
286 from: piece.from,
287 to: piece.to,
288 });
289 }
290 return out;
293export function ringsSvg(rings, tone = "lo") {
294 const groups = new Map();
295 for (const { d, alpha } of rings ?? []) {
296 const level = Math.min(1, Math.round(alpha * 20) / 20);
297 if (level <= 0) continue;
298 groups.set(level, (groups.get(level) ?? "") + d);
299 }
300 return [...groups].map(([alpha, d]) => `<path class="tb-ring" data-tone="${tone}" d="${d}"${alpha < 1 ? ` opacity="${alpha.toFixed(2)}"` : ""}/>`).join("");
303export function tubeSvg(piece, { tone = "hi" } = {}) {
304 const shine = piece.wide ? `<path class="tb-shine" d="${piece.shine}"/><path class="tb-shade" d="${piece.shade}"/>` : "";
305 return `<g class="tb"><path class="tb-body" d="${piece.body}"/>${shine}${ringsSvg(piece.rings, piece.ringTone)}<path class="iso-line tb-edge" data-tone="${tone}" d="${piece.edges}"/></g>`;