engine: branching (experimental) -- tall neighbouring walls share one trunk with 30deg arms, opt-in in Auto

Ported from the branching spike (prototype/branch, local issue 008 / #8) after the
bunny X30 reprint held. fins/branching.js regroups Auto's tall walls onto trunks;
plate trunks stand on a one-layer pad; arms keep the walls' top lines and their own
tines (prop.js records tineOff so they are copied, not re-emitted). Off by default:
sweep vs main unchanged.
This commit is contained in:
matthewtrahan committed 2026-09-29 07:43:32 -05:00
1 parent aef3484627
commit cc0538ff28
5 files changed
+368 -1

No files matched your search

+10
View File
@@ -138,6 +138,16 @@ same `stepObjects()` the app's worker output goes through):
- the imported part runs through `analyze` + `buildFins` and gets a fin.
Fixtures (`tests/fixtures/*.step`) are made with FreeCAD.
**`branching.test.js`** -- experimental branching (`web/fins/branching.js`), stress
torus + tube at X30:
- **off by default** (and ignored outside Auto): the build is unchanged;
- fin ranges still **cover the build exactly** (per-fin removal keeps working);
- the arms keep the walls' top lines and **their own tines** -- the count is unchanged
(re-emitting against the whole part lost 6 of 54 on the torus);
- trunk and arms **never fuse into the part**; a plate trunk stands on a **one-layer pad**
(print 1 fell off the plate without it);
- a trunk can carry **two walls** (the tube).
**`sway.test.js`** -- sway braces (`web/sway.js`) on a 150 mm post (plain blocks,
no stress models needed):
- a tall part gets braces, watertight, and the **rib never fuses** into the part;
+76
View File
@@ -0,0 +1,76 @@
// Branching (web/fins/branching.js, experimental): tall neighbouring walls regrouped
// onto one trunk with leaning arms. Built on the stress torus and tube at X30, the
// committed models where it fires (the curved figures it was printed on are not in git).
// - off by default: buildFins without the option is unchanged;
// - every fin's triangle ranges still cover the build exactly (per-fin removal);
// - the arms keep the walls' top lines, so the tine count is unchanged;
// - trunk and arms never fuse into the part -- only the tines bite in;
// - a plate trunk stands on a one-layer pad;
// - a group can carry more than one wall (the tube's two bore-side walls).
import { loadModel, analyze, fins, insideCount, rotX, assert } from './_util.js';
function build(name, rot, opts = {}) {
const topo = loadModel(name);
const res = analyze(topo, 45, rot);
const built = fins.buildFins(topo, res, rot, { mode: 'auto', bedPad: true, tines: true, ...opts });
return { topo, res, built };
}
const R30 = rotX(30);
const branches = (b) => b.fins.filter((f) => f.kind === 'branch');
const trisOf = (b, f) => f.triRanges.flatMap(([s, e]) => b.triangles.slice(s, e));
Deno.test('branching: off by default -- the build is unchanged', () => {
const a = build('torus', R30).built, b = build('torus', R30, { branching: false }).built;
assert(a.branching === undefined, 'no branching report when the option is off');
assert(a.triangles.length === b.triangles.length, 'same triangles with the option off');
assert(!branches(a).length, 'no branch fins');
});
Deno.test('branching: draw/prop mode ignores the option', () => {
const { built } = build('torus', R30, { mode: 'prop', branching: true });
assert(!branches(built).length && built.branching === undefined, 'prop mode must not branch');
});
for (const name of ['torus', 'tube']) {
Deno.test(`branching ${name}/X30: fin ranges cover the build exactly`, () => {
const { built } = build(name, R30, { branching: true });
assert(built.branching.groups > 0, `${name} X30 should branch (got ${JSON.stringify(built.branching)})`);
const n = built.triangles.length, seen = new Uint8Array(n);
for (const f of built.fins) for (const [s, e] of f.triRanges) {
assert(s >= 0 && e <= n && s <= e && (e - s) % 3 === 0, `fin ${f.id} range [${s},${e}) out of bounds`);
for (let i = s; i < e; i++) { assert(!seen[i], `vertex ${i} claimed twice`); seen[i] = 1; }
}
assert(seen.every((v) => v), 'every vertex belongs to some fin');
assert(new Set(built.fins.map((f) => f.id)).size === built.fins.length, 'fin ids unique');
});
Deno.test(`branching ${name}/X30: the grip is unchanged (same tines)`, () => {
const plain = build(name, R30).built, br = build(name, R30, { branching: true }).built;
assert(br.tines === plain.tines, `tines ${plain.tines} -> ${br.tines}: arms keep the walls' top lines`);
});
Deno.test(`branching ${name}/X30: trunk and arms never fuse into the part`, () => {
const { topo, res, built } = build(name, R30, { branching: true, tines: false });
for (const f of branches(built)) {
const inside = insideCount(topo, R30, res.offset, trisOf(built, f));
assert(inside === 0, `branch ${f.id}: ${inside} verts inside the part`);
}
});
}
Deno.test('branching: a plate trunk stands on a one-layer pad', () => {
const { built } = build('torus', R30, { branching: true, tines: false, layerHeight: 0.2 });
const plate = branches(built).filter((f) => !f.onPart);
assert(plate.length, 'torus X30 has a plate trunk');
for (const f of plate) {
const pad = trisOf(built, f).filter((v) => v[2] <= 0.2 + 1e-9);
assert(pad.some((v) => Math.abs(v[2] - 0.2) < 1e-9) && pad.some((v) => Math.abs(v[2]) < 1e-9),
`branch ${f.id}: no one-layer pad at z 0..0.2`);
}
});
Deno.test('branching: the tube groups two walls onto one trunk', () => {
const { built } = build('tube', R30, { branching: true });
assert(branches(built).some((f) => f.arms >= 2), 'expected a trunk carrying two arms');
});
+9 -1
View File
@@ -25,6 +25,7 @@
* seating.js how the part sits on the plate: contact, face/edge/point
* pad.js PAD and the bed pad (conforming oval, or the brim-style one)
* wedges.js angled wedges where no wall reaches; gripPatches for Draw
* branching.js experimental: tall neighbouring walls regrouped onto one trunk + arms
*
* Each module imports only modules above it in this list and never fins.js.
*/
@@ -36,6 +37,7 @@ import { CUT, CUTOUT_PATTERNS } from './cutout.js';
import { FIN } from './fins/config.js';
import { buildPad, PAD } from './fins/pad.js';
import { bedContact, seatedPartTris, seatingOf } from './fins/seating.js';
import { branchWalls } from './fins/branching.js';
import { buildPerpFins, PERP, propServesPatch, unservedAfterWedges, wedgeVeto } from './fins/wedges.js';
// Moved into web/fins/ (one module per concern); re-exported here so every
@@ -43,6 +45,7 @@ import { buildPerpFins, PERP, propServesPatch, unservedAfterWedges, wedgeVeto }
export { FIN } from './fins/config.js';
export { PAD } from './fins/pad.js';
export { gripPatches, perpColumns } from './fins/wedges.js';
export { BRANCH } from './fins/branching.js';
/**
* Wedge row pitch for a coverage setting, mirroring prop.js's coverRowSpan: 0.5 is
@@ -97,6 +100,7 @@ export function applyTunables(t) {
* wedges under the faces no wall reached). 'stabilize' is
* an old name for 'auto'; any other mode builds 'prop'.
* @param opts.bedPad add the pad when bed contact is too small to hold
* @param opts.branching experimental, Auto only: regroup tall walls onto trunks (fins/branching.js)
*/
export function buildFins(topo, result, rot, opts = {}) {
const built = buildFinsAndBraces(topo, result, rot, opts);
@@ -108,7 +112,11 @@ export function buildFins(topo, result, rot, opts = {}) {
function buildFinsAndBraces(topo, result, rot, opts = {}) {
applyTunables(opts.tunables);
const built = buildFinsCore(topo, result, rot, opts);
let built = buildFinsCore(topo, result, rot, opts);
// Branching rewrites Auto's walls, so it runs before the braces stand clear of them.
if (opts.branching && (built.mode === 'auto' || built.mode === 'stabilize')) {
built = branchWalls(topo, result, rot, built, opts);
}
// Sway braces are an optional ADD-ON to whatever the mode placed (sway.js): a
// tall part still needs its overhangs held, and bracing its sides is a
// separate job on separate faces.
+268
View File
@@ -0,0 +1,268 @@
/**
* BRANCHING (experimental, local issue 008 / #8): "walls off walls". Take the tall
* walls the Auto pass placed, group near neighbours, and rebuild each group as ONE
* trunk wall with leaning ARMS -- one solid, far less bed and usually less plastic.
*
* An arm is a ruled sheet from the original wall's top line (unchanged, so the held
* area and the tines are the same) down to the trunk's top line J, leaning at most
* LEAN from vertical at every station. The trunk is a normal wall under J (plate:
* sweep with its foot plus a one-layer pad; part: sweepBetween). A group of one
* whose trunk comes out shorter than its wall is the FAN wall.
*
* Opt-in (opts.branching, Auto only). Ported from prototype/branch/spike.js after
* the bunny X30 reprint held (2026-09-29); print 1 fell off the plate, which is why
* the footprint answers to the whole group (TOTAL_SLENDER) and plate trunks get a pad.
*
* Imports only the prop pieces, solids and inside -- never fins.js.
*/
import { PROP } from '../prop.js';
import { sweep, sweepBetween } from '../prop/sweep.js';
import { floorLine } from '../prop/attached.js';
import { emitTines, tineStepFor } from '../prop/tines.js';
import { ribbon } from '../solids.js';
import { solidClearance } from '../inside.js';
import { seatedPartTris } from './seating.js';
export const BRANCH = {
lean: 30, // deg from vertical an arm may lean (Matthew: err safe -- 90%+ of printers)
minH: 15, // only walls this tall are worth a trunk
minTrunk: 5, // trunk height above its floor
reach: 25, // plan distance to consider a neighbour
minJ: 3, // shortest trunk line (mm)
trunkSlender: 5, // trunk height <= 5 x its length (coupon: 7:1 stood, err safe)
totalSlender: 5, // floor to highest arm top <= 5 x trunk length (print 1 fell over)
padEnd: 3, // plate pad runs this far past each trunk end
padMinHalf: 4, // pad half-width floor (the foot flange is <= 3)
tries: 12, // cheapest trunk placements tried before a group gives up
};
const planDist = (A, B) => Math.min(...A.flatMap((p) => B.map((q) => Math.hypot(p[0] - q[0], p[1] - q[1]))));
/**
* Where each arm station lands on the trunk line: the arm's own extent along u
* mapped linearly onto [a0, a1], so no two stations share a bottom point --
* clamping pinned every station past J's end to one point and the arm folded flat
* (zero-volume scraps). An arm square to J spreads by station index instead.
*/
function armBottoms(line, J) {
const proj = line.map((p) => (p[0] - J.cx) * J.u[0] + (p[1] - J.cy) * J.u[1]);
const lo = Math.min(...proj), hi = Math.max(...proj);
return line.map((p, i) => {
const f = hi - lo > 0.5 ? (proj[i] - lo) / (hi - lo) : i / Math.max(1, line.length - 1);
const t = J.a0 + (J.a1 - J.a0) * f;
return [J.cx + J.u[0] * t, J.cy + J.u[1] * t];
});
}
/** Trunk lines J for arms `walls`, cheapest first: a few directions x lengths. */
function solveJ(walls, floorZ) {
const tan = Math.tan(BRANCH.lean * Math.PI / 180), cosL = Math.cos(BRANCH.lean * Math.PI / 180);
const pts = walls.flatMap((w) => w.line);
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length;
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length;
// candidate directions: each wall's run, the PCA axis, and their perpendiculars
const dirs = [];
for (const w of walls) {
const a = w.line[0], b = w.line[w.line.length - 1], n = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (n > 1e-6) dirs.push([(b[0] - a[0]) / n, (b[1] - a[1]) / n]);
}
let sxx = 0, sxy = 0, syy = 0;
for (const p of pts) { const x = p[0] - cx, y = p[1] - cy; sxx += x * x; sxy += x * y; syy += y * y; }
const th = 0.5 * Math.atan2(2 * sxy, sxx - syy);
dirs.push([Math.cos(th), Math.sin(th)]);
for (const d of [...dirs]) dirs.push([-d[1], d[0]]);
const cands = [];
const topZ = Math.max(...pts.map((p) => p[2]));
const minL = (topZ - floorZ) / BRANCH.totalSlender;
for (const u of dirs) {
const proj = pts.map((p) => (p[0] - cx) * u[0] + (p[1] - cy) * u[1]);
const lo = Math.min(...proj), hi = Math.max(...proj);
// side reach: how far the arm tops sit off the trunk's line, for the pad's width
const side = Math.max(...pts.map((p) => Math.abs((p[0] - cx) * -u[1] + (p[1] - cy) * u[0])));
for (const f of [1, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0]) {
const L = Math.max(BRANCH.minJ, minL, (hi - lo) * f), mid = (lo + hi) / 2;
const a0 = mid - L / 2, a1 = mid + L / 2;
let zJ = Infinity, armArea = 0;
for (const w of walls) {
const bot = armBottoms(w.line, { u, cx, cy, a0, a1 });
w.line.forEach((p, i) => { zJ = Math.min(zJ, p[2] - Math.hypot(p[0] - bot[i][0], p[1] - bot[i][1]) / tan); });
}
if (zJ - floorZ < BRANCH.minTrunk) continue;
if (zJ - floorZ > BRANCH.trunkSlender * L) continue;
for (const w of walls) {
for (let i = 0; i + 1 < w.line.length; i++) {
const p = w.line[i], q = w.line[i + 1];
armArea += Math.hypot(q[0] - p[0], q[1] - p[1]) * ((p[2] + q[2]) / 2 - zJ);
}
}
// sheet area is what costs plastic; arms lean, so 1/cos(lean) bounds their length
const cost = armArea / cosL + L * (zJ - floorZ);
cands.push({ u, cx, cy, a0, a1, L, zJ, cost, side });
}
}
return cands.sort((a, b) => a.cost - b.cost);
}
/** Arm solid: one ribbon from the wall's top line down to J, tip-wide at top, th-wide at J. */
function buildArm(line, J, out) {
const secs = [], bot = armBottoms(line, J);
for (let i = 0; i < line.length; i++) {
const p = line[i];
const a = line[Math.max(0, i - 1)], b = line[Math.min(line.length - 1, i + 1)];
let rx = b[0] - a[0], ry = b[1] - a[1];
const rn = Math.hypot(rx, ry) || 1; rx /= rn; ry /= rn;
const sx = ry, sy = -rx;
const j = [bot[i][0], bot[i][1], J.zJ];
const tw = PROP.tip / 2, bw = PROP.th / 2;
secs.push([
[p[0] + sx * tw, p[1] + sy * tw, p[2]], [p[0] - sx * tw, p[1] - sy * tw, p[2]],
[j[0] - sx * bw, j[1] - sy * bw, j[2] - 0.5], [j[0] + sx * bw, j[1] + sy * bw, j[2] - 0.5],
]);
}
ribbon(secs, out);
}
/** One-layer pad under a plate trunk: grips the bed, snaps off like a brim. */
function buildPad(J, padH, out) {
const W = Math.max(BRANCH.padMinHalf, J.side), sx = J.u[1], sy = -J.u[0];
const sec = (t) => { const x = J.cx + J.u[0] * t, y = J.cy + J.u[1] * t;
return [[x + sx * W, y + sy * W, padH], [x - sx * W, y - sy * W, padH], [x - sx * W, y - sy * W, 0], [x + sx * W, y + sy * W, 0]]; };
ribbon([sec(J.a0 - BRANCH.padEnd), sec(J.a1 + BRANCH.padEnd)], out);
}
/** Trunk (+ pad) then arms into `out`; returns the trunk's triangle count, 0 if it failed. */
function buildGroup(g, J, out, ctx) {
const n = Math.max(PROP.minStations, Math.round(J.L / PROP.stationStep) + 1);
const jl = [];
for (let k = 0; k < n; k++) {
const t = J.a0 + (J.a1 - J.a0) * k / (n - 1);
jl.push([J.cx + J.u[0] * t, J.cy + J.u[1] * t, J.zJ + PROP.gap]); // "surface" line: top = z - gap
}
const before = out.length;
const ok = g.onPart ? sweepBetween(jl, floorLine(jl, ctx.partTris), out) : sweep(jl, 0, out);
if (!ok) { out.length = before; return 0; }
if (!g.onPart) buildPad(J, ctx.padH, out);
const nTrunk = out.length - before;
for (const w of g.walls) buildArm(w.line, J, out);
return nTrunk;
}
/** The cheapest trunk placement whose trunk + arms clear the part, or null. */
function placeGroup(g, ctx) {
for (const J of solveJ(g.walls, g.floorZ).slice(0, BRANCH.tries)) {
const tris = [];
const nTrunk = buildGroup(g, J, tris, ctx);
if (!nTrunk) continue;
// solidClearance reports only the CLOSEST contact. A part trunk is welded at its
// foot by design, so checking trunk + arms together always returned that foot and
// hid an arm 0.07 mm off the part: arms are checked on their own.
const clash = (t) => {
const hit = t.length ? solidClearance(ctx.topo, ctx.rot, ctx.off, t, 0.25) : null;
return hit && (hit.cosUp > 0.7 ? hit.d < PROP.gap - 0.065 : hit.d < 0.205);
};
if (clash(tris.slice(nTrunk)) || (!g.onPart && clash(tris.slice(0, nTrunk)))) continue;
return { J, tris };
}
return null;
}
/**
* Rebuild `built` (buildFinsCore's Auto result) with tall neighbouring walls
* grouped onto trunks. Walls left alone keep their triangles; each group becomes
* one fin record of kind 'branch' whose triangles are trunk + pad + arms + tines.
* Returns `built` unchanged when nothing groups.
*/
export function branchWalls(topo, result, rot, built, opts = {}) {
const off = result.offset;
const partTris = seatedPartTris(topo, rot, off);
const ctx = { topo, rot, off, partTris, padH: opts.layerHeight ?? PROP.tineH };
const fins = built.fins ?? [];
const sheet = (ws) => ws.reduce((s, w) => s + w.f.span * w.f.height, 0);
const walls = [];
(built.props ?? []).forEach((q, i) => {
if (q.squat || (q.height ?? 0) < BRANCH.minH || !(q.line?.length >= 2)) return;
const f = fins[i];
if (!f || f.kind !== 'prop') return;
const floorZ = q.partAttached
? Math.min(...floorLine(q.line.map((p) => [p[0], p[1], p[2] + PROP.gap]), partTris).map((p) => p[2])) : 0;
walls.push({ f, q, line: q.line, onPart: !!q.partAttached, floorZ });
});
walls.sort((a, b) => b.f.height - a.f.height);
const used = new Set(), groups = [];
let rejected = 0;
for (const seed of walls) {
if (used.has(seed)) continue;
let g = { walls: [seed], onPart: seed.onPart, floorZ: seed.floorZ };
let placed = placeGroup(g, ctx);
const near = walls.filter((w) => w !== seed && !used.has(w) && w.onPart === seed.onPart
&& Math.abs(w.floorZ - seed.floorZ) < 2 && planDist(w.line, seed.line) <= BRANCH.reach)
.sort((a, c) => planDist(a.line, seed.line) - planDist(c.line, seed.line));
for (const w of near) {
const g2 = { walls: [...g.walls, w], onPart: g.onPart, floorZ: Math.max(g.floorZ, w.floorZ) };
const p2 = placeGroup(g2, ctx);
// join only if one trunk costs less than this group + w on its own
if (p2 && p2.J.cost < (placed?.J.cost ?? sheet(g.walls)) + w.f.span * w.f.height) { g = g2; placed = p2; }
}
if (!placed) { rejected++; continue; }
if (placed.J.cost >= sheet(g.walls) * 0.95) continue; // not worth it
for (const w of g.walls) used.add(w);
groups.push({ ...g, ...placed });
}
if (!groups.length) return { ...built, branching: { groups: 0, walls: 0, rejected } };
// Drop the grouped walls' triangles (their ranges hold wall AND tines) and
// shift every other fin's ranges down to match.
const dropFins = new Set(groups.flatMap((g) => g.walls.map((w) => w.f)));
const drop = new Uint8Array(built.triangles.length);
for (const f of dropFins) for (const [s, e] of f.triRanges) drop.fill(1, s, e);
const shift = new Int32Array(built.triangles.length + 1);
const triangles = [];
for (let i = 0; i < built.triangles.length; i++) {
shift[i] = triangles.length;
if (!drop[i]) triangles.push(built.triangles[i]);
}
shift[built.triangles.length] = triangles.length;
const kept = fins.filter((f) => !dropFins.has(f))
.map((f) => ({ ...f, triRanges: f.triRanges.map(([s, e]) => [shift[s], shift[s] + (e - s)]) }));
// Each group: trunk + arms, then each wall's own tines. An arm keeps its wall's
// top line, so the wall's tines grip it unchanged; they are copied, not re-emitted
// (re-emitting against the whole part lost 6 of 54 on the torus). A wall without
// a recorded tine offset gets a fresh comb on the same line.
const withTines = opts.tines ?? true;
const step = tineStepFor(opts.tineDensity), tineH = opts.layerHeight ?? PROP.tineH;
let id = fins.reduce((m, f) => Math.max(m, (f.id ?? -1) + 1), fins.length);
// the wall's own tine count lives on its prop (the fin record carries 0)
let tines = (built.tines ?? 0) - groups.flatMap((g) => g.walls).reduce((s, w) => s + (w.q.tines ?? 0), 0);
const recs = groups.map((g) => {
const s = triangles.length;
triangles.push(...g.tris);
let nT = 0;
if (withTines) for (const w of g.walls) {
const r = w.q.triRanges;
if (Number.isInteger(w.q.tineOff) && r.length === 1) {
for (let i = r[0][0] + w.q.tineOff; i < r[0][1]; i++) triangles.push(built.triangles[i]);
nT += w.q.tines ?? 0;
} else {
const top = w.line.map((p) => [p[0], p[1], p[2] + PROP.gap]);
nT += emitTines(top, partTris, topo, rot, off, triangles, step, undefined, tineH);
}
}
tines += nT;
const J = g.J, t = (a) => [J.cx + J.u[0] * a, J.cy + J.u[1] * a, J.zJ];
return {
height: Math.max(...g.walls.map((w) => w.f.height)), length: J.L, tines: nT, rows: 0,
stilt: 0, lean: 0, bearing: 0, site: null,
id: id++, kind: 'branch', arms: g.walls.length, onPart: g.onPart,
triRanges: [[s, triangles.length]], line: [t(J.a0), t(J.a1)], span: J.L,
};
});
const all = [...kept, ...recs];
return {
...built, triangles, fins: all, tines,
braceCount: withTines ? all.length : built.braceCount,
branching: { groups: groups.length, walls: dropFins.size, rejected },
};
}
+5
View File
@@ -347,6 +347,8 @@ function buildPass(topo, result, rot, opts, raster) {
props.push({ ...pa.prop, area: patch.area, region: patch.region, tines: nT,
caps: c0 === null ? undefined : [c0, capAt()], grip: gripFrom(g0),
trimmed: line.length - pa.prop.stations,
// where the tines start inside the range (branching keeps them)
tineOff: g0 - tri0,
id: nextId++, kind: 'prop',
triRanges: [[tri0, out.length]] });
continue;
@@ -517,6 +519,9 @@ function buildPass(topo, result, rot, opts, raster) {
// it, so on the successful try it points at this wall. emitTines just
// pushed its tines right after, so wall + tines are one contiguous segment.
triRanges: [[before, out.length]],
// where the tines start inside the range (fins/branching.js keeps them
// when it swaps the wall for an arm with the same top line)
tineOff: g0 - before,
});
for (let k = run[0] + run2[0]; k < run[0] + run2[1]; k++) claimed[k] = true;
placed = true;