Files
support-fins/web/prop.js
T
matthewtrahan cc0538ff28 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.
2026-09-29 07:43:32 -05:00

545 lines
29 KiB
JavaScript

/**
* PROP mode -- a breakaway wall that stands UNDER an overhang.
*
* This is a different support from the fin in fins.js, not a variant of it, and
* the difference is what it leans on:
*
* Stabilize a wall BESIDE the part, gripping a face with horizontal tines.
* Needs a face to grip, so it cannot serve a cone or a sphere.
* Prop a wall UNDER the overhang, rising from the plate and stopping
* `gap` short. The part bridges that last layer, so the wall never
* fuses and it snaps off. Needs only an underside contact line,
* which every shape has.
*
* A prop has no tines, deliberately. Tine-less supports were rejected early in
* this project on Slant3D's demo of a cube falling away from one -- but that is
* a part balanced on an EDGE with the support as its only restraint. A part
* sitting down on the plate with an overhang above it has no such failure mode:
* gravity holds it onto the prop. `tools/support/breakaway.py` in the video repo
* works exactly this way, has no tines anywhere in it, and produced good fins on
* real printed shelter hubs -- the parts Stabilize could not touch.
*
* The geometry is M3's, which was validated (34/36 regions served, every wall
* watertight) and then retired for the wrong reason. What is new here is the
* judgement around it: a prop is only emitted where the wall can actually reach
* the plate through open air.
*
* LAYOUT. This file is the entry point: `buildProps` (the auto-placer's whole
* pass over a part), `noProps`, `coverRowSpan`, and re-exports of everything
* fins.js, draw.js, ui/ and the tests import. The pieces live in web/prop/:
*
* config.js PROP, every number a wall is built from
* surface.js seat a vertex; part surface height(s) above (x, y)
* contact.js the contact line under an overhang, settled to exactly `gap`
* sweep.js the wall solid: the upside-down T, and a part-attached wall
* clearance.js which stations can carry a wall (reach, certification, runs)
* tines.js the grip comb along a wall's top
* tracks.js where Suggest puts walls: straight patches, tracks, tube line
* attached.js walls that stand on the part instead of the plate
* squat.js brimmed squat walls for the near-bed band
* rival.js a small tube against the patch path: the region keeps the better
* raster.js walls across a region's whole footprint, raced against the rest
*
* Each module imports only modules above it in this list and never prop.js.
*/
import { solidClearance } from './inside.js';
import { MIN_REGION_AREA } from './overhangs.js';
import { buildPartAttached } from './prop/attached.js';
import { bodyMask, insertFloorStations, longestRun, stationCertified, stationIsClear, tallBody, tallSpan, withLowTails } from './prop/clearance.js';
import { PROP } from './prop/config.js';
import { contourTop, lowerSag, settleTop, straightness } from './prop/contact.js';
import { rasterRest, rasterTracks, withRaster } from './prop/raster.js';
import { tubeRivalry } from './prop/rival.js';
import { buildSquatBed } from './prop/squat.js';
import { seat } from './prop/surface.js';
import { sweep } from './prop/sweep.js';
import { emitTines, tineStepFor } from './prop/tines.js';
import { patchTracks, splitRegion, tubeLine } from './prop/tracks.js';
// Re-exported so fins.js, draw.js, ui/ and the tests import from prop.js alone.
export { PROP } from './prop/config.js';
export { splitRegion, tubeLine, patchTracks } from './prop/tracks.js';
export { straightness, contactLine, lowerSag, contourTop, settleTop } from './prop/contact.js';
export { footFor, profileHalf, sweep, sweepBetween } from './prop/sweep.js';
export { surfaceZAt, surfaceZsAt } from './prop/surface.js';
export { stationIsClear, stationCertified, pathToPlateIsClear, longestRun,
withLowTails, insertFloorStations } from './prop/clearance.js';
export { tineStepFor, emitTines } from './prop/tines.js';
export { floorLine, PART_BAND } from './prop/attached.js';
export { sweepSquat, buildSquatBed } from './prop/squat.js';
/**
* What buildProps returns when it builds nothing -- kept here so the callers
* that refuse to build (a part seated on a point) return the same shape.
*/
export function noProps() {
return {
triangles: [], props: [], served: 0, volume: 0,
skipped: { noLine: 0, wanders: 0, stub: 0, blocked: 0,
degenerate: 0, buried: 0, weld: 0, sliver: 0 },
};
}
// mm row pitch at the sparsest coverage (0). This is DELIBERATELY wider than
// maxUnsupportedSpan: the mid-slider (0.5) is the structural anti-sag cap, and
// dragging left of it trades sag safety for fewer supports -- the tool warns when
// a part actually lands a row wider than the cap (buildProps.sagRisk). Matthew
// asked for this: a small part was floored at 3 fins by the hard cap.
export const COVER_SPARSE_SPAN = 30.0;
/**
* Wide-face row pitch for a coverage setting. 0.5 is the neutral default and maps
* to the structural cap (maxUnsupportedSpan) -- the old sparse behaviour, so a part
* built at the slider's default is byte-identical to before. Left of centre loosens
* past the cap toward COVER_SPARSE_SPAN (fewer supports, may sag); right of centre
* tightens to half the cap (denser). Monotonic, so tests/coverage.test.js holds.
*/
export function coverRowSpan(coverage) {
const c = Math.max(0, Math.min(1, coverage));
const cap = PROP.maxUnsupportedSpan;
return c <= 0.5
? cap + ((0.5 - c) / 0.5) * (COVER_SPARSE_SPAN - cap) // 30 .. 12
: cap - ((c - 0.5) / 0.5) * (cap / 2); // 12 .. 6
}
/**
* Build a breakaway prop under every overhang region that can take one.
*
* Two passes: the normal one (tube line or split patches per region), then a
* raster pass over the regions it left partly bare, and each region keeps the
* pass that holds more of it -- see web/prop/raster.js. `opts.raster === false`
* runs the normal pass alone.
*
* @returns {{triangles, props, skipped, served, sagRisk}}
*/
export function buildProps(topo, result, rot, opts = {}) {
const capFrom = globalThis.__TINECAP?.length ?? 0; // tests' tine capture: see withRaster
const normal = buildPass(topo, result, rot, opts, null);
if (opts.raster === false) return normal;
return withRaster(topo, result, rot, opts, normal, capFrom,
(regions) => buildPass(topo, result, rot, opts, regions));
}
/**
* One placement pass. `raster` null = the normal placement for every region;
* a Set of region indices = raster tracks (rasterTracks) for those regions only.
*/
function buildPass(topo, result, rot, opts, raster) {
const { pos } = topo;
const step = opts.step ?? PROP.stationStep;
// Wide-face coverage (0 sparse .. 1 dense) sets the row spacing via coverRowSpan:
// 0.5 is the anti-sag cap (the default), left of it loosens past the cap (fewer
// supports, flagged as sagRisk when a row actually lands wider than the cap),
// right of it tightens. Pinned by tests/coverage.test.js.
const coverage = Math.max(0, Math.min(1, opts.coverage ?? 0.5));
const rowSpan = coverRowSpan(coverage);
// sagRisk warns ONLY when the user dragged coverage below centre, asking for row
// pitch wider than the anti-sag cap. It is NOT enough that the placed spacing
// exceeds the cap: rounding vExt/rowSpan down routinely lands a hair over the cap
// even at the neutral default (a 53mm face / 12mm cap -> 4 rows at 13.25mm), and
// warning there is just noise. So gate on the REQUESTED pitch, not the rounded
// result.
const wantSparse = rowSpan > PROP.maxUnsupportedSpan + 0.5;
const zBed = 0;
const off = result.offset;
const withTines = opts.tines === true;
// tines emitted; sagRisk: the user chose sub-cap spacing AND a real row landed wide
const tally = { tines: 0, sagRisk: false, sagRegions: new Set() };
const out = [];
const props = [];
// Per-fin identity, assigned in build order. The id is only used to map a
// raycast hit back to its fin WITHIN one build (the UI tracks removals across
// rebuilds by a spatial signature, not this id). Sequential keeps it stable
// within a generation.
let nextId = 0;
const skipped = { noLine: 0, wanders: 0, stub: 0, blocked: 0,
degenerate: 0, buried: 0, weld: 0, sliver: 0 };
const v = [0, 0, 0];
// The whole part, seated once, for the part-attached floor probe: the floor a
// support lands on is usually a DIFFERENT region than the overhang, so it must
// raycast the full mesh. Cheap next to the per-region work below.
const partTris = new Float64Array(pos.length);
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity,
minZ = Infinity, maxZ = -Infinity;
for (let f = 0; f < topo.nFaces; f++) {
for (let i = 0; i < 3; i++) {
seat(pos, f * 9 + i * 3, rot, off, v);
partTris[f * 9 + i * 3] = v[0];
partTris[f * 9 + i * 3 + 1] = v[1];
partTris[f * 9 + i * 3 + 2] = v[2];
if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0];
if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1];
if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2];
}
}
// DENSE grip comb, uniform along every grippable wall. A tip-over-risk scale
// (commit c0fcf8e) once let "stable" parts fall back to a sparse 9mm comb -- which
// starved shallow parts down to a few nubs that read as "laying on the face"
// instead of a gripping comb (regression Matthew caught). The cube-detach that
// scale was reacting to was actually the bed pad's fault (fixed separately in the
// pad commits), not the tines'. So the comb is uniformly dense again (Slant3D's
// "7-8 low down, spreading with height"); density DEFAULTS to PROP.tineStep and
// tests/tines_realparts.test.js pins that real parts get a full comb. The user's
// "Tine grip" slider (opts.tineDensity) can loosen it toward tineStepSparse for a
// surface-critical face, never silently -- see tineStepFor / tests/tine_density.test.js.
const tineStepEff = tineStepFor(opts.tineDensity);
// A tine MUST be exactly one slicer layer tall or it stops printing as one clean
// continuous bead and tears on removal (welts) instead of bending off. So it
// tracks the user's real layer height (default 0.2mm) -- see the UI's Layer height.
const tineHeight = opts.layerHeight ?? PROP.tineH;
// where the tests' tine capture stands (see buildProps); null when off
const capAt = () => globalThis.__TINECAP?.length ?? null;
// The lowest tine vertex in out[from..] -- where a wall's grip starts, which
// the raster race must not raise (prop/raster.js gripZ). Infinity: no tines.
const gripFrom = (from) => {
let z = Infinity;
for (let i = from; i < out.length; i++) if (out[i][2] < z) z = out[i][2];
return z;
};
// The support unit is the locally-straight sub-patch, not the connected
// region -- see splitRegion. Fragments too small to be worth a wall are
// counted, not silently dropped: absence of output recorded as success is
// exactly how M5's scoreboard lied.
const patches = [];
for (let ri = 0; ri < result.regions.length; ri++) {
const rFaces = result.regions[ri].faces;
// Seat the WHOLE region's triangles once, shared by all its sub-patches.
// The polyline each wall follows comes from its own sub-patch, but the
// surface its top must CLEAR is the whole region's: a wall near a patch
// boundary can run under a sibling patch's faces, and measuring against
// patch-only triangles welded it to geometry it could not see -- gap
// 0.003 mm and flank 0.011 mm on hub_corner, measured the first time this
// split shipped with per-patch triangles.
const regionTris = new Float64Array(rFaces.length * 9);
const regionPts = [];
let regionArea = 0;
for (let k = 0; k < rFaces.length; k++) {
regionArea += topo.area[rFaces[k]];
let gx = 0, gy = 0, gz = 0;
for (let i = 0; i < 3; i++) {
seat(pos, rFaces[k] * 9 + i * 3, rot, off, v);
regionTris[k * 9 + i * 3] = v[0];
regionTris[k * 9 + i * 3 + 1] = v[1];
regionTris[k * 9 + i * 3 + 2] = v[2];
regionPts.push([v[0], v[1], v[2]]);
gx += v[0]; gy += v[1]; gz += v[2];
}
regionPts.push([gx / 3, gy / 3, gz / 3]);
}
// Raster pass: the whole footprint's tracks, no tube route, no split.
if (raster) {
if (!raster.has(ri)) continue;
const lines = rasterTracks(regionPts, regionTris, partTris, step, rowSpan,
{ topo, rot, offset: off });
// the sparse-coverage sag warning, per region: it only counts if this region swaps
if (wantSparse && lines.spacing > PROP.maxUnsupportedSpan) tally.sagRegions.add(ri);
if (lines.length) patches.push({ faces: rFaces, area: regionArea, region: ri, tris: regionTris, lines });
continue;
}
// Curved region whose lowest line is straight = a tube: ONE wall under
// that line, the way breakaway.py props the shelter hubs. Only when the
// region is flat, or its lowest points form a ring, does it go to
// splitRegion for rows of tracks. See tubeLine.
// A SMALL tube (under tubeMinArea, see tubeLine) is a new claim on a region
// the patch path used to own, so it keeps the region's split too: the tube is
// built first, the patches then compete for the same region, and the one that
// holds more of it stays (see web/prop/rival.js).
const tube = tubeLine(topo, rFaces, rot, regionPts, regionTris, step);
const small = regionArea < PROP.tubeMinArea;
const split = small || !tube?.length ? splitRegion(topo, rFaces, rot) : null;
if (tube && tube.length) {
patches.push({ faces: rFaces, area: regionArea, region: ri,
tris: regionTris, lines: tube, smallTube: small ? split : null });
continue;
}
for (const p of split) {
if (p.area < MIN_REGION_AREA) { skipped.sliver++; continue; }
p.region = ri;
p.tris = regionTris;
patches.push(p);
}
}
const servedRegions = new Set();
// A small tube competes with its region's patches: see web/prop/rival.js.
const rivalry = tubeRivalry({ out, props, skipped, served: servedRegions, tally },
topo, result, partTris);
for (const patch of patches) {
const regionTris = patch.tris;
const mark = rivalry.begin(patch); // a small tube undoes to here
let lines;
if (patch.lines) {
// A tube's lowest-line track(s), fitted and resampled by tubeLine.
lines = patch.lines;
} else {
// The patch's own geometry: vertices plus face centroids for the frame
// fit, and its triangles for asking "does the patch cover this station".
const pts = [];
const patchTris = new Float64Array(patch.faces.length * 9);
for (let k = 0; k < patch.faces.length; k++) {
const f = patch.faces[k];
let gx = 0, gy = 0, gz = 0;
for (let i = 0; i < 3; i++) {
seat(pos, f * 9 + i * 3, rot, off, v);
pts.push([v[0], v[1], v[2]]);
patchTris[k * 9 + i * 3] = v[0];
patchTris[k * 9 + i * 3 + 1] = v[1];
patchTris[k * 9 + i * 3 + 2] = v[2];
gx += v[0]; gy += v[1]; gz += v[2];
}
pts.push([gx / 3, gy / 3, gz / 3]);
}
lines = patchTracks(pts, patchTris, step, { topo, rot, offset: off }, rowSpan);
// The user chose sub-cap spacing (wantSparse) AND this face actually landed a
// multi-row gap wider than the cap. Flag it so the UI can warn (never blocks;
// Matthew's call). Single-row faces (spacing 0) can't sag, so they don't warn.
if (wantSparse && lines.length && lines.spacing > PROP.maxUnsupportedSpan) tally.sagRisk = true;
}
if (!lines.length) { skipped.noLine++; continue; }
// An index loop, not for...of over a copy: a raster track's leftover pieces
// are appended to `lines` mid-loop (rasterRest).
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
// PART-ATTACHED first: if solid part sits below this overhang, a support
// must stand on THAT floor, not stilt to the plate through the part (the
// bug Matthew hit on a real hub). buildPartAttached declines on an ordinary
// bed overhang (floorLine ~0), so the plate path below is reached unchanged
// for the flagship parts. When there IS a floor but no safe wall fits (side
// walls in the way, a buried column, too short) it says `floored` with the
// reason -- counted and skipped, never stilted through the part. A bore is
// no longer a refusal: its wall is built and flagged inBore. Works on a COPY so
// the plate path's own `line` is untouched.
const tri0 = out.length;
const pa = buildPartAttached(line, partTris, topo, rot, off, out);
if (pa.ok && patch.smallTube) {
// A small tube's line is new to this path, so hold its wall to the same
// measured clearance the plate path demands (see the sweep below): on
// 3DBenchy at Y35 an unchecked one fused 0.09 mm into the cabin roof.
const hit = solidClearance(topo, rot, off, out.slice(tri0), 0.25);
if (hit && (hit.cosUp > 0.7 ? hit.d < PROP.gap - 0.065 : hit.d < 0.205)) {
out.length = tri0;
skipped.weld++;
continue;
}
}
if (pa.ok) {
servedRegions.add(patch.region);
let nT = 0;
const c0 = capAt(), g0 = out.length;
// pa.prop.line already carries the wall top (surface minus gap); add the
// gap back so emitTines reads it as the surface, like the plate path does.
if (withTines) {
const topLine = pa.prop.line.map((p) => [p[0], p[1], p[2] + PROP.gap]);
nT = emitTines(topLine, partTris, topo, rot, off, out, tineStepEff, undefined, tineHeight);
tally.tines += nT;
}
// buildPartAttached pushed the wall starting at tri0; emitTines above pushed
// its tines right after, so wall + tines are contiguous -> one segment.
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;
}
if (pa.floored) { skipped[pa.floored]++; continue; }
// Finish the top against the WHOLE region, not just this patch: a track
// near a patch boundary can run under a sibling patch's faces, and
// clearance measured against patch-only triangles welded walls to
// geometry they could not see (gap 0.003mm, flank 0.011mm, hub_corner).
contourTop(line, regionTris);
lowerSag(line, regionTris);
// pin the wall's low end to the squat floor, not the nearest 1mm station
if (insertFloorStations(line).length) contourTop(line, regionTris);
// SQUAT BED PASS: hold the near-bed stations too low for the flanged wall
// below (which discards everything under minHeight as stub/blocked). It runs
// AFTER the tall wall (runSquat, on every exit path) so it can skip exactly
// the stations that wall's low tail covered (`claimed`) and nothing more.
// squatLine is a deep copy taken now, before the tall path's settleTop
// mutates the shared points, so the squat pass sees the contoured line.
const squatLine = line.map((p) => [p[0], p[1], p[2]]);
const claimed = line.map(() => false);
const runSquat = () => {
for (const sq of buildSquatBed(squatLine, regionTris, topo, rot, off, out, claimed)) {
// a squat wall's base is the thin brim, not the tall flange, so tines
// attach from squatBrimH up (the default minTop would skip every one).
const t0 = out.length, c0 = capAt();
const nT = withTines ? emitTines(
sq.line.map((p) => [p[0], p[1], p[2] + PROP.gap]),
regionTris, topo, rot, off, out, tineStepEff, PROP.squatBrimH, tineHeight) : 0;
tally.tines += nT;
servedRegions.add(patch.region);
// buildSquatBed pushed this wall (sq.triRange) BEFORE every squat wall's
// tines, so a fin's wall and its tines are NON-contiguous in `out` --
// track both segments so removing the fin takes wall AND tines together.
const segs = [sq.triRange];
if (out.length > t0) segs.push([t0, out.length]);
props.push({ ...sq, area: patch.area, region: patch.region, tines: nT,
caps: c0 === null ? undefined : [c0, capAt()], grip: gripFrom(t0),
id: nextId++, kind: 'prop', triRanges: segs });
}
};
// A track is straight in XY by construction, so this gate is a tripwire
// rather than the bowl-refusal it was for bucketed polylines -- bowls are
// now refused by their holes (see patchTracks). Keep it: anything that
// trips it means the frame fit itself went wrong.
if (straightness(line) > PROP.maxWander) { skipped.wanders++; runSquat(); continue; }
// Trim to the longest run that can actually carry a wall, rather than
// discarding the track over a local problem. See `longestRun`.
const clear = line.map((p, k) =>
p[2] - PROP.gap >= PROP.minHeightSquat && stationIsClear(line, k, topo, rot, off));
const usable = withLowTails(
line.map((p, k) => clear[k] && p[2] - PROP.gap >= PROP.minHeight), clear);
const run = longestRun(usable);
if (!run || run[1] - run[0] < PROP.minStations) { skipped.blocked++; runSquat(); continue; }
if (raster) {
for (const rest of rasterRest(line, run)) {
lines.push(rest);
// the piece runs its own squat pass; this line's must not double it
for (let k = rest.from; k < rest.from + rest.length; k++) claimed[k] = true;
}
}
const sub = line.slice(run[0], run[1]);
const body = bodyMask(sub); // before settleTop -- see bodyMask
if (tallSpan(sub, body) < (patch.smallTube ? PROP.minSpanTube : PROP.minSpan)) { skipped.stub++; runSquat(); continue; }
// Last, on the trimmed run only: put the closest approach exactly on spec.
// It runs here rather than earlier because trimming changes which part of
// the edge is closest, so settling before the trim settles the wrong
// thing.
settleTop(sub, regionTris);
// Settling can push a station that was only just tall enough below the
// floor, and `sweep` would then throw away the whole wall -- the same
// all-or-nothing failure the trim exists to prevent, reintroduced one step
// later. Re-trim against the settled line: on height, and on the measured
// clearance to everything settleTop could not see (stationCertified).
// Tall stations carry the wall; low ones may only extend it as its tail.
const lowA = sub.map((p, k) =>
p[2] - PROP.gap >= PROP.minHeightSquat && stationCertified(sub, k, topo, rot, off));
const tallA = sub.map((p, k) => lowA[k] && p[2] - PROP.gap >= PROP.minHeight);
// Sweep, then MEASURE the finished solid -- exact triangle-to-triangle
// clearance against the whole part (solidClearance), because the last
// welds this pipeline shipped sat between stations, where no per-station
// probe would ever look. A contact is a local problem like every other:
// trim the station that owns it and try again, up to a few rounds,
// rather than discarding a 90mm wall over one rib. A wall that cannot be
// cut clear is dropped -- "no prop" is a fixable disappointment, a fused
// prop is a ruined print.
let placed = false, reason = null;
for (let tries = 0; tries < 4 && !placed; tries++) {
const run2 = longestRun(withLowTails(tallA, lowA));
if (!run2 || run2[1] - run2[0] < PROP.minStations) { reason = 'blocked'; break; }
const settled = sub.slice(run2[0], run2[1]);
const span2 = Math.hypot(settled[settled.length - 1][0] - settled[0][0],
settled[settled.length - 1][1] - settled[0][1]);
const settledBody = body.slice(run2[0], run2[1]);
if (tallSpan(settled, settledBody) < (patch.smallTube ? PROP.minSpanTube : PROP.minSpan)) { reason = 'stub'; break; }
const before = out.length;
if (!sweep(settled, zBed, out, PROP.minHeightSquat)) {
out.length = before;
reason = 'degenerate';
break;
}
// 0.25 reach: the tightest threshold below is 0.205, and every extra
// tenth of reach widens the broad phase for nothing
const hit = solidClearance(topo, rot, off, out.slice(before), 0.25);
// Same acceptance as stationCertified: an approach from above is the
// breakaway interface, anything else is a flank. Interpenetration
// measures 0 and fails the flank test, which is what retires the old
// vertex-containment `buried` check -- crossing surfaces have
// distance 0 long before any vertex is inside.
if (hit && (hit.cosUp > 0.7 ? hit.d < PROP.gap - 0.065 : hit.d < 0.205)) {
out.length = before;
let kBest = 0, dBest = Infinity;
for (let k = 0; k < settled.length; k++) {
const dx = settled[k][0] - hit.x, dy = settled[k][1] - hit.y;
if (dx * dx + dy * dy < dBest) { dBest = dx * dx + dy * dy; kBest = k; }
}
const at = run2[0] + kBest;
for (const k of [Math.max(0, at - 1), at, Math.min(lowA.length - 1, at + 1)]) {
lowA[k] = false;
tallA[k] = false;
}
reason = 'weld';
continue;
}
// The TALLEST point, not the lowest: this is what the wall costs to
// print and how far it has to stand up on its own. `line` is
// deliberately not used here -- the wall only exists over `sub`.
const top = Math.max(...settled.map((p) => p[2])) - PROP.gap;
// signed volume of the emitted solid (divergence theorem over its
// triangles): the plastic this wall costs, which is the number the
// "less material than slicer supports" claim has to be measured against
let vol = 0;
for (let i = before; i < out.length; i += 3) {
const a = out[i], b = out[i + 1], c = out[i + 2];
vol += (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;
}
servedRegions.add(patch.region);
// The grip comb: nubs along this wall's settled top that bite into the
// part. `settled` carries the surface z; emitTines subtracts the gap.
const c0 = capAt(), g0 = out.length;
const nT = withTines ? emitTines(settled, regionTris, topo, rot, off, out, tineStepEff, undefined, tineHeight,
tallBody(settledBody)) : 0;
tally.tines += nT;
props.push({
region: patch.region, tines: nT,
caps: c0 === null ? undefined : [c0, capAt()], grip: gripFrom(g0),
span: span2, height: top - zBed, area: patch.area,
stations: settled.length, trimmed: line.length - settled.length,
volume: Math.abs(vol),
// the centreline, so a coverage check can ask what this wall reaches
line: settled.map((p) => [p[0], p[1], p[2] - PROP.gap]),
id: nextId++, kind: 'prop',
// `before` (captured at the start of THIS try, line ~2129) marks where
// this wall's triangles begin in `out`; failed weld retries roll back to
// 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;
}
if (!placed && reason) skipped[reason]++;
runSquat();
}
if (patch.smallTube) rivalry.tubeDone(patch, mark, patches);
}
rivalry.finish();
// `served` counts REGIONS with at least one wall, because a region can now
// yield several -- subtracting a prop count from a region count would say a
// part with one region and three walls had "-2 unserved".
return { triangles: out, props, skipped, served: servedRegions.size,
servedRegions: [...servedRegions],
tines: tally.tines, sagRisk: tally.sagRisk,
...(raster ? { sagRegions: tally.sagRegions } : {}),
volume: props.reduce((s, q) => s + q.volume, 0) };
}