Files

242 lines
14 KiB
JavaScript

// Draw mode places a hand-drawn breakaway WALL under the line the user draws, and
// (Matthew's ask) that wall grips the part with the same tine comb the auto fins
// use when Tines is on. These pin: a drawn wall builds, it carries tines with the
// toggle on and none with it off, the geometry stays watertight, and the tines --
// and only the tines -- bite into the part.
import { WEB, tiltedBlockTopo, prop, block, buildTopology, loadModel, isClosed, insideCount, assert } from './_util.js';
const { drawnWall, DRAW_MIN_LEN } = await import(`${WEB}draw.js`);
const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const OFF = { x: 0, y: 0, z: 0 };
// A 40x60x12 block tilted 45 deg about X (baked into the verts, min z = 0), so its
// underside is a broad downward overhang whose face still has enough horizontal
// normal to grip. The tilt is about X, so at a fixed Y the underside height is
// constant in X -- draw the wall along X for a clean, level contact line.
function tiltedBlock() {
const topo = tiltedBlockTopo(-20, 20, -30, 30, -6, 6, 45);
// Find a Y where the vertical ray passes through the solid (top + underside) with
// the underside standing a good few mm off the plate -- a real overhang to prop.
let best = null;
for (let y = -25; y <= 25; y += 0.5) {
const zs = prop.surfaceZsAt(topo.pos, 0, y);
if (zs.length < 2) continue;
const under = Math.min(...zs), top = Math.max(...zs);
if (under < 4 || top - under < 1) continue; // too near the plate / too thin
if (!best || under > best.under) best = { y, under, top };
}
assert(best, 'test setup: found no overhang Y on the tilted block');
return { topo, y: best.y, z: best.under };
}
function draw(tines) {
const { topo, y, z } = tiltedBlock();
const a = [-8, y, z], b = [8, y, z]; // 16mm along X, level underside
const r = drawnWall(a, b, topo.pos, 0,
{ tines, topo, rot: IDENTITY, offset: OFF, tineDensity: 1 });
return { topo, r };
}
Deno.test('draw: a wall builds under the drawn line on a tilted overhang', () => {
const { r } = draw(false);
assert(r.ok, `drawn wall failed: ${r.reason}`);
assert(r.tris.length > 0, 'drawn wall produced no geometry');
});
Deno.test('draw: Tines ON grips the part, OFF is a plain breakaway wall', () => {
const on = draw(true).r;
const off = draw(false).r;
assert(on.ok && off.ok, 'drawn wall failed to build');
assert(on.tines > 0, `Tines on emitted no tines (${on.tines})`);
assert(!off.tines, `Tines off still emitted tines (${off.tines})`);
});
Deno.test('draw: a tined wall is watertight', () => {
const { r } = draw(true);
assert(isClosed(r.tris), 'tined drawn-wall geometry is not closed');
});
Deno.test('draw: only the tines bite into the part, never the wall', () => {
const { topo, r: on } = draw(true);
const off = draw(false).r;
const inWith = insideCount(topo, IDENTITY, OFF, on.tris);
const inWithout = insideCount(topo, IDENTITY, OFF, off.tris);
// The bare wall stands off by the breakaway gap -- nothing of it inside the part.
assert(inWithout === 0, `the plain wall pokes into the part (${inWithout} verts inside)`);
// Turning tines on is the ONLY thing that adds interior bite.
assert(inWith > inWithout, 'Tines on added no bite into the part');
});
// ---- Draw opens up (local issue 011): short walls, squat walls on the plate and on the part.
// Each case here was refused before; a drawn wall that built before builds the same (golden
// cube-x40-draw), because the squat walls are only tried after both full-height walls fail.
const topoOf = (...blocks) => {
const pos = new Float32Array(blocks.flatMap((b) => [...b]));
return buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
};
const lowest = (tris) => Math.min(...tris.map((v) => v[2]));
Deno.test('draw: a short wall (3 mm) builds; under DRAW_MIN_LEN still refuses', () => {
const { topo, y, z } = tiltedBlock();
const r = drawnWall([-1.5, y, z], [1.5, y, z], topo.pos, 0);
assert(r.ok, `3 mm drawn wall refused: ${r.reason}`);
const h = DRAW_MIN_LEN * 0.6;
const no = drawnWall([-h / 2, y, z], [h / 2, y, z], topo.pos, 0);
assert(!no.ok && /too short/.test(no.reason), `a ${h} mm line should refuse as too short`);
});
Deno.test('draw: a squat wall on the plate under a low overhang (0.8 mm of wall)', () => {
const topo = topoOf(block(-10, 10, -5, 5, 1.0, 4)); // underside 1 mm over the plate
const r = drawnWall([-5, 0, 1.0], [5, 0, 1.0], topo.pos, 0);
assert(r.ok && r.squat && !r.partAttached, `expected a squat plate wall: ${JSON.stringify({ ok: r.ok, reason: r.reason })}`);
assert(Math.abs(lowest(r.tris)) < 1e-6, 'a plate wall should stand on z 0');
assert(isClosed(r.tris), 'the squat wall is not closed');
assert(insideCount(topo, IDENTITY, OFF, r.tris) === 0, 'the squat wall pokes into the part');
});
Deno.test('draw: a squat wall on the part under a shelf 1.2 mm over it', () => {
// a hand just over a thigh: slab z 0-3, shelf z 4.2-6 above it
const topo = topoOf(block(-15, 15, -10, 10, 0, 3), block(-12, 12, -6, 6, 4.2, 6));
const r = drawnWall([-6, 0, 4.2], [6, 0, 4.2], topo.pos, 0);
assert(r.ok && r.squat && r.partAttached, `expected a squat wall on the part: ${JSON.stringify({ ok: r.ok, reason: r.reason })}`);
assert(Math.abs(lowest(r.tris) - (3 + prop.PROP.footGap)) < 1e-3, `foot should sit footGap over the slab, at ${lowest(r.tris)}`);
assert(isClosed(r.tris), 'the squat wall is not closed');
assert(insideCount(topo, IDENTITY, OFF, r.tris) === 0, 'the squat wall pokes into the part');
});
Deno.test('draw: under minHeightSquat of room still refuses', () => {
const topo = topoOf(block(-10, 10, -5, 5, 0.6, 4)); // 0.6 - gap 0.2 = 0.4 mm of wall
const r = drawnWall([-5, 0, 0.6], [5, 0, 0.6], topo.pos, 0);
assert(!r.ok, 'a wall under minHeightSquat should not build');
});
Deno.test('draw: a line from low to tall mixes a squat stem with the full wall, and is closed', () => {
// a ramp underside from 1.0 mm to ~9 mm over the plate along X
const pos = new Float32Array(block(-10, 10, -5, 5, 0, 4));
for (let i = 0; i < pos.length; i += 3) pos[i + 2] += 1.0 + (pos[i] + 10) * 0.4; // shear: z += 1 + 0.4 (x + 10)
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
const zAt = (x) => 1.0 + (x + 10) * 0.4;
const r = drawnWall([-9, 0, zAt(-9)], [8, 0, zAt(8)], topo.pos, 0);
assert(r.ok && r.squat, `expected a mixed squat/full wall: ${JSON.stringify({ ok: r.ok, reason: r.reason })}`);
assert(r.height > prop.PROP.minHeight, 'the tall end should be a full-height wall');
assert(insideCount(topo, IDENTITY, OFF, r.tris) === 0, 'the mixed wall pokes into the part');
assert(isClosed(r.tris), 'the mixed wall is not closed (each piece is its own closed solid)');
});
Deno.test('draw: no squat wall through the part -- too little room on a low slab refuses', () => {
// slab z 0-1, shelf from 1.6: 0.4 mm of room on the slab (under minHeightSquat), and a
// plate wall would have to pass through the slab to reach the shelf
const topo = topoOf(block(-15, 15, -10, 10, 0, 1), block(-12, 12, -6, 6, 1.6, 4));
const r = drawnWall([-6, 0, 1.6], [6, 0, 1.6], topo.pos, 0);
assert(!r.ok, `built a wall where none fits (squat ${r.squat}, partAttached ${r.partAttached})`);
});
Deno.test('draw: a squat plate wall takes tines from the brim up', () => {
// the 45deg block lifted 0.4 mm off the plate, drawn where the underside is ~1.4 mm
// up: a tine needs a sloped face to reach into (a level
// underside takes none), and under the edge is open air, so the brim fits both sides
const pos = Float32Array.from(tiltedBlockTopo(-20, 20, -30, 30, -6, 6, 45).pos);
for (let i = 2; i < pos.length; i += 3) pos[i] += 0.4;
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
let at = null;
for (let y = -30; y <= 30 && !at; y += 0.05) {
const zs = prop.surfaceZsAt(topo.pos, 0, y);
if (zs.length >= 2 && Math.abs(Math.min(...zs) - 1.4) < 0.03) at = { y, z: Math.min(...zs) };
}
assert(at, 'test setup: no underside ~1.4 mm up on the lifted block');
const r = drawnWall([-6, at.y, at.z], [6, at.y, at.z], topo.pos, 0,
{ tines: true, topo, rot: IDENTITY, offset: OFF, tineDensity: 1 });
assert(r.ok && r.squat, `expected a squat plate wall: ${r.reason}`);
assert(r.tines > 0, 'a squat drawn wall with Tines on emitted no tines');
});
// ---- review fixes: a squat wall never fills the part, its brim fits, its pieces overlap
Deno.test('draw: a line on an UPWARD slope builds no wall inside the part', () => {
// the cone/pyramid case: at 0.6 mm headroom the slope itself read as the wall's floor
const pos = Float32Array.from(tiltedBlockTopo(-20, 20, -30, 30, -6, 6, 45).pos);
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
for (let y = -20; y <= 20; y += 5) {
const top = Math.max(...prop.surfaceZsAt(pos, 0, y));
if (!isFinite(top)) continue;
const r = drawnWall([-4, y, top], [4, y, top], pos, 0);
// squat or full (local issue 033: a full wall from a top-face line filled the part)
assert(!r.ok || insideCount(topo, IDENTITY, OFF, r.tris) === 0,
`a line on the top face at y ${y} built a${r.squat ? ' squat' : ''} wall inside the part`);
}
});
Deno.test('draw: a squat brim that would run into the part beside the line refuses', () => {
// a body resting on the plate at x 0..10 and a cantilever over x<0 at z 1.2: a line at
// x -2 is clear straight down, but its 2.5 mm brim reaches into the body
const topo = topoOf(block(0, 10, -8, 8, 0, 6), block(-10, 0, -8, 8, 1.2, 6));
const r = drawnWall([-2, -5, 1.2], [-2, 5, 1.2], topo.pos, 0);
assert(!r.ok || insideCount(topo, IDENTITY, OFF, r.tris) === 0, 'the squat brim pokes into the body beside it');
});
Deno.test('draw: the squat stem and the full wall overlap, never meet flush', () => {
const pos = new Float32Array(block(-10, 10, -5, 5, 0, 4));
for (let i = 0; i < pos.length; i += 3) pos[i + 2] += 1.0 + (pos[i] + 10) * 0.4;
const zAt = (x) => 1.0 + (x + 10) * 0.4;
const r = drawnWall([-9, 0, zAt(-9)], [8, 0, zAt(8)], pos, 0);
assert(r.ok && r.squat, `expected a mixed wall: ${r.reason}`);
// the brim (z <= squatBrimH, wide) must reach past where the flanged wall starts
const P = prop.PROP;
const brimX = r.tris.filter((v) => v[2] <= P.squatBrimH + 1e-6 && Math.abs(v[1]) > P.th).map((v) => v[0]);
const tallX = r.tris.filter((v) => v[2] > P.minHeight).map((v) => v[0]);
assert(Math.max(...brimX) > Math.min(...tallX) + 0.5, `brim ends at x ${Math.max(...brimX)}, full wall starts at ${Math.min(...tallX)}: flush, not overlapping`);
});
Deno.test('draw: a squat brim stays out of an underside that dips beside the line (torus)', () => {
// the review's case: the brim's mid-height was clear, its top corner 0.06 mm inside the torus
const p = loadModel('torus').pos;
let mz = Infinity; for (let i = 2; i < p.length; i += 3) mz = Math.min(mz, p[i]);
const pos = new Float32Array(p); for (let i = 2; i < pos.length; i += 3) pos[i] -= mz;
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
const r = drawnWall([-15.29, 17.49, 2.31], [-14.72, 20.77, 2.31], pos, 0);
assert(!r.ok || insideCount(topo, IDENTITY, OFF, r.tris.filter((v) => v[2] > 0.05)) === 0,
'the squat brim sits inside the torus');
});
// ---- local issue 033: full-height drawn walls stay out of the part
Deno.test('draw: a line on a sphere\'s top builds no wall inside it', () => {
// the part path read the sphere's own underside as the floor under a top-face line
// and stood a wall inside it, floor to top (296-384 vertices in)
const p = loadModel('sphere').pos;
let mz = Infinity; for (let i = 2; i < p.length; i += 3) mz = Math.min(mz, p[i]);
const pos = Float32Array.from(p); for (let i = 2; i < pos.length; i += 3) pos[i] -= mz;
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
for (const x of [5, 8, 10, 14]) {
const top = (y) => Math.max(...prop.surfaceZsAt(pos, x, y));
const r = drawnWall([x, -4, top(-4)], [x, 4, top(4)], pos, 0);
assert(!r.ok || (!r.partAttached && insideCount(topo, IDENTITY, OFF, r.tris) === 0),
`a line on the sphere's top at x ${x} built a wall inside it (part-attached ${!!r.partAttached})`);
}
});
Deno.test('draw: a full wall whose flange would cut into the body beside it refuses', () => {
// a body resting on the plate at x 0..10, a cantilever over x<0 at z 1.8: lines 0.5 and
// 1 mm out built full walls whose flange reached 0.6-1.1 mm into the body
const topo = topoOf(block(0, 10, -5, 5, 0, 6), block(-10, 0.01, -5, 5, 1.8, 6));
for (const x of [-0.5, -1]) {
const r = drawnWall([x, -4, 1.8], [x, 4, 1.8], topo.pos, 0);
assert(!r.ok && /too close/.test(r.reason), `line at x ${x}: expected 'too close', got ${r.ok ? 'a wall' : r.reason}`);
}
// 2 mm out the flange clears it: still builds (bed-plane vertices are on, not in, the body's
// bottom, and parity reads some of them inside -- so measure the flange's reach instead)
const r = drawnWall([-2, -4, 1.8], [-2, 4, 1.8], topo.pos, 0);
assert(r.ok && !r.squat, `line at x -2: ${r.reason ?? 'squat'}`);
assert(Math.max(...r.tris.map((v) => v[0])) < 0, 'the wall 2 mm out reaches the body');
});
Deno.test('draw: a thin pin inside the flange footprint, between stations, refuses', () => {
// a 0.3 mm pin standing on the plate under a high slab, between two stations and two
// of the old across-samples: the flange cut it with no wall vertex inside (review)
const topo = topoOf(block(-12, 12, -10, 10, 20, 24), block(0.4, 0.7, 1.6, 1.9, 0, 5));
const r = drawnWall([-10, 0, 20], [10, 0, 20], topo.pos, 0);
assert(!r.ok && /too close/.test(r.reason), `expected 'too close', got ${r.ok ? 'a wall' : r.reason}`);
});