Files
Mitch MilamandClaude Opus 5 b40a2ce54c engine: let a plugin ask for sway braces
The braces (web/sway.js) shipped on the site in #29, but no plugin could
reach them: computeFins forwards an explicit list of options to buildFins,
and `sway` was not on it, so every host silently got fins only.

It now forwards `sway` whole -- a host passes the same object the website's
options panel builds -- and reports the result apart from the fins:
swayBraces / swayTines / swaySkipped, plus swayReason for a host that asked
and got none. Off by default, so nothing changes for a caller that says
nothing: ENGINE_DEFAULTS.sway is null and the geometry is byte-identical.

Braces land in the same soup as the fins, so stats.finTriangles counts them
too; splitting them per body would need per-brace ranges this entry point
does not expose yet. Noted where it matters.

Tests: off by default is unchanged, { on: true } braces a tall post and says
so, the counts match the website brace for brace, tine spacing and "grip
from" reach the braces, and a part too short to brace reports why while its
fins still build. 26 pass in this suite, 200 across the JS suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 08:11:47 -05:00

193 lines
10 KiB
JavaScript

// The plugin entry must run the SAME engine as the website, and its output
// must keep every promise the upstream suite pins.
//
// In Orca the user poses the part with Orca's own gizmo, so the plugin hands the
// engine an ALREADY-POSED mesh parked somewhere on the plate. Two claims:
// 1. Same engine: for a posed mesh, the Orca path places the same fins as the
// website run on that same posed mesh, regardless of plate position.
// 2. Same promises: watertight, wall clears the part, tined fins placed, fins
// stand on the part's bed.
//
// deno test --allow-read plugins/shared/tests/
import { computeFins } from '../engine/fins_entry.js';
import { readSTL, MODELS, analyze, fins, rotX, rotY, assert, assertClose, block,
isClosed, insideCount } from '../../../tests/_util.js';
import { buildTopology, IDENTITY3 } from '../../../web/overhangs.js';
const OPTS = { mode: 'auto', bedPad: true, tines: true, tineDensity: 0, coverage: 0.5, layerHeight: 0.2 };
const topoOf = (pos) => buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
// three.js Matrix3.elements are column-major: v' = M v. Float64: Orca poses in double.
function bake(pos, m, dx = 0, dy = 0, dz = 0) {
const out = new Float64Array(pos.length);
for (let i = 0; i < pos.length; i += 3) {
const x = pos[i], y = pos[i + 1], z = pos[i + 2];
out[i] = m[0] * x + m[3] * y + m[6] * z + dx;
out[i + 1] = m[1] * x + m[4] * y + m[7] * z + dy;
out[i + 2] = m[2] * x + m[5] * y + m[8] * z + dz;
}
return out;
}
// What printfins.com does with an STL that was saved already posed (identity rotation).
function centred(pos) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity;
for (let i = 0; i < pos.length; i += 3) {
x0 = Math.min(x0, pos[i]); x1 = Math.max(x1, pos[i]);
y0 = Math.min(y0, pos[i + 1]); y1 = Math.max(y1, pos[i + 1]); z0 = Math.min(z0, pos[i + 2]);
}
const o = new Float64Array(pos.length);
for (let i = 0; i < pos.length; i += 3) {
o[i] = pos[i] - (x0 + x1) / 2; o[i + 1] = pos[i + 1] - (y0 + y1) / 2; o[i + 2] = pos[i + 2] - z0;
}
return o;
}
const flat = (b) => [...b.triangles, ...(b.padTriangles || [])].flatMap((v) => [v[0], v[1], v[2]]);
const PLATE = [137.25, 88.5, 3.0]; // an arbitrary spot on Orca's plate
const load = (name) => readSTL(Deno.readFileSync(`${MODELS}${name}.stl`));
const CASES = [['cube', 'X45', rotX(45)], ['ramp', 'X45', rotX(45)], ['wedge', 'X45', rotX(45)],
['lbracket', 'Y20', rotY(20)], ['lbracket', 'Y35', rotY(35)], ['lbracket', 'Y50', rotY(50)]];
for (const [name, pose, rot] of CASES) {
Deno.test(`${name}/${pose}: Orca path places the same fins as the website, anywhere on the plate`, () => {
// Bit-equality is NOT a fair bar: the upstream engine's tine placement moves
// under 1e-13 mm of coordinate noise (see ENGINE-SENSITIVITY.md -- lbracket@Y50
// goes 16 -> 19 tines between two float-identical-to-print poses). So we pin
// what matters: the same overhangs, the same number of fins, tines within 3.
const posedAtOrigin = centred(bake(load(name), rot));
const topo = topoOf(posedAtOrigin);
const r = analyze(topo, 45, IDENTITY3);
const web = fins.buildFins(topo, r, IDENTITY3, OPTS);
const got = computeFins(bake(load(name), rot, ...PLATE), OPTS);
assert(got.stats.overhangRegions === r.regions.length, 'overhang analysis differs');
assert(got.stats.braces === web.braceCount, `fin count ${got.stats.braces} vs ${web.braceCount}`);
assert(Math.abs(got.stats.tines - web.tines) <= 3, `tines ${got.stats.tines} vs ${web.tines}`);
});
Deno.test(`${name}/${pose}: upstream promises hold on the Orca path`, () => {
const posed = bake(load(name), rot, ...PLATE);
const res = computeFins(posed, OPTS);
assert(res.stats.braces >= 1, 'no fins placed');
assert(res.stats.tines >= 1, 'fins placed without tines (not gripping)');
const tri = (a) => { const v = []; for (let i = 0; i < a.length; i += 3) v.push([a[i], a[i + 1], a[i + 2]]); return v; };
const finV = tri(res.triangles.subarray(0, res.stats.finTriangles * 9));
const padV = tri(res.triangles.subarray(res.stats.finTriangles * 9));
assert(isClosed(finV), 'fin geometry is not closed');
assert(isClosed(padV), 'pad geometry is not closed');
// Walls only (no tines): must clear the part, exactly as upstream pins it.
const walls = computeFins(posed, { ...OPTS, tines: false });
const seated = centred(posed);
const inside = insideCount(topoOf(seated), IDENTITY3, { x: 0, y: 0, z: 0 },
tri(walls.triangles.subarray(0, walls.stats.finTriangles * 9)));
assert(inside === 0, `${inside} wall verts inside the part`);
});
Deno.test(`${name}/${pose}: offset maps fins back under the part on the plate`, () => {
const posed = bake(load(name), rot, ...PLATE);
const { triangles: all, offset, stats } = computeFins(posed, OPTS);
const triangles = all.subarray(0, stats.finTriangles * 9); // fin walls; the pad skirts wider
const off = [offset.x, offset.y, offset.z];
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
const plo = [Infinity, Infinity, Infinity], phi = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < triangles.length; i += 3) for (let k = 0; k < 3; k++) {
const w = triangles[i + k] - off[k];
lo[k] = Math.min(lo[k], w); hi[k] = Math.max(hi[k], w);
}
for (let i = 0; i < posed.length; i += 3) for (let k = 0; k < 3; k++) {
plo[k] = Math.min(plo[k], posed[i + k]); phi[k] = Math.max(phi[k], posed[i + k]);
}
assertClose(lo[2], plo[2], 1e-3, 'fin base is not on the part\'s bed');
assert(hi[2] <= phi[2] + 1e-3, 'fins rise above the part');
for (const k of [0, 1]) assert(lo[k] > plo[k] - 15 && hi[k] < phi[k] + 15, `fins off the part on axis ${k}`);
});
}
Deno.test('lbracket/Y35: website rot-param vs pre-posed: same overhangs and fins', () => {
const raw = load('lbracket'), rot = rotY(35), topo = topoOf(raw);
const r = analyze(topo, 45, rot), web = fins.buildFins(topo, r, rot, OPTS);
const got = computeFins(bake(raw, rot, ...PLATE), OPTS);
assert(got.stats.overhangRegions === r.regions.length, 'overhang analysis differs');
assert(got.stats.braces === web.braceCount, 'fin count differs');
assert(Math.abs(got.stats.tines - web.tines) <= 3, `tines ${got.stats.tines} vs ${web.tines}`);
});
Deno.test('moving a part around the plate never changes its fins', () => {
for (const [name, , rot] of CASES) {
const ref = computeFins(bake(load(name), rot, ...PLATE), OPTS).triangles;
for (const spot of [[0, 0, 0], [12.3456789, -250.1, 0], [300.07, 299.93, 0.4], [-0.000001, 55.5, 0]]) {
const t = computeFins(bake(load(name), rot, ...spot), OPTS).triangles;
assert(t.length === ref.length, `${name}: ${t.length / 9} vs ${ref.length / 9} tris at ${spot}`);
let worst = 0;
for (let i = 0; i < t.length; i++) worst = Math.max(worst, Math.abs(t[i] - ref[i]));
assert(worst < 1e-4, `${name}: fins moved by ${worst} mm at ${spot}`);
}
}
});
Deno.test('rejects malformed input', () => {
let threw = false;
try { computeFins(new Float32Array(10)); } catch { threw = true; }
assert(threw, 'accepted a soup that is not a multiple of 9');
});
// ---- sway braces -----------------------------------------------------------
// The website's braces (web/sway.js) are reachable through the entry point only
// because `sway` is on the options list it forwards: everything absent from that
// list is dropped before buildFins ever sees it, which is how the feature shipped
// on the site and stayed unreachable from every plugin.
/** A tall, slender post as a posed soup -- the case sway braces exist for. */
function post(h = 150) {
const pos = block(-20, 20, -15, 15, 0, h);
return Float64Array.from(pos);
}
Deno.test('sway: off by default -- a host that says nothing gets exactly what it got before', () => {
const soup = post();
const plain = computeFins(soup, OPTS);
const explicit = computeFins(soup, { ...OPTS, sway: null });
assert(plain.stats.swayBraces === 0, `braces nobody asked for (${plain.stats.swayBraces})`);
assert(plain.triangles.length === explicit.triangles.length, 'sway:null changed the output');
});
Deno.test('sway: { on: true } braces a tall part, and says so in the stats', () => {
const soup = post();
const off = computeFins(soup, OPTS);
const on = computeFins(soup, { ...OPTS, sway: { on: true } });
assert(on.stats.swayBraces >= 2, `expected braces, got ${on.stats.swayBraces} (${on.stats.swayReason})`);
assert(on.stats.swayTines > 0, 'braces placed without tines');
assert(on.triangles.length > off.triangles.length, 'braces were not added to the geometry');
// counted apart from the fins, which serve overhangs and are a different promise
assert(on.stats.braces === off.stats.braces, 'brace count leaked into the fin count');
});
Deno.test('sway: the engine agrees with the website, brace for brace', () => {
const soup = post();
const topo = topoOf(soup);
const web = fins.buildFins(topo, analyze(topo, OPTS.threshold ?? 45, IDENTITY3), IDENTITY3,
{ ...OPTS, sway: { on: true } });
const got = computeFins(soup, { ...OPTS, sway: { on: true } });
assert(got.stats.swayBraces === web.sway.count,
`braces ${got.stats.swayBraces} vs the website's ${web.sway.count}`);
assert(got.stats.swayTines === web.sway.tines,
`brace tines ${got.stats.swayTines} vs the website's ${web.sway.tines}`);
});
Deno.test('sway: the host\u2019s settings reach the braces', () => {
const soup = post();
const loose = computeFins(soup, { ...OPTS, sway: { on: true, tineSpacing: 20 } });
const tight = computeFins(soup, { ...OPTS, sway: { on: true, tineSpacing: 4 } });
assert(tight.stats.swayTines > loose.stats.swayTines,
`tine spacing ignored (${tight.stats.swayTines} at 4mm vs ${loose.stats.swayTines} at 20mm)`);
const shallow = computeFins(soup, { ...OPTS, sway: { on: true, gripFrom: 120 } });
assert(shallow.stats.swayTines < loose.stats.swayTines, '"grip from" ignored');
});
Deno.test('sway: a part too short to brace reports why, and still builds its fins', () => {
const short = Float64Array.from(block(-20, 20, -15, 15, 0, 20));
const r = computeFins(short, { ...OPTS, sway: { on: true } });
assert(r.stats.swayBraces === 0, `a 20mm part got ${r.stats.swayBraces} braces`);
assert(typeof r.stats.swayReason === 'string' && r.stats.swayReason,
'no reason given for placing none');
});