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