Files
Matthew Trahanandmatthewtrahan a59fb2ba6d Interface material: wall crests + tines as their own 3MF object for a toolchanger (#21) (#205)
* engine: interface crest (GitHub #21) -- with PROP.iface on, every wall (prop, part-attached, squat, Draw/fill, wedge blade) splits 0.6 mm under its top into body + crest, the crest and the tine comb tagged (v[3] = 1) so triRanges/removal/Worker are untouched; the 3MF writer's separate form writes the tagged triangles as a third object, '<name> interface', in register; off (the default) is byte-identical

* site: Interface material switch (Walls) -- the crest and tines show purple and export as their own 3MF object, for a toolchanger's second filament (PETG under PLA); reaches the Worker via tunables and Draw on the page; re-import still pre-selects the part; German strings; plugins leave it out (one body to the host)

* engine: interface crest review fixes -- sway brace tines are interface too (Auto and Draw); the crest is one trapezoid on the taper line, so no section has a collinear corner (zero-area cap triangles); Interface material is in the undo history; FIN-SPEC lists what is not split yet (sway rib edge, a part-standing wall's foot, a wedge whose contact steps straight up) (review)

* site: the interface is a part of the supports object, not its own -- an object starting in mid-air is refused by Orca ('empty first layer'); as the supports' second component it stands on the plate with them and takes its own filament per part

* engine: interface crest modes -- 'flat' (the default when on) crests a wall station only under an underside within 10deg of level, a full-width (th) 0.6 mm block with no tip taper, tines left in the body; 'all' crests every wall top and the tines as before. A 35deg cube's print with the crest everywhere couldn't lay PETG on the narrow PLA tip through every layer; PETG interfaces are used on flat undersides. Off stays byte-identical (sweep vs main)

* site: Interface material is a dropdown (Off / Flat contacts / Everywhere); the readout says when it is on and no wall got a crest; German strings

* engine: interface modes review fixes -- cutouts under a flat crest keep their old ceiling (holeTop: the taper start, not the crest's cut), so the split loses no plastic (lbracket Y8 lattice lost 4.6%); the crest's part reference is set only while the crest is on and dropped after each build; the readout's 'no flat contacts' note counts walls only (not the pad) and only in Flat contacts; tests: flat mode with lattice/diamond cutouts, mixed flat/tilted walls, squat walls (review)

* engine: the interface crest is one layer of the print (PROP.ifaceLayers 1, the Layer height field, as the tines), not a fixed 0.6 mm -- one tool change each way per flat contact instead of three; tooltip and FIN-SPEC say so; tests: the crest is one 0.2 / 0.3 mm layer

---------

Co-authored-by: matthewtrahan <mathyout@pm.me>
2026-10-09 22:00:37 -05:00

236 lines
12 KiB
JavaScript

// The interface crest (GitHub #21, web/prop/crest.js): with PROP.iface on, the top
// band of a wall ('flat': under a level underside; 'all': every wall, and the tines)
// is its own tagged solid, for a toolchanger's second material. Pinned here:
// - off, nothing is tagged (the rest of the suite pins that off is unchanged);
// - on, body and crest each come out closed and wound outward, and between them
// lose no plastic of the one-body build (a flat crest adds its untapered tip);
// - the crest is the band at the top: no body vertex rises into it;
// - the 3MF writes the crest as a third object, in register, and reads it back.
import { WEB, assert, block, blockTopo, loadModel, analyze, fins, prop, rotX, rotY, isClosed } from './_util.js';
const { writeThreeMF, readThreeMF } = await import(`${WEB}threemf.js`);
const { drawnWall } = await import(`${WEB}draw.js`);
const { CUT } = await import(`${WEB}cutout.js`);
const { PROP, splitInterface } = prop;
function build(name, rot, { iface, mode = 'auto', cutout = 'none', layerHeight } = {}) {
const topo = loadModel(name);
const was = [PROP.iface, CUT.pattern];
PROP.iface = iface;
CUT.pattern = cutout;
try {
// the app's call (CLAUDE.md): analyze(topo, 45, rot), then buildFins
return fins.buildFins(topo, analyze(topo, 45, rot), rot, { mode, bedPad: true, layerHeight }).triangles;
} finally {
[PROP.iface, CUT.pattern] = was;
}
}
/** Signed volume of a closed, outward-wound triangle soup. */
function volume(tris) {
let V = 0;
for (let i = 0; i < tris.length; i += 3) {
const [a, b, c] = [tris[i], tris[i + 1], tris[i + 2]];
V += (a[0] * (b[1] * c[2] - b[2] * c[1]) + a[1] * (b[2] * c[0] - b[0] * c[2])
+ a[2] * (b[0] * c[1] - b[1] * c[0])) / 6;
}
return V;
}
/** Triangles with (near) zero area: a slicer reports them as degenerate facets. */
function zeroArea(tris) {
let n = 0;
for (let i = 0; i < tris.length; i += 3) {
const [a, b, c] = [tris[i], tris[i + 1], tris[i + 2]];
const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const x = u[1] * v[2] - u[2] * v[1], y = u[2] * v[0] - u[0] * v[2], z = u[0] * v[1] - u[1] * v[0];
if (Math.hypot(x, y, z) / 2 < 1e-6) n++;
}
return n;
}
/** Off tags nothing; on splits the same plastic into a closed body and crest. */
function checkSplit(off, on) {
assert(off.length > 0, 'no supports to split');
assert(off.every((v) => v[3] === undefined), 'the crest is off but a vertex is tagged');
const { body, iface } = splitInterface(on);
assert(iface.length > 0, 'the crest is on but nothing is tagged');
assert(isClosed(body), 'the body is not closed');
assert(isClosed(iface), 'the crest is not closed');
const vb = volume(body), vi = volume(iface), v0 = volume(off);
assert(vb > 0 && vi > 0, `wound inward: body ${vb}, crest ${vi}`);
// the split adds no sliver the one-body build did not have (a collinear corner in
// a section made one in each ribbon end cap)
const z0 = zeroArea(off), zb = zeroArea(body), zi = zeroArea(iface);
assert(zb + zi <= z0, `zero-area triangles: ${z0} off, ${zb} body + ${zi} crest on`);
// the split loses no plastic; it only adds the tip a flat crest no longer narrows
// to (the top 1.5 mm of those walls kept full width: a few percent at most)
assert(vb + vi > 0.995 * v0 && vb + vi < 1.05 * v0,
`body ${vb.toFixed(1)} + crest ${vi.toFixed(1)} vs one body ${v0.toFixed(1)}`);
}
// Prop walls with tines (cube), a wedge (plate stood at 60), Full (lbracket), a
// cut wall (tshape, diamond cutouts), squat walls on the near-bed ledge (lowledge).
const CASES = [
['cube', rotX(35), {}],
['plate', rotX(60), {}],
['lbracket', rotX(40), { mode: 'full' }],
['tshape', rotX(30), { cutout: 'diamond' }],
['lowledge', rotX(0), {}],
];
for (const [name, rot, opts] of CASES) {
const label = `${name}${opts.mode ? ` ${opts.mode}` : ''}${opts.cutout ? ` ${opts.cutout}` : ''}`;
Deno.test(`interface crest: ${label}, everywhere -- no plastic lost, split into two closed solids`, () => {
checkSplit(build(name, rot, { ...opts, iface: false }), build(name, rot, { ...opts, iface: 'all' }));
});
}
// 'flat' on walls that mix flat and tilted stations, under cutouts (the holes keep
// their old ceiling: a flat crest's cut must not let them climb and take plastic),
// and on the near-bed ledge's squat walls.
const FLAT_CASES = [
['lbracket', rotY(8), { cutout: 'lattice' }],
['staircase', rotY(8), { cutout: 'lattice' }],
['plus', rotX(90), { cutout: 'diamond' }],
['lowledge', rotX(0), {}],
];
for (const [name, rot, opts] of FLAT_CASES) {
Deno.test(`interface crest: ${name}${opts.cutout ? ` ${opts.cutout}` : ''}, flat contacts -- no plastic lost, split into two closed solids`, () => {
checkSplit(build(name, rot, { ...opts, iface: false }), build(name, rot, { ...opts, iface: 'flat' }));
});
}
// 'flat': the portal on its side stands two walls under a flat ceiling -- the case
// a PETG interface is for -- and its crest keeps the wall's full width (no tip).
Deno.test('interface crest: flat contacts -- a flat ceiling gets a full-width crest', () => {
const rot = rotX(90);
const off = build('portal', rot, { iface: false });
const on = build('portal', rot, { iface: 'flat' });
checkSplit(off, on);
const { iface } = splitInterface(on);
const zTop = Math.max(...iface.map((v) => v[2]));
// the crest's top face near one point of one wall (the two stand 12 mm apart):
// the walls run along x or y, so the narrower of the two spans is its width
const top0 = iface.find((v) => zTop - v[2] < 1e-6);
const top = iface.filter((v) => zTop - v[2] < 1e-6 && Math.hypot(v[0] - top0[0], v[1] - top0[1]) < 3);
const span = (k) => Math.max(...top.map((v) => v[k])) - Math.min(...top.map((v) => v[k]));
const width = Math.min(span(0), span(1));
assert(Math.abs(width - PROP.th) < 1e-6, `crest top ${width.toFixed(3)} mm wide, want the wall's ${PROP.th}`);
});
// ...one layer of the print tall (PROP.ifaceLayers, the Layer height field): one
// tool change each way per flat contact. It reaches CREST_OVERLAP (0.01) into the body.
for (const layerHeight of [0.2, 0.3]) {
Deno.test(`interface crest: flat contacts -- the crest is one ${layerHeight} mm layer`, () => {
const { iface } = splitInterface(build('portal', rotX(90), { iface: 'flat', layerHeight }));
const zTop = Math.max(...iface.map((v) => v[2]));
const top0 = iface.find((v) => zTop - v[2] < 1e-6);
const near = iface.filter((v) => Math.hypot(v[0] - top0[0], v[1] - top0[1]) < 3);
const h = zTop - Math.min(...near.map((v) => v[2]));
assert(Math.abs(h - (layerHeight + 0.01)) < 1e-6, `crest ${h.toFixed(3)} mm tall, want ${layerHeight} + 0.01`);
});
}
// ...and a tilted underside gets none: on the 35deg cube every contact leans, and the
// first print showed PETG won't lay down on PLA climbing every layer. The tines stay
// in the body too (they grip sideways faces).
Deno.test('interface crest: flat contacts -- a tilted underside and its tines get none', () => {
const on = build('cube', rotX(35), { iface: 'flat' });
assert(on.every((v) => v[3] === undefined), 'a wall under the 35deg cube got a crest in flat mode');
const all = build('cube', rotX(35), { iface: 'all' });
assert(all.some((v) => v[3] === 1), 'everywhere mode left the cube bare');
});
// A wall standing ON the part (sweepBetween), lifted footGap off it and welded (0).
// An L: a base block the wall stands on (top z=5), a shelf 30 mm above it.
const L = new Float32Array([...block(-40, 40, -10, 10, 0, 5), ...block(-40, 40, -10, 10, 35, 39)]);
for (const footGap of [PROP.footGap, 0]) {
Deno.test(`interface crest: a wall on the part (footGap ${footGap}) -- no plastic lost, split`, () => {
const was = [PROP.footGap, PROP.iface];
PROP.footGap = footGap;
try {
const wall = (iface) => {
PROP.iface = iface;
const r = drawnWall([-30, 0, 35], [30, 0, 35], L, 0);
assert(r.ok, r.reason);
return r.tris;
};
checkSplit(wall(false), wall('flat')); // the shelf is a flat ceiling
} finally { [PROP.footGap, PROP.iface] = was; }
});
}
Deno.test('interface crest: the body stops under the crest of every wall', () => {
// Prop walls without tines, so every tagged solid is a wall's crest: under each
// crest's footprint, no body vertex stands higher than the crest's bottom.
const topo = loadModel('cube');
const rot = rotX(35);
PROP.iface = 'all';
let tris;
try {
tris = fins.buildFins(topo, analyze(topo, 45, rot), rot, { mode: 'auto', bedPad: true, tines: false }).triangles;
} finally { PROP.iface = false; }
const { body, iface } = splitInterface(tris);
assert(iface.length > 0, 'no crest');
// the crest's lowest point in each 0.25 mm cell of its footprint
const cells = new Map();
const key = (v) => `${Math.round(v[0] * 4)},${Math.round(v[1] * 4)}`;
for (const v of iface) {
const k = key(v);
cells.set(k, Math.min(cells.get(k) ?? Infinity, v[2]));
}
let worst = -Infinity;
for (const v of body) {
const lo = cells.get(key(v));
if (lo !== undefined) worst = Math.max(worst, v[2] - lo);
}
assert(worst <= 0.011, `a body vertex stands ${worst.toFixed(3)} mm into the crest`);
});
Deno.test('interface crest: everywhere, sway brace tines are interface too', () => {
// a 150 mm post, the tall part tests/sway.test.js braces
const topo = blockTopo(-20, 20, -15, 15, 0, 150);
const rot = rotX(0);
const res = analyze(topo, 45, rot);
const run = (iface) => {
PROP.iface = iface;
try {
return fins.buildFins(topo, res, rot, { mode: 'auto', bedPad: true, tines: true, layerHeight: 0.2, sway: { on: true } });
} finally { PROP.iface = false; }
};
const off = run(false), on = run('all');
assert(on.sway.tines > 0, `no sway tines to tag (${on.sway.reason ?? 'no brace'})`);
// with nothing overhanging on the post, every tagged triangle is a brace tine
const tagged = on.triangles.filter((v) => v[3] === 1).length / 3;
assert(tagged === on.sway.tines * 12, `${tagged} tagged triangles, want ${on.sway.tines} tines x 12`);
assert(off.triangles.every((v) => v[3] === undefined), 'off, a brace tine is tagged');
});
Deno.test('interface crest: the 3MF writes it as a part of the supports object, in register', async () => {
const on = build('cube', rotX(35), { iface: 'all' });
const { body, iface } = splitInterface(on);
const part = [[0, 0, 0], [1, 0, 0], [0, 1, 0]];
const bytes = new Uint8Array(await writeThreeMF(part, body, 'cube', { separate: true, iface }).arrayBuffer());
const xml = new TextDecoder().decode(bytes);
// two build items, so the crest -- in mid-air on its own -- stands on the plate
// with the supports (Orca refused a floating interface object: "empty first layer")
const items = [...xml.matchAll(/<item objectid="(\d+)" transform="([^"]+)"\/>/g)];
assert(items.length === 2, `${items.length} build items, want 2 (part, supports)`);
assert(items[0][2] === items[1][2], 'the part and supports do not share one transform');
const sup = xml.match(/<object id="(\d+)" type="model" name="cube supports"><components>(.*?)<\/components>/);
assert(sup && sup[1] === items[1][1], 'the supports item is not the body + interface assembly');
assert(xml.includes('name="cube interface"'), 'no "cube interface" part');
const r = await readThreeMF(bytes);
assert(r.objects.length === 2, `read ${r.objects.length} objects, want 2`);
// the supports object reads back as body + crest, every triangle of both
const tris = r.objects[1].positions.length / 9;
assert(tris === (body.length + iface.length) / 3, `supports read back ${tris} triangles, want ${(body.length + iface.length) / 3}`);
// locked (the CLI's form), the crest is just more of the supports
const locked = new TextDecoder().decode(new Uint8Array(
await writeThreeMF(part, body, 'cube', { iface }).arrayBuffer()));
assert(!locked.includes('interface'), 'the locked form wrote an interface object');
});