mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-19 02:10:52 -05:00
engine: raster placement -- walls across a region's whole footprint, raced per region
The normal pass gives a curved region one wall under its lowest line and splits the rest into 15-degree patches; a dome, bowl or organic underside was left mostly bare with nothing skipped. A second, raster pass (parallel tracks at the row pitch across the whole region, cut where the floor changes plate/part, every usable run kept) runs over the regions the normal pass holds less than 95% of, and each region keeps the pass that holds 10% more overhang area (from wall tops, probe.js's rule). A swapped region keeps any normal wall that stands clear and holds area or grip the raster walls miss, and never swaps if its lowest tine would rise. prop.js only wires the two passes; web/prop/raster.js owns the rest. raster:false runs the normal pass alone; the small-tube tests pin it.
This commit is contained in:
1 parent
ee65e47de4
commit
6778366d2f
3 files changed
+381
-11
No files matched your search
@@ -43,8 +43,10 @@ const topoOf = (tris) => {
|
||||
return buildTopology({ getAttribute: (k) => (k === 'position' ? { array: arr } : null) });
|
||||
};
|
||||
const I = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
// raster: false -- these tests pin the tube-vs-patch race inside the normal
|
||||
// pass; the raster pass (web/prop/raster.js) would add walls to both sides.
|
||||
const build = (topo) => fins.buildFins(topo, analyze(topo, 45, I), I,
|
||||
{ mode: 'auto', bedPad: true, tines: true, coverage: 0.5 });
|
||||
{ mode: 'auto', bedPad: true, tines: true, coverage: 0.5, raster: false });
|
||||
|
||||
// A 30x20x24 block with a 5 mm peg sticking 8 mm out of its +Y face, 14 mm up:
|
||||
// the chimney in miniature. The peg overlaps the block by 1 mm, as a modelled
|
||||
|
||||
+68
-10
@@ -38,6 +38,7 @@
|
||||
* 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.
|
||||
*/
|
||||
@@ -47,6 +48,7 @@ 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';
|
||||
@@ -103,9 +105,25 @@ export function coverRowSpan(coverage) {
|
||||
/**
|
||||
* 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 normal = buildPass(topo, result, rot, opts, null);
|
||||
if (opts.raster === false) return normal;
|
||||
return withRaster(topo, result, rot, opts, normal,
|
||||
(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:
|
||||
@@ -171,6 +189,15 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
// 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
|
||||
@@ -203,6 +230,16 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
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 });
|
||||
if (lines.length) patches.push({ faces: rFaces, area: regionArea, region: ri, tris: regionTris, lines });
|
||||
else skipped.noLine++;
|
||||
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
|
||||
@@ -266,7 +303,10 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
}
|
||||
if (!lines.length) { skipped.noLine++; continue; }
|
||||
|
||||
for (const line of lines) {
|
||||
// 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
|
||||
@@ -290,15 +330,19 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
}
|
||||
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]);
|
||||
tally.tines += emitTines(topLine, partTris, topo, rot, off, out, tineStepEff, undefined, tineHeight);
|
||||
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,
|
||||
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,
|
||||
id: nextId++, kind: 'prop',
|
||||
triRanges: [[tri0, out.length]] });
|
||||
@@ -327,17 +371,20 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
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;
|
||||
if (withTines) tally.tines += emitTines(
|
||||
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);
|
||||
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, id: nextId++, kind: 'prop', triRanges: segs });
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -354,7 +401,14 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
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 (!run || run[1] - run[0] < PROP.minStations) { if (!line.rest) 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
|
||||
@@ -442,9 +496,13 @@ export function buildProps(topo, result, rot, opts = {}) {
|
||||
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.
|
||||
if (withTines) tally.tines += emitTines(settled, regionTris, topo, rot, off, out, tineStepEff, undefined, tineHeight,
|
||||
tallBody(settledBody));
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Raster placement: walls across a region's WHOLE footprint, the way a slicer's
|
||||
* grid support stands, raced against the patch/tube placement region by region.
|
||||
*
|
||||
* Why. A wall's top already follows whatever surface is above it (contourTop,
|
||||
* settleTop), so a wall does not need a straight or flat overhang -- only a
|
||||
* straight track in PLAN. What held curved parts back was placement: a curved
|
||||
* region got ONE wall under its lowest line (tubeLine), and anything else was
|
||||
* split into 15-degree patches (splitRegion) with every patch under 12 mm2
|
||||
* dropped. The dome ceiling example got 2 walls and 34% of its area held with no
|
||||
* skips at all -- nothing refused, the placer simply never tried a second wall.
|
||||
* Laying parallel tracks at the row pitch across the whole region, the way a
|
||||
* grid support does, took it to 75% (bowl 24 -> 75, torus X30 4 -> 54, the real
|
||||
* bear upright 0 -> 57; spike in prototype/raster/, local issue 008).
|
||||
*
|
||||
* Why a race and not a replacement. Raster alone LOSES on real tubes: a pipe on
|
||||
* its side wants one wall under its lowest line, and parallel tracks across it
|
||||
* land on its flanks (hook 98 -> 36%, vase_flare X30 93 -> 59%). So buildProps
|
||||
* builds the normal pass, then a raster pass over only the regions the normal
|
||||
* pass left partly bare (`rasterWanted`), and `raceRegions` keeps, per region,
|
||||
* whichever holds more overhang area -- by a margin, so a region the normal pass
|
||||
* already holds does not swap one clean wall for several stepped pieces for a
|
||||
* sliver more area (the real gree figure's arm, measured in the spike).
|
||||
*
|
||||
* Split out of prop.js, which only wires the two passes together.
|
||||
*/
|
||||
import { floorLine } from './attached.js';
|
||||
import { PROP } from './config.js';
|
||||
import { seat } from './surface.js';
|
||||
import { footFor } from './sweep.js';
|
||||
import { patchTracks } from './tracks.js';
|
||||
|
||||
// A raster region replaces the normal one only if it holds this much more
|
||||
// overhang area: RACE_REL of the normal pass's held area, and at least
|
||||
// RACE_ABS mm2. Equal-ish coverage keeps the normal pass's fewer, longer walls.
|
||||
export const RACE_REL = 0.10;
|
||||
export const RACE_ABS = 5.0;
|
||||
// A region the normal pass already holds this much of (own faces) is not
|
||||
// rebuilt as raster -- there is nothing left to win.
|
||||
export const RASTER_SKIP_HELD = 0.95;
|
||||
// A swap may not raise a region's lowest grip by more than this (mm): the
|
||||
// lowest tine is what holds the part while it is still short and least stable,
|
||||
// and prototype/sweep/compare.js blocks on it rising 0.1 mm.
|
||||
export const GRIP_RISE = 0.1;
|
||||
|
||||
/**
|
||||
* The raster tracks for one region: parallel straight tracks at `rowSpan`
|
||||
* across its whole footprint (patchTracks on the region, not on a patch), each
|
||||
* cut where the region has no face above it and where the floor under it
|
||||
* changes between plate and part, so every piece goes down exactly one of the
|
||||
* plate path or the part-attached path. The raster pass then keeps every
|
||||
* usable run of a track, not just the longest (see rasterRest).
|
||||
*/
|
||||
export function rasterTracks(regionPts, regionTris, partTris, step, rowSpan, support) {
|
||||
const lines = [];
|
||||
for (const t of patchTracks(regionPts, regionTris, step, support, rowSpan)) {
|
||||
// floorLine's plate/part split, the same test buildPartAttached counts with
|
||||
const floor = floorLine(t, partTris);
|
||||
let cur = [], cls = null;
|
||||
for (let k = 0; k < t.length; k++) {
|
||||
const onPart = floor[k][2] > PROP.gap + 0.5;
|
||||
if (cls !== null && onPart !== cls) {
|
||||
if (cur.length >= PROP.minStations) lines.push(cur);
|
||||
cur = [];
|
||||
}
|
||||
cls = onPart;
|
||||
cur.push(t[k]);
|
||||
}
|
||||
if (cur.length >= PROP.minStations) lines.push(cur);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raster track crosses the part and other obstacles, so its longest usable
|
||||
* run is rarely all of it: the pieces either side of `run` get their own try.
|
||||
* Returns them (each at least minStations long), marked `rest` so a piece that
|
||||
* turns out unusable is not counted as a second skip for the same track, and
|
||||
* with `from`, the index its first station had in `line` -- the caller claims
|
||||
* those stations off `line`'s squat pass, since the piece runs its own.
|
||||
*/
|
||||
export function rasterRest(line, run) {
|
||||
const rest = [];
|
||||
for (const [a, b] of [[0, run[0]], [run[1], line.length]]) {
|
||||
if (b - a < PROP.minStations) continue;
|
||||
const piece = line.slice(a, b);
|
||||
piece.rest = true;
|
||||
piece.from = a;
|
||||
rest.push(piece);
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overhang faces seated once: [cx, cy, cz, area, region] per face.
|
||||
*/
|
||||
function overhangFaces(topo, result, rot) {
|
||||
const { pos } = topo, off = result.offset, v = [0, 0, 0], faces = [];
|
||||
result.regions.forEach((g, ri) => {
|
||||
for (const f of g.faces) {
|
||||
let cx = 0, cy = 0, cz = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
seat(pos, f * 9 + i * 3, rot, off, v);
|
||||
cx += v[0] / 3; cy += v[1] / 3; cz += v[2] / 3;
|
||||
}
|
||||
faces.push([cx, cy, cz, topo.area[f], ri]);
|
||||
}
|
||||
});
|
||||
return faces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overhang area held by wall TOPS `tops`: a top point within half the
|
||||
* maxUnsupportedSpan in plan and 0-1.5 mm below the face (prototype/examples/
|
||||
* probe.js's rule). Tops, not every support vertex as rival.js counts: a wall's
|
||||
* base flange sits within 3 mm of any low face around it, and on the torus at
|
||||
* X45 that credited a low ledge to a wall 9 mm away that never reaches it.
|
||||
* `faces` is the set judged (a region's own, or every face near it).
|
||||
*/
|
||||
function heldBy(faces, tops) {
|
||||
const reach = PROP.maxUnsupportedSpan / 2;
|
||||
let held = 0;
|
||||
for (const [cx, cy, cz, a] of faces) {
|
||||
for (const p of tops) {
|
||||
const dz = cz - p[2];
|
||||
if (dz >= -0.05 && dz <= 1.5 && Math.hypot(p[0] - cx, p[1] - cy) <= reach) {
|
||||
held += a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
// every wall's top line (surface minus gap), the points heldBy measures from
|
||||
const wallTops = (qs) => qs.flatMap((q) => q.line ?? []);
|
||||
|
||||
// Half-width of a wall's footprint on whatever it stands on: a squat wall's
|
||||
// brim, a part-attached wall's own thickness, a plate wall's flange.
|
||||
const halfFoot = (q) => (q.squat ? PROP.squatBrimW
|
||||
: q.partAttached ? PROP.th / 2 : footFor(q.height ?? 0));
|
||||
|
||||
// The lowest point a set of walls grips from: each wall's lowest tine vertex
|
||||
// (`grip`, recorded by buildProps as it emits them). Infinity = no tines.
|
||||
const gripZ = (qs) => Math.min(Infinity, ...qs.map((q) => q.grip ?? Infinity));
|
||||
|
||||
// Closest approach of two walls' centrelines in PLAN.
|
||||
function planGap(l1, l2) {
|
||||
const seg = (p, a, b) => {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1], L = dx * dx + dy * dy;
|
||||
const t = L > 1e-12 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L)) : 0;
|
||||
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
|
||||
};
|
||||
let d = Infinity;
|
||||
for (const [P, Q] of [[l1, l2], [l2, l1]]) {
|
||||
for (const p of P) {
|
||||
if (Q.length === 1) d = Math.min(d, Math.hypot(p[0] - Q[0][0], p[1] - Q[0][1]));
|
||||
for (let i = 0; i + 1 < Q.length; i++) d = Math.min(d, seg(p, Q[i], Q[i + 1]));
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
const byRegion = (props) => {
|
||||
const m = new Map();
|
||||
for (const q of props) {
|
||||
if (!m.has(q.region)) m.set(q.region, []);
|
||||
m.get(q.region).push(q);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
/**
|
||||
* buildProps's second half: given the normal pass, run the raster pass over the
|
||||
* regions it left partly bare (`rasterPass(regions)` is buildProps's own pass
|
||||
* builder) and race them. Returns `normal` itself when nothing swaps, so a part
|
||||
* the raster pass cannot improve builds byte-identical to the normal pass.
|
||||
*
|
||||
* opts.rasterVeto (auto mode: fins/wedges.js wedgeVeto) drops raster walls that
|
||||
* would take a wedge's face before the race sees them.
|
||||
*
|
||||
* The tests' tine capture (globalThis.__TINECAP, see tines.js) must end up
|
||||
* holding the KEPT walls' tines only: each pass captures into its own array,
|
||||
* every wall records its slice (`caps`), and the kept slices go back.
|
||||
*/
|
||||
export function withRaster(topo, result, rot, opts, normal, rasterPass) {
|
||||
const faces = overhangFaces(topo, result, rot);
|
||||
const want = rasterWanted(faces, result, normal);
|
||||
if (!want.size) return normal;
|
||||
const cap = globalThis.__TINECAP;
|
||||
const capNormal = cap ? cap.slice() : null;
|
||||
if (cap) globalThis.__TINECAP = [];
|
||||
const raster = rasterPass(want);
|
||||
const capRaster = globalThis.__TINECAP;
|
||||
globalThis.__TINECAP = cap;
|
||||
if (opts.rasterVeto) raster.props = raster.props.filter((q) => !opts.rasterVeto(q, normal.props));
|
||||
const raced = raceRegions(faces, result, normal, raster);
|
||||
if (!raced.rasterRegions) return normal;
|
||||
if (cap) {
|
||||
cap.length = 0;
|
||||
for (const q of raced.props) if (q.caps) cap.push(...(q.raster ? capRaster : capNormal).slice(...q.caps));
|
||||
}
|
||||
return raced;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regions worth a raster pass: those the normal pass `a` holds less than
|
||||
* RASTER_SKIP_HELD of (own faces, by its own walls and every other wall).
|
||||
*/
|
||||
function rasterWanted(faces, result, a) {
|
||||
const tops = wallTops(a.props);
|
||||
const want = new Set();
|
||||
for (let ri = 0; ri < result.regions.length; ri++) {
|
||||
const own = faces.filter((f) => f[4] === ri);
|
||||
let area = 0;
|
||||
for (const f of own) area += f[3];
|
||||
if (area > 0 && heldBy(own, tops) < RASTER_SKIP_HELD * area) want.add(ri);
|
||||
}
|
||||
return want;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every overhang face within reach of region `ri` (its bbox grown by two spans):
|
||||
* a wall under one region holds its neighbours too, so the race judges them all.
|
||||
*/
|
||||
function nearRegion(faces, ri) {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const f of faces) {
|
||||
if (f[4] !== ri) continue;
|
||||
x0 = Math.min(x0, f[0]); x1 = Math.max(x1, f[0]);
|
||||
y0 = Math.min(y0, f[1]); y1 = Math.max(y1, f[1]);
|
||||
}
|
||||
const reach = 2 * PROP.maxUnsupportedSpan;
|
||||
return faces.filter((f) => f[0] >= x0 - reach && f[0] <= x1 + reach
|
||||
&& f[1] >= y0 - reach && f[1] <= y1 + reach);
|
||||
}
|
||||
|
||||
/**
|
||||
* The normal-pass walls `qa` a region swapped to raster (`qb`) still keeps, or
|
||||
* null when the swap must not happen.
|
||||
*
|
||||
* Kept: any normal wall that stands clear of every kept wall (feet + 1 mm apart
|
||||
* in plan, so no two fuse) and either holds area they miss or grips lower than
|
||||
* they do. Torus X45: the raster rows missed a low ledge the normal pass's squat
|
||||
* wall held (69 mm2); bore_bracket X45: they dropped the wall whose tines gripped
|
||||
* 1.2 mm off the bed for one gripping from 22.9 mm. The swap must trade neither.
|
||||
* If the region's lowest grip still rises past GRIP_RISE, no swap.
|
||||
*/
|
||||
function keepers(qa, qb, near) {
|
||||
const kept = [...qb], extra = [];
|
||||
const bare = near.filter((f) => !heldBy([f], wallTops(kept)));
|
||||
const clear = (q) => kept.every((r) => planGap(q.line ?? [], r.line ?? []) >= halfFoot(q) + halfFoot(r) + 1);
|
||||
// lowest-gripping first, so a low wall claims its spot before a neighbour
|
||||
const order = [...qa].sort((x, y) => gripZ([x]) - gripZ([y]));
|
||||
for (const q of order) {
|
||||
if (!q.line?.length || !clear(q)) continue;
|
||||
const addsArea = heldBy(bare, q.line) >= RACE_ABS;
|
||||
const addsGrip = gripZ([q]) < gripZ(kept) - GRIP_RISE;
|
||||
if (!addsArea && !addsGrip) continue;
|
||||
extra.push(q);
|
||||
kept.push(q);
|
||||
for (let i = bare.length - 1; i >= 0; i--) if (heldBy([bare[i]], q.line)) bare.splice(i, 1);
|
||||
}
|
||||
return gripZ(kept) > gripZ(qa) + GRIP_RISE ? null : extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per region, keep the normal pass `a` or the raster pass `b` (plus the normal
|
||||
* walls `keepers` saves): the raster pass must hold RACE_REL / RACE_ABS more of
|
||||
* the faces near the region. Returns buildProps's shape, with `rasterRegions` =
|
||||
* how many regions swapped.
|
||||
*
|
||||
* `skipped` stays the normal pass's: a region the raster pass rescued may still
|
||||
* be counted as skipped there. Over-reporting a skip is the safe direction --
|
||||
* the readout never claims a region was served that wasn't.
|
||||
*/
|
||||
function raceRegions(faces, result, a, b) {
|
||||
const A = byRegion(a.props), B = byRegion(b.props);
|
||||
const out = [], props = [], served = new Set();
|
||||
let tines = 0, volume = 0, swapped = 0, sagRisk = a.sagRisk;
|
||||
const take = (build, q) => {
|
||||
const triRanges = [];
|
||||
for (const [s, e] of q.triRanges) {
|
||||
const r0 = out.length;
|
||||
for (let i = s; i < e; i++) out.push(build.triangles[i]);
|
||||
triRanges.push([r0, out.length]);
|
||||
}
|
||||
props.push({ ...q, id: props.length, triRanges, raster: build === b });
|
||||
served.add(q.region);
|
||||
tines += q.tines ?? 0;
|
||||
volume += q.volume ?? 0;
|
||||
};
|
||||
for (let ri = 0; ri < result.regions.length; ri++) {
|
||||
const qa = A.get(ri) ?? [], qb = B.get(ri) ?? [];
|
||||
let extra = null;
|
||||
if (qb.length) {
|
||||
const near = nearRegion(faces, ri);
|
||||
const ha = qa.length ? heldBy(near, wallTops(qa)) : 0;
|
||||
const hb = heldBy(near, wallTops(qb));
|
||||
if (hb > ha + Math.max(RACE_ABS, RACE_REL * ha)) extra = keepers(qa, qb, near);
|
||||
}
|
||||
if (!extra) { for (const q of qa) take(a, q); continue; }
|
||||
swapped++;
|
||||
sagRisk ||= b.sagRisk;
|
||||
for (const q of qb) take(b, q);
|
||||
for (const q of extra) take(a, q);
|
||||
}
|
||||
return { triangles: out, props, skipped: a.skipped, served: served.size,
|
||||
servedRegions: [...served], tines, sagRisk, volume, rasterRegions: swapped };
|
||||
}
|
||||
Reference in new issue
Block a user