mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-22 03:42:40 -05:00
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>
193 lines
10 KiB
JavaScript
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');
|
|
});
|