Skill Tracer
Skills › Anatomy › anatomy › turn-fidelity.mjs

turn-fidelity.mjs

414 lines · scripts/turn-fidelity.mjs at 1482e84

1import * as k from "../kit/iso-kit.mjs";
2import * as G from "../kit/lathe.mjs";
3import { tubePieces } from "../kit/tube.mjs";
4import { TURN } from "../kit/turn.mjs";
5
6const rootOf = (group) => {
7 let root = group;
8 while (root && root.parent) root = root.parent;
9 return root && root.kind === "turn" ? root : null;
10};
11const restValues = (T, theta) => Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "turn" && !g.parent ? theta : g.kind === "free" ? null : 0]));
13export function identity(T, P, angles, { plant = null } = {}) {
14 const scene = TURN.prepare(T.data, { unit: 1 });
15const subpaths = (d) => {
16 const out = [];
17 for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
18 const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
19 const pts = [];
20 for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
21 out.push({ pts, closed: /Z\s*$/.test(body) });
22 }
23 return out;
24 };
25 const segDist = (p, a, b) => {
26 const dx = b[0] - a[0];
27 const dy = b[1] - a[1];
28 const l = dx * dx + dy * dy;
29 const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
30 return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
31 };
32 const toPoly = (pts, closed) => (pts, closed);
33 void toPoly;
34 const distToPoly = (p, poly, closed = true) => {
35 let best = Infinity;
36 const n = poly.length;
37 for (let i = 0; i < (closed ? n : n - 1); i++) best = Math.min(best, segDist(p, poly[i], poly[(i + 1) % n]));
38 if (n === 1) best = Math.hypot(p[0] - poly[0][0], p[1] - poly[0][1]);
39 return best;
40 };
41 const oneWay = (A, B) => {
42 let worst = 0;
43 for (const p of A) {
44 let near = null;
45 let gap = Infinity;
46 for (const q of B) {
47 const g = Math.hypot(p[0] - q[0], p[1] - q[1]);
48 if (g < gap) {
49 gap = g;
50 near = q;
51 }
52 }
53 worst = Math.max(worst, gap < 0.15 ? Math.max(Math.abs(p[0] - near[0]), Math.abs(p[1] - near[1])) : distToPoly(p, B));
54 }
55 return worst;
56 };
57 const hausdorff = (A, B) => Math.max(oneWay(A, B), oneWay(B, A));
58 const applyMatrix = (pts, t) => {
59 const v = (t.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
60 return pts.map(([x, y]) => [v[0] * x + v[2] * y + v[4], v[1] * x + v[3] * y + v[5]]);
61 };
62 const levelOf = (score, bands) => {
63 let u = 0;
64 for (const t of bands) {
65 const x = Math.min(1, Math.max(0, (score - (t - 0.035)) / 0.07));
66 u += x * x * (3 - 2 * x);
67 }
68 return u;
69 };
72const results = { outline: 0, top: 0, faces: 0, compared: 0, skipped: 0, missing: 0, crease: 0 };
73const worstAt = {};
74for (const theta of angles) {
75 const cams = TURN.cams(scene, restValues(T, theta));
76 const Pp = { ...P, azimuth: P.azimuth - theta };
77 for (const part of T.parts) {
78 if (part.kind !== "prism" || !rootOf(part.group) || part.group.kind === "free") continue;
79 const planted = plant && part.name === plant;
80 const run = scene.runs[part.id];
81 const cam = part.group ? cams[part.group.index] : cams.world;
82 const f = TURN.emit(run, cam, TURN.frame(run));
83 let root = part.group;
84 while (root && root.parent) root = root.parent;
85 const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
86 const a = k.iso(pivot, P);
87 const b = k.iso(pivot, Pp);
88 const shift = [a[0] - b[0], a[1] - b[1]];
89 const sh = (pts) => pts.map(([x, y]) => [x + shift[0], y + shift[1]]);
90 const { F, ring, s0, s1 } = part.world;
91 const up = run.up;
92 const Pk = planted ? { ...Pp, azimuth: Pp.azimuth - 2 } : Pp;
93 const pts = up ? ring.map(([x, y]) => G.add3(G.add3(F.o, G.mul3(F.a, F.a[2] < 0 ? s1 : s0)), G.add3(G.mul3(F.u, x), G.mul3(F.v, y)))) : null;
94 const kit = up ? k.extrude(pts.map(([x, y]) => [x, y]), pts[0][2], s1 - s0, Pk) : G.prismOf(F, ring, s0, s1, Pk);
95 const cut = Boolean(part.spec.cuts?.length);
96 const mine = subpaths(f.d[run.role[cut ? "fill" : "edge"]])[0].pts;
97 const theirs = sh(subpaths(kit.fill)[0].pts);
98 const visible = cut ? oneWay(subpaths(f.d[run.role.edge]).flatMap((path) => path.pts), theirs) : 0;
99 const ho = Math.max(hausdorff(mine, theirs), visible);
100 results.outline = Math.max(results.outline, ho);
101 if (ho > (worstAt[part.name]?.outline ?? -1)) worstAt[part.name] = { ...(worstAt[part.name] ?? {}), outline: ho, at: theta };
102 if (up) {
103 const topMine = applyMatrix(subpaths(part.planPath)[0].pts, f.t[run.role.top]);
104 const topKit = sh(subpaths(kit.top)[0].pts);
105 results.top = Math.max(results.top, hausdorff(topMine, topKit));
106 }
107 const kitFaces = kit.shades.map((d) => subpaths(d)).concat(up ? [] : [subpaths(kit.top)]);
108 const faceCount = up ? run.n : run.n + 2;
109 for (let i = 0; i < faceCount; i++) {
110 const role = run.role[`f${i}`];
111 const d = f.d[role];
112 if (!d) continue;
113 const q = f.q[role];
114 if (q % TURN.q !== 0) {
115 results.skipped++;
116 continue;
117 }
118 const tone = q / TURN.q;
119 const pts = subpaths(d)[0].pts;
120 let best = Infinity;
121 for (const sp of kitFaces[tone] ?? []) best = Math.min(best, hausdorff(pts, sh(sp.pts)));
122 if (!Number.isFinite(best)) {
123 results.missing++;
124 continue;
125 }
126 results.compared++;
127 results.faces = Math.max(results.faces, best);
128 }
129 }
132 const limit = 0.06;
133 const pass = results.outline <= limit && results.top <= limit && results.faces <= limit && results.missing === 0;
134 const worst = Object.entries(worstAt).filter(([, w]) => w.outline > limit).map(([name, w]) => `${name} outline ${w.outline.toFixed(3)} at ${w.at}°`);
135 return { pass, text: `prisms at ${angles.length} angles: outline ${results.outline.toFixed(4)} px · top ${results.top.toFixed(4)} px · faces ${results.faces.toFixed(4)} px over ${results.compared} faces (${results.skipped} in crossfade windows, ${results.missing} with no kit face of that tone)`, worst };
138export function rounds(T, P, angles, { only = null } = {}) {
139 const scene = TURN.prepare(T.data, { unit: 1 });
140 const CELL = 0.1;
141const subpaths = (d) => {
142 const out = [];
143 for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
144 const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
145 const pts = [];
146 for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
147 out.push(pts);
148 }
149 return out;
150 };
151 const segDist = (p, a, b) => {
152 const dx = b[0] - a[0];
153 const dy = b[1] - a[1];
154 const l = dx * dx + dy * dy;
155 const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
156 return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
157 };
158 const distToPoly = (p, poly) => {
159 let best = Infinity;
160 for (let i = 0; i < poly.length; i++) best = Math.min(best, segDist(p, poly[i], poly[(i + 1) % poly.length]));
161 return best;
162 };
163 const hausdorff = (A, B) => {
164 let worst = 0;
165 for (const p of A) worst = Math.max(worst, distToPoly(p, B));
166 for (const p of B) worst = Math.max(worst, distToPoly(p, A));
167 return worst;
168 };
169 function raster(layers, box, grow = 0) {
170 const [x0, y0, x1, y1] = box;
171 const w = Math.ceil((x1 - x0) / CELL) + 1;
172 const h = Math.ceil((y1 - y0) / CELL) + 1;
173 const out = new Int8Array(w * h).fill(-1);
174 for (const { tone, d } of layers) {
175 if (!d) continue;
176 const mask = new Uint8Array(w * h);
177 const polys = subpaths(d);
178 for (let row = 0; row < h; row++) {
179 const y = y0 + row * CELL + CELL / 2;
180 const hits = [];
181 for (const poly of polys)
182 for (let i = 0; i < poly.length; i++) {
183 const a = poly[i];
184 const b = poly[(i + 1) % poly.length];
185 if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) {
186 const x = a[0] + ((y - a[1]) / (b[1] - a[1])) * (b[0] - a[0]);
187 hits.push([x, b[1] > a[1] ? 1 : -1]);
188 }
189 }
190 hits.sort((p, q) => p[0] - q[0]);
191 let wind = 0;
192 for (let i = 0; i < hits.length - 1; i++) {
193 wind += hits[i][1];
194 if (!wind) continue;
195 const from = Math.max(0, Math.ceil((hits[i][0] - x0 - CELL / 2) / CELL));
196 const to = Math.min(w - 1, Math.floor((hits[i + 1][0] - x0 - CELL / 2) / CELL));
197 for (let c = from; c <= to; c++) mask[row * w + c] = 1;
198 }
199 }
200 const reach = Math.round(grow / CELL);
201 for (let row = 0; row < h; row++)
202 for (let c = 0; c < w; c++) {
203 let on = mask[row * w + c];
204 for (let dy = -reach; dy <= reach && !on; dy++)
205 for (let dx = -reach; dx <= reach && !on; dx++) {
206 if (dx * dx + dy * dy > reach * reach) continue;
207 const rr = row + dy;
208 const cc = c + dx;
209 if (rr >= 0 && cc >= 0 && rr < h && cc < w && mask[rr * w + cc]) on = 1;
210 }
211 if (on) out[row * w + c] = tone;
212 }
213 }
214 return { out, w, h };
215 }
217const stats = { outline: 0, outlineFine: 0, bands: 0, cells: 0, mismatched: 0, parts: 0 };
218const worst = [];
219for (const theta of angles) {
220 const cams = TURN.cams(scene, restValues(T, theta));
221 const Pp = { ...P, azimuth: P.azimuth - theta };
222 const Pf = { ...Pp, origin: [Pp.origin[0] * 10, Pp.origin[1] * 10], scale: Pp.scale * 10 };
223 for (const part of T.parts) {
224 if ((part.kind !== "round" && part.kind !== "lathe") || (only && !only.test(part.name)) || !rootOf(part.group) || part.group.kind === "free") continue;
225 const run = scene.runs[part.id];
226 const cam = part.group ? cams[part.group.index] : cams.world;
227 const f = TURN.emit(run, cam, TURN.frame(run));
228 let root = part.group;
229 while (root && root.parent) root = root.parent;
230 const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
231 const a = k.iso(pivot, P);
232 const b = k.iso(pivot, Pp);
233 const shift = [a[0] - b[0], a[1] - b[1]];
234 let F, s0, s1, r0, r1, ends, profile;
235 if (part.kind === "lathe") {
236 const { curve, knots } = part.world;
237 F = part.world.F;
238 s0 = curve.s0;
239 s1 = curve.s1;
240 r0 = curve.radius(s0);
241 r1 = curve.radius(s1);
242 ends = ["flat", "flat"];
243 profile = [[s0, r0], ...knots.slice(1, -1).map((sv) => [sv, curve.radius(sv), 1]), [s1, r1]];
244 } else ({ F, s0, s1, r0, r1, ends, profile } = part.world);
245 const meridian = [...(ends[0] === "open" ? [] : [[profile[0][0], 0]]), ...profile.filter((p) => p[1] > 1e-9), ...(ends[1] === "open" ? [] : [[profile[profile.length - 1][0], 0]])];
246 const domed = ends.includes("dome");
247 const radius = (s) => {
248 if (ends[0] === "dome" && s < s0) return Math.sqrt(Math.max(0, r0 * r0 - (s - s0) ** 2));
249 if (ends[1] === "dome" && s > s1) return Math.sqrt(Math.max(0, r1 * r1 - (s - s1) ** 2));
250 return r0 + ((r1 - r0) * (Math.max(s0, Math.min(s1, s)) - s0)) / (s1 - s0);
251 };
252 const slope = (s) => {
253 if (ends[0] === "dome" && s < s0) return -(s - s0) / Math.max(1e-4, radius(s));
254 if (ends[1] === "dome" && s > s1) return -(s - s1) / Math.max(1e-4, radius(s));
255 return (r1 - r0) / (s1 - s0);
256 };
257 const lo = ends[0] === "dome" ? s0 - r0 : s0;
258 const hi = ends[1] === "dome" ? s1 + r1 : s1;
259 const knots = [];
260 for (let i = 0; i <= 64; i++) knots.push(lo + ((hi - lo) * i) / 64);
261 const analytic = [[lo, 0], ...knots.slice(1, -1).map((sv) => [sv, radius(sv), 1]), [hi, 0]];
262 if (ends[0] !== "dome") analytic.splice(1, 0, [lo, r0]);
263 if (ends[1] !== "dome") analytic.splice(analytic.length - 1, 0, [hi, r1]);
264 const lathed = part.kind === "lathe" ? { slope: part.world.curve.slope, radius: part.world.curve.radius } : null;
265 const kit = lathed ? G.lathe(meridian, F, Pp, { smooth: lathed }) : domed ? G.lathe(analytic, F, Pp, { smooth: { slope, radius } }) : G.lathe(meridian, F, Pp, { smooth: true });
266 const kitFine = lathed ? G.lathe(meridian, F, Pf, { smooth: lathed }) : domed ? G.lathe(analytic, F, Pf, { smooth: { slope, radius } }) : G.lathe(meridian, F, Pf, { smooth: true });
267 const sh = (pts) => pts.map(([x, y]) => [x + shift[0], y + shift[1]]);
268 const cut = Boolean(part.spec.cuts?.length);
269 const mine = subpaths(f.d[run.role[cut ? "fill" : "edge"]])[0];
270 const theirs = sh(subpaths(kit.fill)[0]);
271 const fineRef = subpaths(kitFine.fill)[0].map(([x, y]) => [x / 10 + shift[0], y / 10 + shift[1]]);
272 const ho = hausdorff(mine, theirs);
273 const visible = cut ? subpaths(f.d[run.role.edge]).flat().reduce((worst, point) => Math.max(worst, distToPoly(point, fineRef)), 0) : 0;
274 const hf = Math.max(hausdorff(mine, fineRef), visible);
275 stats.outline = Math.max(stats.outline, ho);
276 stats.outlineFine = Math.max(stats.outlineFine, hf);
277 stats.parts++;
278 const ours = [0, 1, 2, 3, 4].map((t) => ({ tone: t, d: f.d[run.role[`s${t}`]] }));
279 for (const end of [0, 1]) {
280 const role = run.role[`k${end}`];
281 if (role === undefined || !f.d[role]) continue;
282 const q = f.q[role];
283 ours.push({ tone: q % TURN.q === 0 ? q / TURN.q : 9, d: f.d[role] });
284 }
285 const theirsLayers = [...kitFine.shades.map((d, t) => ({ tone: t, d })), { tone: 4, d: kitFine.top }].map(({ tone, d }) => ({ tone, d: subpaths(d).map((poly) => "M" + poly.map(([x, y]) => `${x / 10 + shift[0]} ${y / 10 + shift[1]}`).join("L") + "Z").join("") }));
286 let x0 = Infinity;
287 let y0 = Infinity;
288 let x1 = -Infinity;
289 let y1 = -Infinity;
290 for (const [x, y] of mine) {
291 x0 = Math.min(x0, x);
292 y0 = Math.min(y0, y);
293 x1 = Math.max(x1, x);
294 y1 = Math.max(y1, y);
295 }
296 const box = [x0 - 1, y0 - 1, x1 + 1, y1 + 1];
297 const A = raster(ours, box, 0.2);
298 const B = raster(theirsLayers, box, 0.2);
299 const inside = raster([{ tone: 1, d: f.d[run.role.edge] }], box).out;
300 const capCells = raster([0, 1].map((end) => ({ tone: 1, d: run.role[`k${end}`] !== undefined ? f.d[run.role[`k${end}`]] : "" })), box, 0.6).out;
301 const capExempt = part.kind === "lathe";
302 const insideKit = raster([{ tone: 1, d: "M" + fineRef.map(([x, y]) => `${x} ${y}`).join("L") + "Z" }], box).out;
303 let worstGap = 0;
304 const reach = Math.ceil(0.5 / CELL) + 2;
305 for (let row = 0; row < A.h; row++)
306 for (let c = 0; c < A.w; c++) {
307 const i = row * A.w + c;
308 if (inside[i] < 0 || insideKit[i] < 0) continue;
309 const ta = A.out[i];
310 const tb = B.out[i];
311 if (ta === 9 || ta < 0 || tb < 0) continue;
312 if (capExempt && capCells[i] >= 0) continue;
313 stats.cells++;
314 if (ta === tb) continue;
315 let edge = false;
316 for (let dy = -3; dy <= 3 && !edge; dy++) for (let dx = -3; dx <= 3; dx++) if (inside[(row + dy) * A.w + c + dx] < 0) edge = true;
317 if (edge) continue;
318 stats.mismatched++;
319 let near = Infinity;
320 for (let dy = -reach; dy <= reach; dy++)
321 for (let dx = -reach; dx <= reach; dx++) {
322 const rr = row + dy;
323 const cc = c + dx;
324 if (rr < 0 || cc < 0 || rr >= A.h || cc >= A.w) continue;
325 if (B.out[rr * A.w + cc] === ta) near = Math.min(near, Math.hypot(dx, dy) * CELL);
326 }
327 worstGap = Math.max(worstGap, near);
328 }
329 stats.bands = Math.max(stats.bands, worstGap);
330 if (worstGap > 0.5 || hf > 0.05) worst.push({ part: part.name, theta, band: worstGap, outline: hf });
331 }
334 return { pass: stats.outlineFine <= 0.05 && stats.bands <= 0.5, text: `rounds: ${stats.parts} frames at ${angles.length} angles: outline vs kit ${stats.outline.toFixed(4)} px (kit rounds to 0.1), vs kit at 10x ${stats.outlineFine.toFixed(4)} px · band boundaries ≤ ${stats.bands.toFixed(2)} px apart (${stats.mismatched} of ${stats.cells} interior cells differ)`, worst: worst.sort((a, b) => b.band - a.band || b.outline - a.outline).slice(0, 12).map((w) => `${w.part} at ${w.theta}°: outline ${w.outline.toFixed(3)} px, band ${w.band.toFixed(2)} px`) };
337export function tubes(T, P, angles) {
338 const scene = TURN.prepare(T.data, { unit: 1 });
339const subpaths = (d) => [...(d ?? "").matchAll(/M([^M]*)/g)].map(([, body]) => {
340 const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
341 const pts = [];
342 for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
343 return pts;
344 });
345 const segDist = (p, a, b) => {
346 const dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy;
347 const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
348 return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
349 };
350 const compare = (mine, theirs) => {
351 const A = subpaths(mine);
352 const B = subpaths(theirs);
353 if (A.length !== B.length) return { error: Infinity, why: `subpaths ${A.length} vs ${B.length}` };
354 let worst = 0;
355 for (let i = 0; i < A.length; i++) {
356 if (A[i].length === B[i].length) for (let j = 0; j < A[i].length; j++) worst = Math.max(worst, Math.abs(A[i][j][0] - B[i][j][0]), Math.abs(A[i][j][1] - B[i][j][1]));
357 else {
358 for (const [from, to] of [[A[i], B[i]], [B[i], A[i]]]) for (const p of from) worst = Math.max(worst, Math.min(...to.slice(1).map((q, j) => segDist(p, to[j], q))));
359 }
360 }
361 return { error: worst };
362 };
364const stats = { body: 0, shine: 0, shade: 0, edges: 0, rings: 0, chunks: 0 };
365const bad = [];
366for (const theta of angles) {
367 const cams = TURN.cams(scene, restValues(T, theta));
368 const Pp = { ...P, azimuth: P.azimuth - theta };
369 const Pf = { ...Pp, origin: [Pp.origin[0] * 10, Pp.origin[1] * 10], scale: Pp.scale * 10 };
370 for (const route of T.routes) {
371 if (route.group && (!rootOf(route.group) || route.group.kind === "free")) continue;
372 const root = rootOf(route.group);
373 const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
374 const a = k.iso(pivot, P);
375 const b = k.iso(pivot, Pp);
376 const shift = [a[0] - b[0], a[1] - b[1]];
377 const kit = tubePieces(route.world, route.r, Pf, { maxLength: route.chunk, spacing: route.spacing, gaps: route.gaps, breaks: route.breaks, caps: route.caps, stripes: route.wide, rings: route.rings });
378 const scaled = (d) => subpaths(d).map((pts) => "M" + pts.map(([x, y]) => `${x / 10 + shift[0]} ${y / 10 + shift[1]}`).join("L")).join("");
379 const chunks = T.parts.filter((part) => part.route === route.name);
380 if (kit.length !== chunks.length) {
381 bad.push(`${route.name}: ${chunks.length} chunks vs kit ${kit.length}`);
382 continue;
383 }
384 chunks.forEach((part, index) => {
385 const run = scene.runs[part.id];
386 const f = TURN.emit(run, route.group ? cams[route.group.index] : cams.world, TURN.frame(run));
387 const piece = kit[index];
388 stats.chunks++;
389 for (const key of ["body", "shine", "shade", "edges"]) {
390 if (run.role[key] === undefined) continue;
391 const { error, why } = compare(f.d[run.role[key]], scaled(piece[key]));
392 stats[key] = Math.max(stats[key], error);
393 if (error > 0.05) bad.push(`${part.name} ${key} at ${theta}°: ${why ?? error.toFixed(3)}`);
394 }
395 if (route.rings) {
396 const mine = run.roles.filter((role) => role.name.startsWith("r")).map((role) => f.d[run.role[role.name]]).join("");
397 const theirs = scaled(piece.rings.filter((ring) => ring.alpha * 32 >= 0.5).map((ring) => ring.d).join(""));
398 const A = subpaths(mine);
399 const B = subpaths(theirs);
400 let worst = A.length === B.length ? 0 : Infinity;
401 for (const seg of A) {
402 let best = Infinity;
403 for (const other of B) best = Math.min(best, Math.max(...seg.map((p, j) => Math.hypot(p[0] - other[j][0], p[1] - other[j][1]))));
404 worst = Math.max(worst, best);
405 }
406 stats.rings = Math.max(stats.rings, worst);
407 if (worst > 0.05) bad.push(`${part.name} rings at ${theta}°: ${A.length} vs ${B.length} segments, worst ${worst.toFixed(3)}`);
408 }
409 });
410 }
413 return { pass: !bad.length, text: `tubes: ${stats.chunks} chunk frames at ${angles.length} angles against tubePieces at 10x: body ${stats.body.toFixed(4)} · shine ${stats.shine.toFixed(4)} · shade ${stats.shade.toFixed(4)} · edges ${stats.edges.toFixed(4)} · rings ${stats.rings.toFixed(4)} px`, worst: bad.slice(0, 20) };