remove fins: a removed fin stays removed when the support gap changes (045) -- its key matched exactly and held the wall top's z and height, which the gap moves, so 0.2 -> 0.3 brought every removed fin back; now a key takes the nearest fin of its kind and bearing within 1.5 mm (z only a tie-break, so stacked walls stay apart), and older saved keys still match

This commit is contained in:
matthewtrahan authored and Matthew Trahan committed 2026-10-16 16:50:16 -05:00
1 parent 5e00c04911
commit da80a6eb48
4 files changed
+201 -40

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
// FIN KEYS (local issue 045): a removed Auto fin stays removed when a rebuild
// moves it a little. The key used to be matched exactly and held the wall top's z
// and height, both measured below the part by the support gap, so a gap change
// re-keyed every fin and every removed fin came back. Pinned here:
// - a gap change 0.2 -> 0.3 keeps every removal (exact keys kept none);
// - a key matches its own fin, and nothing in another pose;
// - two walls stacked at one x/y stay two fins; a reversed line is the same fin;
// - an older session's key (taken at the middle VERTEX) still matches.
// The page glue (ui/remove.js) needs a DOM; this is the matching under it.
import { WEB, assert, loadModel, analyze, rotX } from './_util.js';
const { buildFins } = await import(`${WEB}fins.js`);
const { assignKeys, matchRemoved, parseKey, finKey } = await import(`${WEB}ui/finkeys.js`);
const records = (topo, res, rot, gap) => assignKeys(
buildFins(topo, res, rot, { mode: 'auto', bedPad: true, tunables: { propGap: gap } })
.fins.map((f) => ({ ...f })).filter((f) => f.line?.length));
for (const name of ['lowledge', 'tshape', 'torus']) {
Deno.test(`fin keys: ${name} X30 -- removals hold across a gap change, and stay in their pose`, () => {
const topo = loadModel(name), rot = rotX(30), res = analyze(topo, 45, rot);
const A = records(topo, res, rot, 0.2), B = records(topo, res, rot, 0.3);
assert(A.length > 0 && A.length === B.length, `${name}: fins ${A.length} -> ${B.length}`);
const keys = new Set(A.map((r) => r.sig));
assert(B.filter((r) => keys.has(r.sig)).length < A.length, `${name}: exact keys already survive -- not the 045 case`);
const m = matchRemoved(B, keys);
assert(m.size === A.length, `${name}: ${m.size}/${A.length} removals kept at gap 0.3`);
const self = matchRemoved(A, keys);
assert(A.every((r) => self.get(r.id) === r.sig), `${name}: a key matched another fin of its own build`);
const up = rotX(0), other = records(topo, analyze(topo, 45, up), up, 0.2);
assert(matchRemoved(other, keys).size === 0, `${name}: a removal reached into the upright pose`);
});
}
const rec = (id, line, height = 10, kind = 'prop') => ({ id, kind, line, height });
Deno.test('fin keys: stacked walls at one x/y stay two fins; a reversed line is the same fin', () => {
const low = rec(0, [[0, 0, 10], [10, 0, 10]]), high = rec(1, [[0, 0, 30], [10, 0, 30]]);
const [a, b] = assignKeys([low, high]);
const m = matchRemoved([low, high], new Set([b.sig]));
assert(m.size === 1 && m.has(1), `the high wall's key matched ${JSON.stringify([...m])}`);
const flipped = assignKeys([rec(5, [[10, 0.3, 10.1], [0, 0.3, 10.1]])]);
assert(matchRemoved(flipped, new Set([a.sig])).get(5) === a.sig, 'a wall run the other way lost its removal');
const turned = assignKeys([rec(6, [[5, -5, 10], [5, 5, 10]])]);
assert(matchRemoved(turned, new Set([a.sig])).size === 0, 'a crossing wall took the removal');
});
Deno.test('fin keys: one key per fin, repeats and suffixes parse', () => {
const twins = assignKeys([rec(0, [[0, 0, 5], [4, 0, 5]]), rec(1, [[0, 0, 5], [4, 0, 5]])]);
assert(twins[0].sig !== twins[1].sig && twins[1].sig.endsWith('#1'), `twins keyed ${twins.map((r) => r.sig)}`);
const m = matchRemoved(twins, new Set(twins.map((r) => r.sig)));
assert(m.size === 2, 'both twins removable');
const p = parseKey('wedge:1.5,-2,3:4:0.7#1~2');
assert(p && p.kind === 'wedge' && p.x === 1.5 && p.y === -2 && p.z === 3 && p.bearing === 0.7, JSON.stringify(p));
});
Deno.test("fin keys: an older session's key (the middle vertex) still matches", () => {
// a 2-point wall: the old key sat at its END, half a wall from the mid-length point
const wall = rec(0, [[0, 0, 10], [20, 0, 10]]);
const old = finKey(wall, true);
assert(parseKey(old).x === 20, `legacy key ${old}`);
const [now] = assignKeys([{ ...wall }]);
assert(matchRemoved([now], new Set([old])).get(0) === old, 'the saved removal came back');
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Fin keys: how a removed Auto fin is found again in a fresh build (ui/remove.js).
*
* A removal is remembered by a spatial key, not the build's sequential id, so it
* survives a rebuild. The key used to be matched EXACTLY, and it held the wall
* top's z and height -- both measured below the part by the support gap, so
* changing the gap re-keyed every fin and the removed ones came back (local issue
* 045). A rebuild mostly moves a fin up or down, a little sideways at most (gap
* 0.2 -> 0.3: m4 bracket 92/93 fins within 0.05 mm in x/y, the rest within 1 mm),
* so a key now matches the NEAREST fin of the same kind and bearing within a
* tolerance. z stays in the key as a tie-break only: two walls stacked at one x/y
* on different levels are still two fins.
*
* Pure (no DOM, no three.js) so tests can run it against real builds.
*/
const XY_TOL = 1.5; // mm a fin may move sideways and still be the same fin
const Z_TOL = 2; // mm up or down: a gap step moves a wall top ~0.1-0.2
const BEARING_TOL = 0.15; // rad
const r1 = (v) => Math.round(v * 10) / 10; // 0.1 mm
/** The point halfway along a polyline's length (not its middle vertex, which
* jumps when the line gains a point). */
function midAlong(line) {
if (line.length === 1) return line[0];
let total = 0;
for (let i = 1; i < line.length; i++) total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
let left = total / 2;
for (let i = 1; i < line.length; i++) {
const a = line[i - 1], b = line[i];
const seg = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (seg >= left && seg > 0) {
const t = left / seg;
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), a[2] + t * (b[2] - a[2])];
}
left -= seg;
}
return line[line.length - 1];
}
/** A fin record's key: kind, mid-length point, height, bearing. The same text
* format as before 045 (kind:x,y,z:height:bearing), so a saved session's or an
* undo snapshot's keys still parse. */
export function finKey(r, legacy = false) {
if (!r || !r.line || !r.line.length) return null;
// legacy: the middle VERTEX, what keys saved before 045 were taken at
const m = legacy ? r.line[Math.floor(r.line.length / 2)] : midAlong(r.line);
const a = r.line[0], b = r.line[r.line.length - 1];
return `${r.kind ?? 'prop'}:${r1(m[0])},${r1(m[1])},${r1(m[2])}`
+ `:${r1(r.height ?? 0)}:${r1(Math.atan2(b[1] - a[1], b[0] - a[0]))}`;
}
/** Key each record, UNIQUELY: a symmetric part can give two fins one key, and a
* shared key would make one removal click drop both. A repeat gets `#n`. */
export function assignKeys(records) {
const seen = new Map();
for (const r of records) {
const base = finKey(r);
if (base == null) { r.sig = null; continue; }
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
r.sig = n === 0 ? base : `${base}#${n}`;
r.legacySig = finKey(r, true); // matched too, so an older session's removals hold
}
return records;
}
/** {kind, x, y, z, bearing} from a key, or null. Ignores the `#n` / `~n`
* suffixes that keep keys unique. */
export function parseKey(key) {
const m = /^([^:]+):(-?[\d.]+),(-?[\d.]+),(-?[\d.]+):(-?[\d.]+):(-?[\d.]+)(?:[#~]\d+)*$/.exec(key ?? '');
return m ? { kind: m[1], x: +m[2], y: +m[3], z: +m[4], bearing: +m[6] } : null;
}
/** Bearing difference of two undirected lines (a rebuild may run a wall the
* other way), in [0, pi/2]. */
function bearingGap(a, b) {
const d = Math.abs(a - b) % Math.PI;
return Math.min(d, Math.PI - d);
}
/**
* Match removed keys to a build's records: each key takes the nearest record of
* its kind and bearing within the tolerances, one record per key, closest pairs
* first. A key with no fin near it (another pose, or a fin the new build doesn't
* make) matches nothing and is kept for later.
* Returns Map record id -> the removed key it matched.
*/
export function matchRemoved(records, removedKeys) {
const keys = [...removedKeys].map((k) => [k, parseKey(k)]).filter(([, p]) => p);
const recs = [];
for (const r of records) for (const s of [r.sig, r.legacySig]) { const q = parseKey(s); if (q) recs.push([r, q]); }
const pairs = [];
for (const [k, p] of keys) {
for (const [r, q] of recs) {
if (p.kind !== q.kind || bearingGap(p.bearing, q.bearing) > BEARING_TOL) continue;
const dxy = Math.hypot(p.x - q.x, p.y - q.y), dz = Math.abs(p.z - q.z);
if (dxy > XY_TOL || dz > Z_TOL) continue;
pairs.push([Math.hypot(dxy, dz), k, r]);
}
}
pairs.sort((a, b) => a[0] - b[0]);
const out = new Map(), used = new Set();
for (const [, k, r] of pairs) {
if (used.has(k) || out.has(r.id)) continue;
used.add(k); out.set(r.id, k);
}
return out;
}
+25 -39
View File
@@ -17,6 +17,7 @@ import { lastBuilt, finMesh, setFinTris } from './finbuild.js';
import { clearPreview, syncDrawControls } from './walls.js';
import { updateReadout } from './readout.js';
import { histPush } from './history.js';
import { assignKeys, matchRemoved } from './finkeys.js';
// Auto fins are a flat triangle soup in ONE mesh, but each fin record now carries
// its triangle segment(s) (built.fins[i].triRanges, vertex-indexed into
@@ -25,11 +26,12 @@ import { histPush } from './history.js';
// orientation rebuild (slider tweak) and even an orientation round-trip: the sig
// matches the fin back to its place, and a different orientation's fins simply
// don't match (so nothing is spuriously removed on a re-pose). removedIds is
// derived per build from removedSigs ∩ the current records, so the filter never
// has to track an id across rebuilds.
// derived per build by matching removedSigs to the nearest current records
// (ui/finkeys.js), so the filter never has to track an id across rebuilds.
let finRecords = []; // [{id, kind, triRanges, line, height, sig}], one per auto fin
export let removedSigs = new Set(); // spatial signatures of fins the user clicked away
export let removedIds = new Set(); // ids removed in the CURRENT build (derived in applyBuilt)
let removedBy = new Map(); // id -> the removed sig it matched (ui/finkeys.js), for Restore
export let removeMode = false; // armed: hover highlights a fin red, click removes it
let hoverFinMesh = null; // red translucent overlay of the fin under the pointer
let hoverFinId = null; // id of the fin hoverFinMesh currently draws (rebuild cache)
@@ -43,34 +45,6 @@ const hoverFinMaterial = new THREE.MeshStandardMaterial({
polygonOffsetFactor: -4, polygonOffsetUnits: -4, clippingPlanes: clip,
});
/** Spatial signature: kind + rounded line midpoint + height + bearing. A content
* key, not the sequential id, so the same fin matches across rebuilds. */
function finSig(r) {
if (!r || !r.line || !r.line.length) return null;
const mid = r.line[Math.floor(r.line.length / 2)];
const a = r.line[0], b = r.line[r.line.length - 1];
const r1 = (v) => Math.round(v * 10) / 10; // 0.1 mm
return `${r.kind ?? 'prop'}:${r1(mid[0])},${r1(mid[1])},${r1(mid[2])}`
+ `:${r1(r.height ?? 0)}:${r1(Math.atan2(b[1] - a[1], b[0] - a[0]))}`;
}
/** Assign each record a UNIQUE sig. finSig is content-derived, so two fins on a
* symmetric/mirrored part can hash identically; a bare collision would make one
* removal click drop both and leave neither individually restorable. Disambiguate
* colliding sigs with an occurrence suffix -- deterministic build order keeps it
* stable within an orientation, and the first occurrence keeps the bare sig. */
function assignSigs(records) {
const seen = new Map();
for (const r of records) {
const base = finSig(r);
if (base == null) { r.sig = null; continue; }
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
r.sig = n === 0 ? base : `${base}#${n}`;
}
return records;
}
/** Flatten the KEPT fin records' triangle segments into one vertex array, tagging
* each triangle with its owning record so a pick can map faceIndex → fin. */
function filteredTriangles(allTris, records, removed) {
@@ -101,7 +75,7 @@ function finTrisForRecord(rec) {
* removal / restore -- the geometry already exists, filtering is cheap. */
function rebuildFinMesh() {
if (!lastBuilt) return;
removedIds = new Set(finRecords.filter((r) => removedSigs.has(r.sig)).map((r) => r.id));
matchRecords();
clearFinHover(); // ids may shift after a rebuild; force the next hover to redraw
const { tris, map } = filteredTriangles(lastBuilt.triangles, finRecords, removedIds);
triToFin = map;
@@ -192,7 +166,11 @@ export function clickRemove(ev) {
const rec = pickFin(ev);
if (!rec || !rec.sig) return;
histPush();
removedSigs.add(rec.sig);
// a stored sig from an earlier build can read the same as this fin's while it
// names another fin (matched by nearness): keep each stored sig unique
let sig = rec.sig;
for (let n = 1; removedSigs.has(sig); n++) sig = `${rec.sig}~${n}`;
removedSigs.add(sig);
rebuildFinMesh();
syncRemoveUI();
}
@@ -203,22 +181,31 @@ export function clickRemove(ev) {
* Returns the KEPT triangles -- the fin mesh shows the filtered set.
*/
export function adoptFins(built) {
finRecords = assignSigs((built.fins ?? []).map((f) => ({ ...f })));
removedIds = new Set(finRecords.filter((r) => removedSigs.has(r.sig)).map((r) => r.id));
finRecords = assignKeys((built.fins ?? []).map((f) => ({ ...f })));
matchRecords();
const { tris, map } = filteredTriangles(built.triangles, finRecords, removedIds);
triToFin = map;
return tris;
}
/** Which of this build's fins the removed sigs name: the nearest fin to each, not
* an exact sig match, so a removal holds when a gap change moves the wall tops
* (ui/finkeys.js). */
function matchRecords() {
removedBy = matchRemoved(finRecords, removedSigs);
removedIds = new Set(removedBy.keys());
}
/** No auto fins on screen (fins off, or Draw mode): no records to pick from. */
export function forgetFins() {
finRecords = []; triToFin = []; removedIds = new Set();
finRecords = []; triToFin = []; removedIds = new Set(); removedBy = new Map();
}
/** A new part: signatures can coincidentally match a different model's fins. */
export function resetRemovals() {
removedSigs = new Set();
removedIds = new Set();
removedBy = new Map();
}
/** Undo/redo: put back a snapshot's removals, disarmed. */
@@ -232,12 +219,11 @@ el('remove-fins-toggle').addEventListener('click', () => {
else beginRemove();
});
el('restore-fins').addEventListener('click', () => {
// Restore only THIS orientation's removals: drop the sigs of the fins present
// in the current build, leaving removals made in other poses intact (deleting a
// missing sig is a harmless no-op).
// Restore only THIS orientation's removals: drop the sigs that matched a fin in
// the current build, leaving removals made in other poses intact.
if (!removedIds.size) return;
histPush();
for (const r of finRecords) removedSigs.delete(r.sig);
for (const sig of removedBy.values()) removedSigs.delete(sig);
rebuildFinMesh();
syncRemoveUI();
});
+1 -1
View File
@@ -8,7 +8,7 @@
* walls again -- selectable, removable, undoable, re-swept when the part turns --
* and Auto's fins rebuild from the same settings in the same pose, so nothing is
* doubled and there is nothing to clear. The fins the user removed from them go
* too: a removal is keyed by the fin's print-space line (ui/remove.js finSig), and
* too: a removal is keyed by the fin's print-space line (ui/finkeys.js finKey), and
* the part comes back to the same place in print space.
*
* Points are stored relative to the centre of the part's bounding box in print