Files
support-fins/web/draw.js
T

356 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DRAW MODE -- the user hands the tool one contact line and it sweeps ONE
* breakaway wall along it.
*
* This is exactly how tools/support/breakaway.py works in the video repo: a
* human places one wall per feature, so the wall's contact line is GIVEN, never
* guessed. That is the whole reason breakaway.py's output was always cleaner than
* the auto-placer's -- the sweep was never the fragile part, GUESSING a contact
* line for a 2D overhang region was. On a square face the auto-placer fit a PCA
* axis to a near-isotropic footprint and the eigenvector snapped to ~45deg, so
* the walls ran diagonally across the face and self-intersected. A line the user
* draws is straight in XY by construction; there is nothing left to snap.
*
* Geometry only. The interaction (picking the two endpoints, live preview, undo)
* lives in ui/walls.js; this module turns two surface points plus the part's triangles
* into a watertight wall, reusing prop/sweep.js's proven `sweep` and its three
* line-settling passes verbatim.
*/
import { PROP, PART_BAND, footFor, surfaceZsAt as gridZsAt, surfaceHitsAt as gridHitsAt, sweep, sweepBetween, sweepSquat, braceWall, floorLine, moldLine, contourTop, lowerSag, settleTop, emitTines, tineStepFor, crestOn, crestPart } from './prop.js';
/**
* A hand-drawn wall may be much shorter than an auto wall (PROP.minSpan, 7 mm): the user
* is pointing at one small overhang -- a cleat, a fingertip, a chin -- that no auto
* wall reaches. 2 mm still gives the three stations a sweep needs (local issue 011).
*/
export const DRAW_MIN_LEN = 2;
/**
* Every surface height directly above (x, y), as a list.
*
* `surfaceZAt` in prop/surface.js returns only the LOWEST, which is what an auto-placer
* wants (the underside a wall props to). A hand-drawn wall instead wants the
* surface the user actually clicked, so this keeps them all and the caller picks
* the one nearest the line the user drew -- otherwise a wall under a shallow
* overhang would jump down to whatever plate-resting geometry shares its (x, y).
*/
function surfaceZsAt(tris, x, y) {
return surfaceHits(tris, x, y).map((h) => h[0]);
}
/** ...and with each, its normal's z (unit): [z, nz]. The full-wall checks (local issue
* 033) ask about surfaces, not parity: a mesh built from bodies touching face to face
* (bridge.stl's deck on its piers) carries coincident inner faces, and an inside/outside
* count through them reads air as solid. Through prop/surface.js's XY grid: a linear
* scan here was 70% of a Full build on a 1.56M-face mini (fill.js checks every try
* through these). A grid cell lists its triangles in index order, so the hits come
* back in the same order the scan gave and onThePart's nearest-hit tie-break holds. */
const surfaceHits = gridHitsAt;
/**
* Is (x, y, z) inside the part? Odd number of surfaces below it in that column. The
* squat fallback has no clearance probe of its own (Auto's stationIsClear needs the
* topology), and at 0.6 mm of headroom a line on an UPWARD face reads that face as its
* floor -- so it checks the solid directly.
*/
function solidAt(tris, x, y, z) {
// a column through a shared edge hits both triangles: one surface, counted once
const zs = surfaceZsAt(tris, x, y).filter((zz) => zz < z).sort((p, q) => p - q);
let n = 0;
for (let i = 0; i < zs.length; i++) if (i === 0 || zs[i] - zs[i - 1] > 1e-5) n++;
return n % 2 === 1;
}
/** Each station sits under a DOWN-facing surface: air just below it and part within
* half a mm above (the settle passes leave a top a little under the underside). */
function underAnOverhang(line, tris) {
return line.every((p) => !solidAt(tris, p[0], p[1], p[2] - 0.02)
&& [0.02, 0.1, 0.25, 0.5].some((d) => solidAt(tris, p[0], p[1], p[2] + d)));
}
/** Does an interior station lie ON the part -- the surface it touches facing up? A line
* drawn on an upward face does; one under an overhang touches a face looking down. Not
* the end stations: Auto and the user put those on the overhang's edge. */
function onThePart(line, tris) {
return line.some((p, i) => {
if (i === 0 || i === line.length - 1) return false;
let best = null;
for (const h of surfaceHits(tris, p[0], p[1])) {
if (Math.abs(h[0] - p[2]) < 0.5 && (!best || Math.abs(h[0] - p[2]) < Math.abs(best[0] - p[2]))) best = h;
}
return best !== null && best[1] > 0;
});
}
/** Does part surface cross z0..z1(i) anywhere under the wall, w(i) either side of its
* centre line? A body resting on the plate beside a plate wall's flange puts its bottom
* face in the flange's band; one standing in its stem, a side face. Sampled every
* BAND_STEP along and across -- at the stations alone, a pin 0.5 mm thick slipped
* between them -- through prop.js's gridded surfaceZsAt (a mini has ~1M faces). */
const BAND_STEP = 0.25;
function bandHitsPart(line, tris, w, z0, z1) {
for (let i = 0; i + 1 < line.length; i++) {
const p = line[i], q = line[i + 1];
const len = Math.hypot(q[0] - p[0], q[1] - p[1]);
if (len < 1e-9) continue;
const rx = (q[0] - p[0]) / len, ry = (q[1] - p[1]) / len;
const wi = Math.max(w(i), w(i + 1)), top = Math.max(z1(i), z1(i + 1));
const na = Math.ceil(len / BAND_STEP), nc = Math.ceil(2 * wi / BAND_STEP);
for (let s = 0; s <= na; s++) for (let k = 0; k <= nc; k++) {
const t = len * s / na, o = -wi + 2 * wi * k / nc;
for (const z of gridZsAt(tris, p[0] + rx * t + ry * o, p[1] + ry * t - rx * o)) {
if (z >= z0 && z <= top) return true;
}
}
}
return false;
}
/** Points across a wall at each station, `w` either side of its centre line, at height z(i). */
function acrossClear(line, tris, w, z) {
for (let i = 0; i < line.length; 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);
if (rn < 1e-9) return false;
rx /= rn; ry /= rn;
for (const o of [-w, -w / 2, 0, w / 2, w]) {
if (solidAt(tris, line[i][0] + ry * o, line[i][1] - rx * o, z(i))) return false;
}
}
return true;
}
/**
* The contact polyline for a wall drawn from `a` to `b` (both surface points in
* PRINT space, [x, y, z]). Straight in XY by construction. Each station's height
* is the part surface nearest the line the user drew -- not the global lowest,
* which would jump to another feature -- and then the three prop/contact.js passes pull
* the top to a clean `gap` below the part exactly as the auto-placer does. `under`
* limits the pick to down-facing surfaces (the fill pass; see below).
*/
export function drawnLine(a, b, tris, step = PROP.stationStep, band = Infinity, under = false) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (len < 1e-6) return null;
const n = Math.max(PROP.minStations - 1, Math.ceil(len / step));
const line = [];
for (let k = 0; k <= n; k++) {
const t = k / n;
const x = a[0] + dx * t, y = a[1] + dy * t;
const hint = a[2] + (b[2] - a[2]) * t; // the height the drawn line implies here
let z = hint, best = Infinity;
// `under`: only surfaces facing DOWN. A column through a leaning figure meets its
// back (the underside, red) and its front; on a curved back the straight-line
// hint lands nearer the front, so a long wall the fill pass laid down a spine
// read "faces up" at every station. A hand-drawn line keeps the nearest of all.
// (Through prop's grid: the fill pass runs this per candidate line, and a linear
// scan per station was minutes on a 1M-face mini.)
const zs = under ? gridHitsAt(tris, x, y).filter((h) => h[1] < 0).map((h) => h[0]) : surfaceZsAt(tris, x, y);
for (const zz of zs) {
const d = Math.abs(zz - hint);
if (d < best) { best = d; z = zz; }
}
line.push([x, y, z]);
}
// `band` bounds every settle pass to surfaces within `band` of the clicked
// overhang, so an over-the-part overhang isn't dragged down onto its own floor
// (the collapse that made part-attached supports impossible). Infinity keeps
// the bed-attached behaviour exactly.
contourTop(line, tris, band);
lowerSag(line, tris, band);
settleTop(line, tris, 0.25, band);
return line;
}
/**
* Build one drawn breakaway wall. Returns `{ ok: true, tris, length, height, top }`
* (`top`: the wall's contact line, the surface-z stations its top follows).
* `opts.under`: follow only down-facing surfaces (drawnLine) -- the fill pass's walls.
* `opts.plateOnly`: never stand on the part; a line with part under it is refused (#218).
* `opts.brace`: a wall that stands on the plate over BRACE.aspect x its length gets
* ribs at the plate (prop/brace.js; `braces` counts them). `opts.braceTall`: one
* over BRACE.tallH gets face ribs along it (the Brace tall walls box), each kept
* clear of other supports by `opts.braceClear(tris)` when given. `floors`: each station's
* bottom z, for a caller that braces later (the fill pass, once every wall is in).
* or `{ ok: false, reason }` with a message the UI can show -- a hand-drawn wall
* that can't be built should say WHY (too short, at the plate) rather than
* silently doing nothing, the failure mode M5's scoreboard was built on.
*/
export function drawnWall(a, b, tris, zBed = 0, opts = {}) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (len < DRAW_MIN_LEN) {
return { ok: false, reason: `wall too short — ${len.toFixed(1)}mm, needs ${DRAW_MIN_LEN}mm` };
}
const out = [];
if (crestOn()) crestPart(tris, opts.layerHeight); // the interface crest's 'flat' test reads the underside here
// A drawn wall grips the part with the same tine comb the auto fins use, when
// Tines is on. emitTines needs the part in topology form (for bite direction), so
// it only runs when the caller passes topo/rot/offset -- the live preview omits
// them and stays a plain wall for speed. `topLine` is the wall's surface-z contact
// line (emitTines subtracts the gap itself).
// (`minTop`: a squat plate wall's base is its thin brim, so its tines start from
// squatBrimH, as Auto's squat walls do -- the flange default would skip every one)
const withTines = (line, minTop = undefined) => opts.tines && opts.topo
? emitTines(line, tris, opts.topo, opts.rot, opts.offset, out,
tineStepFor(opts.tineDensity), minTop, opts.layerHeight ?? PROP.tineH)
: 0;
const withBraces = (line, floorZ) => opts.brace || opts.braceTall
? braceWall(line, floorZ, tris, zBed, out, opts.braceClear, { slim: !!opts.brace, tall: !!opts.braceTall })
: 0;
// PART-ATTACHED first: if solid part sits below the overhang, the support
// stands on THAT, not the plate. Probe with a BANDED top contour so the
// overhang isn't settled down onto the very floor we're looking for; floorLine
// then returns the nearest surface below each station (0 where the path to the
// plate is open). A real floor anywhere along the line routes the whole wall
// through sweepBetween, whose bottom is the per-station floor -- bed stations
// degrade to z=0 on their own. This is what stops the "marched past the part
// straight to the plate" bug: on the over-the-part case sweep-to-plate SUCCEEDS
// and silently builds the tall stilt, so the fix must PREFER the floor.
const topPA = drawnLine(a, b, tris, PROP.stationStep, PART_BAND, !!opts.under);
// ...unless the line lies ON the part: drawn on an UPWARD face (the UI lets you
// click one), it read the part's own underside as its floor and stood the wall
// inside the part, floor to top (local issue 033: a sphere's top, 296-384
// vertices in). That line falls through to the plate wall below.
if (topPA && topPA.length >= PROP.minStations && !onThePart(topPA, tris)) {
const floor = floorLine(topPA, tris);
let floorMax = 0;
for (const p of floor) if (p[2] > floorMax) floorMax = p[2];
// Plate only (#218): part under the overhang refuses the wall outright -- the
// plate wall below would stilt straight through that part.
if (opts.plateOnly && floorMax > PROP.gap + 0.5) {
return { ok: false, reason: 'part of the model sits under this overhang, and '
+ 'Plate only is on — a support can’t stand on the part here' };
}
const mold = floorMax > PROP.gap + 0.5 ? moldLine(topPA, tris) : null;
if (mold && sweepBetween(mold.top, mold.floor, out)) {
let height = 0;
for (let i = 0; i < topPA.length; i++) {
height = Math.max(height, (topPA[i][2] - PROP.gap) - floor[i][2]);
}
// a station whose floor is open air runs down to the plate (floorLine's 0): with
// no flange there, it is the one that most needs a brace (Isaac's hands)
const braces = withBraces(topPA, (i) => floor[i][2]);
const tines = withTines(topPA);
return { ok: true, tris: out, length: len, height, partAttached: true, tines, braces, top: topPA,
floors: floor.map((f) => f[2]) };
}
}
const line = drawnLine(a, b, tris, PROP.stationStep, Infinity, !!opts.under);
if (!line || line.length < PROP.minStations) {
return { ok: false, reason: 'no surface found along that line' };
}
// The flange and the stem must clear the part beside the line (Auto's clearance
// pass measures the same profile): drawn beside a body resting on the plate, the
// flange reached 0.4-1.1 mm into it (local issue 033). Checked before sweep writes.
const top = (i) => line[i][2] - PROP.gap - zBed;
if (line.every((p, i) => top(i) >= PROP.minHeight)
&& (bandHitsPart(line, tris, (i) => footFor(top(i)), zBed - 0.01, () => zBed + PROP.baseH + PROP.gap)
|| bandHitsPart(line, tris, () => PROP.th / 2, zBed - 0.01, (i) => zBed + top(i) / 2))) {
return { ok: false, reason: 'too close to the part — the wall’s foot would cut '
+ 'into it; draw the line a little further out' };
}
// Reaching here means no real floor was found below the overhang, so this is a
// plate-attached wall. `sweep` returns false when any station is shorter than
// PROP.minHeight, and two situations produce that:
// - the drawn line genuinely sits near the plate (drawn by a resting edge);
// - the user pointed at a real overhang HIGH above the bed, but other part
// geometry sits directly under it, so contourTop/settleTop pull the wall
// top down to that lower surface and it collapses.
// The over-the-part case (the L-bracket boss over its base) is now handled by
// the part-attached branch above, which stands on that lower surface instead of
// collapsing -- so a failure here that still LOOKS over-the-part means the floor
// was out of reach (too thin a gap to seat a wall), not that we ignore it.
// The clicked endpoints' heights are exactly "what the user pointed at", so a
// high clickTop with a failed sweep is the unreachable case, not a low line.
if (!sweep(line, zBed, out)) {
// Neither full-height wall fits. Before refusing, the two SQUAT walls a user
// drawing by hand can still want (local issue 011) -- tried only now, so every
// drawn wall that built before builds exactly the same:
// - on the part, with as little as minHeightSquat headroom (a hand just over a thigh);
// - on the plate, its low stations a brimmed squat stem, the tall ones the full wall.
const squat = squatWall(topPA, line, tris, zBed, !!opts.plateOnly);
if (squat) {
out.push(...squat.tris);
const tines = withTines(squat.top, squat.partAttached ? undefined : PROP.squatBrimH);
return { ok: true, tris: out, length: len, height: squat.height, partAttached: squat.partAttached, squat: true, tines, top: squat.top };
}
const clickTop = Math.min(a[2], b[2]) - zBed;
if (clickTop >= PROP.minHeight + PROP.gap) {
return { ok: false, reason: 'this overhang sits above another part of the '
+ 'model, so a wall standing on the plate can’t reach it — rotate so it '
+ 'faces the plate' };
}
return { ok: false, reason: 'nothing to hold up there — the line sits at the plate' };
}
let height = 0;
for (const p of line) height = Math.max(height, p[2] - PROP.gap - zBed);
const braces = withBraces(line, () => zBed);
const tines = withTines(line);
return { ok: true, tris: out, length: len, height, tines, braces, top: line, floors: line.map(() => zBed) };
}
/**
* The squat fallback for a drawn line no full-height wall fits: `{ tris, top, height,
* partAttached }` or null. Part first (sweepBetween down to minHeightSquat of headroom --
* in practice just over 1 mm of room, since floorLine skips surfaces within its 1 mm margin),
* then the plate: stations from minHeightSquat to minHeight get a brimmed squat stem
* (sweepSquat, what Auto builds near the bed), taller runs keep the flanged wall.
* A station under minHeightSquat still refuses the whole line. `plateOnly`: the plate only.
*/
function squatWall(topPA, line, tris, zBed, plateOnly = false) {
if (!plateOnly && topPA && topPA.length >= PROP.minStations && underAnOverhang(topPA, tris)) {
const floor = floorLine(topPA, tris);
// the wall's middle, between its floor and its top, is air (on an upward slope
// the "floor" is the slope itself and this is solid)
const midClear = acrossClear(topPA, tris, PROP.th / 2,
(i) => (topPA[i][2] - PROP.gap + floor[i][2] + PROP.footGap) / 2);
if (midClear && floor.some((p) => p[2] > PROP.gap + 0.5)) {
const mold = moldLine(topPA, tris);
const t = [];
if (mold && sweepBetween(mold.top, mold.floor, t, PROP.minHeightSquat)) {
let height = 0;
for (let i = 0; i < topPA.length; i++) height = Math.max(height, topPA[i][2] - PROP.gap - floor[i][2]);
return { tris: t, top: topPA, height, partAttached: true };
}
}
}
const h = line.map((p) => p[2] - PROP.gap - zBed);
if (h.some((v) => v < PROP.minHeightSquat)) return null;
// a plate wall stands only where the way down is clear: part under a station (too
// close for the squat wall on it above) would put this one straight through it
if (floorLine(line, tris).some((p) => p[2] > PROP.gap + 0.5)) return null;
// ...and only under a real overhang, with room for the whole brim (squatBrimW either
// side, wider than the stem floorLine looked under) and the stem beside the line
if (!underAnOverhang(line, tris)) return null;
// (probed at the brim's TOP plus the gap: under an underside that dips beside the
// line, mid-height passed while the brim's top corner sat in the part -- torus)
if (!acrossClear(line, tris, PROP.squatBrimW, () => zBed + PROP.squatBrimH + PROP.gap)) return null;
if (!acrossClear(line, tris, PROP.th / 2, (i) => zBed + (line[i][2] - PROP.gap - zBed) / 2)) return null;
// runs of tall (full wall) and low (squat) stations; a tall run under two stations
// can't sweep, so it goes squat with its neighbours
const tall = h.map((v) => v >= PROP.minHeight);
for (let i = 0; i < tall.length; i++) {
if (tall[i] && !tall[i - 1] && !tall[i + 1]) tall[i] = false;
}
const t = [];
let i = 0;
while (i < line.length) {
let j = i;
while (j + 1 < line.length && tall[j + 1] === tall[i]) j++;
if (tall[i]) {
if (!sweep(line.slice(i, j + 1), zBed, t)) return null;
} else {
// a squat run reaches TWO stations into each tall neighbour: the tall sweep ends at
// its own first/last station, so one would only meet it face to face -- the pieces
// must overlap, never sit flush (Fusion's weld). sweepSquat takes any h >= 0.6.
const seg = line.slice(Math.max(0, i - 2), Math.min(line.length, j + 3));
if (seg.length < 2 || !sweepSquat(seg, zBed, t)) return null;
}
i = j + 1;
}
return { tris: t, top: line, height: Math.max(...h), partAttached: false };
}