From cc0538ff28a8dbafe5f64dff88ca386ad2f7ef97 Mon Sep 17 00:00:00 2001 From: matthewtrahan Date: Tue, 29 Sep 2026 07:43:32 -0500 Subject: [PATCH] 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. --- tests/README.md | 10 ++ tests/branching.test.js | 76 ++++++++++++ web/fins.js | 10 +- web/fins/branching.js | 268 ++++++++++++++++++++++++++++++++++++++++ web/prop.js | 5 + 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 tests/branching.test.js create mode 100644 web/fins/branching.js diff --git a/tests/README.md b/tests/README.md index 049a392..a28f7a9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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; diff --git a/tests/branching.test.js b/tests/branching.test.js new file mode 100644 index 0000000..6bf10ff --- /dev/null +++ b/tests/branching.test.js @@ -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'); +}); diff --git a/web/fins.js b/web/fins.js index 0e9dd15..f05b416 100644 --- a/web/fins.js +++ b/web/fins.js @@ -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. diff --git a/web/fins/branching.js b/web/fins/branching.js new file mode 100644 index 0000000..bc6839e --- /dev/null +++ b/web/fins/branching.js @@ -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 }, + }; +} diff --git a/web/prop.js b/web/prop.js index 1950ed9..7ab68a7 100644 --- a/web/prop.js +++ b/web/prop.js @@ -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;