mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-19 02:10:52 -05:00
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:
1 parent
aef3484627
commit
cc0538ff28
5 files changed
+368
-1
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user