engine: footGap / footTeeth -- a wall on the part may stop short of it or touch down on teeth (off by default)

Local issue 009: every wall on the slenderness coupon left a scar line where its
welded bottom tip met the part. Two knobs, both off, so nothing builds differently
yet; prototype/foot/ is the coupon that picks the default by print.
This commit is contained in:
matthewtrahan committed 2026-09-30 10:15:41 -05:00
1 parent 2d33435644
commit a2e5a80ec8
4 files changed
+179 -7

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
/**
* Foot coupon, step 2: stand one part-attached wall under each ledge of gen.py's
* coupon with the engine's own buildPartAttached, each with its ledge's
* PROP.footGap / footTeeth, and write the 3MF to print.
*
* deno run -A prototype/foot/build.js # -> out/foot-coupon.3mf + .stl
*/
const WEB = new URL('../../web/', import.meta.url).pathname;
const OUT = new URL('./out/', import.meta.url).pathname;
const { buildTopology, analyze } = await import(`${WEB}overhangs.js`);
const { PROP } = await import(`${WEB}prop.js`);
const { buildPartAttached } = await import(`${WEB}prop/attached.js`);
const { writeThreeMF } = await import(`${WEB}threemf.js`);
const { writeBinarySTL } = await import(`${WEB}stl.js`);
function readSTL(b) {
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength), n = dv.getUint32(80, true);
const pos = new Float32Array(n * 9);
for (let f = 0; f < n; f++) for (let i = 0; i < 9; i++) pos[f * 9 + i] = dv.getFloat32(84 + f * 50 + 12 + i * 4, true);
return pos;
}
const pos = readSTL(Deno.readFileSync(`${OUT}coupon_part.stl`));
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
const rot = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const off = analyze(topo, 45, rot).offset;
const partTris = new Float64Array(pos.length);
for (let i = 0; i < pos.length; i += 3) {
partTris[i] = pos[i] + off.x; partTris[i + 1] = pos[i + 1] + off.y; partTris[i + 2] = pos[i + 2] + off.z;
}
const SLAB_T = 3.0;
const out = [];
console.log('ledge foot span height lowest above slab tris');
for (const l of JSON.parse(Deno.readTextFileSync(`${OUT}ledges.json`))) {
PROP.footGap = l.footGap; PROP.footTeeth = l.footTeeth;
const line = [];
const n = Math.max(PROP.minStations, Math.round((l.x1 - l.x0) / PROP.stationStep) + 1);
for (let k = 0; k < n; k++) line.push([l.x0 + (l.x1 - l.x0) * k / (n - 1) + off.x, l.y + off.y, l.z + off.z]);
const before = out.length;
const pa = buildPartAttached(line, partTris, topo, rot, off, out);
if (!pa.ok) { console.log(`${l.id} REFUSED ${pa.floored ?? 'declined'}`); continue; }
let lo = Infinity;
for (let i = before; i < out.length; i++) lo = Math.min(lo, out[i][2]);
const q = pa.prop;
console.log(`${l.id} ${l.name.padEnd(28)} ${q.span.toFixed(1).padStart(4)} ${q.height.toFixed(1).padStart(5)} ${(lo - off.z - SLAB_T).toFixed(2).padStart(6)} ${(out.length - before) / 3}`);
}
PROP.footGap = 0; PROP.footTeeth = 0;
const part = [];
for (let i = 0; i < partTris.length; i += 3) part.push([partTris[i], partTris[i + 1], partTris[i + 2]]);
Deno.writeFileSync(`${OUT}foot-coupon.3mf`, new Uint8Array(await writeThreeMF(part, out, 'Foot coupon').arrayBuffer()));
Deno.writeFileSync(`${OUT}foot-coupon.stl`, new Uint8Array(await writeBinarySTL([...part, ...out], 'Foot coupon').arrayBuffer()));
Deno.writeFileSync(`${OUT}foot-fins.stl`, new Uint8Array(await writeBinarySTL(out, 'Foot coupon fins').arrayBuffer()));
console.log(`wrote ${OUT}foot-coupon.3mf (+ .stl, fins-only .stl)`);
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Foot coupon (local issue 009): how should a wall standing ON THE PART meet it?
ONE solid piece, like prototype/slender/: a slab, a spine, and six 8 mm ledges
15 mm up, three per side. build.js stands one part-attached wall under each ledge
with a different foot (VARIANTS). Ledge k carries k dots on top so you can tell
them apart. Each wall sits 1 mm in from the ledge's free edge so the free-edge
curl from the slenderness print doesn't muddy the scar.
python3 prototype/foot/gen.py && deno run -A prototype/foot/build.js
"""
import json, numpy as np, trimesh
from trimesh.creation import box
from pathlib import Path
OUT = Path(__file__).parent / 'out'
OUT.mkdir(exist_ok=True)
VARIANTS = [
{'name': 'welded (today)', 'footGap': 0.0, 'footTeeth': 0},
{'name': 'gap 0.2', 'footGap': 0.2, 'footTeeth': 0},
{'name': 'gap 0.3', 'footGap': 0.3, 'footTeeth': 0},
{'name': 'teeth every 3 mm', 'footGap': 0.0, 'footTeeth': 3},
{'name': 'teeth every 5 mm', 'footGap': 0.0, 'footTeeth': 5},
{'name': 'teeth every 3 mm + gap 0.2', 'footGap': 0.2, 'footTeeth': 3},
]
SLAB_T, SPINE_T, LEDGE_T, DEPTH, H, SPAN, MARGIN, GAP_X, EDGE = 3.0, 4.0, 2.0, 8.0, 15.0, 12.0, 1.0, 6.0, 1.0
def bx(x0, x1, y0, y1, z0, z1):
b = box([x1 - x0, y1 - y0, z1 - z0]); b.apply_translation([(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2]); return b
parts, ledges = [], []
w = SPAN + 2 * MARGIN
for k, v in enumerate(VARIANTS):
side = +1 if k < 3 else -1
x = 5.0 + (k % 3) * (w + GAP_X)
z = SLAB_T + H
y0, y1 = (SPINE_T / 2, SPINE_T / 2 + DEPTH) if side > 0 else (-SPINE_T / 2 - DEPTH, -SPINE_T / 2)
parts.append(bx(x, x + w, y0, y1, z, z + LEDGE_T))
for d in range(k + 1): # k+1 dots: ledge id
cx = x + 2 + d * 2.0
parts.append(bx(cx - 0.6, cx + 0.6, (y0 + y1) / 2 - 0.6, (y0 + y1) / 2 + 0.6, z + LEDGE_T - 0.1, z + LEDGE_T + 1.0))
ywall = y1 - EDGE if side > 0 else y0 + EDGE
ledges.append({**v, 'id': k + 1, 'z': z, 'x0': x + MARGIN, 'x1': x + MARGIN + SPAN, 'y': ywall})
L = 5.0 + 3 * (w + GAP_X) - GAP_X + 5.0
top = SLAB_T + H + LEDGE_T + 3
parts.append(bx(0, L, -SPINE_T / 2 - DEPTH - 4, SPINE_T / 2 + DEPTH + 4, 0, SLAB_T)) # slab
parts.append(bx(0, L, -SPINE_T / 2, SPINE_T / 2, SLAB_T - 0.5, top)) # spine
m = trimesh.boolean.union(parts, engine='manifold')
assert m.is_watertight and len(m.split(only_watertight=False)) == 1
m.export(OUT / 'coupon_part.stl')
json.dump(ledges, open(OUT / 'ledges.json', 'w'), indent=1)
print(f'coupon {np.round(m.extents, 1)} mm, {len(ledges)} ledges')
for l in ledges: print(f" ledge {l['id']} ({l['id']} dots): {l['name']}")
+12
View File
@@ -131,6 +131,18 @@ export const PROP = {
// wall under it is its only support: minSpan's "not worth the plate space"
// would drop the whole feature. Such a wall may be this short.
minSpanTube: 3.0,
// HOW A WALL ON THE PART MEETS THE PART (local issue 009: every wall on the
// slenderness coupon left a scar line, the bottom tip welds on purpose). Both
// off = the welded 0.6 mm tip. footGap: the bottom stops this far above the
// floor, a slicer's "bottom Z distance" -- the first layer sags onto the part
// instead of fusing, and the wall keeps its full th there so that layer is two
// lines wide. footTeeth: pitch (mm) of little teeth that alone reach down, the
// wall bridging between them toothH up -- a row of dots instead of a line.
// Coupon: prototype/foot/. Defaults stay welded until it prints.
footGap: 0,
footTeeth: 0,
toothLen: 1.0,
toothH: 1.0,
tubeConvexFrac: 0.7,
tubeTwoSidedFrac: 0.25, // least share of the off-line area on either side
// mm an overhang may bridge unsupported: the wall-to-wall spacing across a
+61 -7
View File
@@ -105,12 +105,21 @@ export function sweep(line, zBed, out, minH = PROP.minHeight) {
* under the overhang (the part bridges the `gap`), and the bottom tip is the only
* thing that welds to the part below, kept as narrow as the top so it leaves the
* smallest possible witness mark and snaps off cleanly. There is no `gap` at the
* bottom on purpose: an air gap at both ends would make the support a floating
* bottom by default: an air gap at both ends would make the support a floating
* island the slicer cannot anchor, so the support grows UP from the floor (the
* only printable topology) and the single scar it can leave is on an internal
* surface you could not have oriented away.
*
* That scar is a full-length line (local issue 009), so PROP.footGap / footTeeth
* can lift the bottom instead: a slicer-style bottom gap the first layer sags
* across, and/or a row of teeth that alone reach down (see config.js). A lifted
* bottom keeps the full wall thickness -- the first layer over air is then two
* lines wide, not one 0.6 mm tip line that could peel.
*/
export function sweepBetween(topLine, botLine, out) {
const teeth = PROP.footTeeth > 0;
const lift = PROP.footGap + (teeth ? PROP.toothH : 0);
const welded = lift <= 0;
const wall = [], st = [];
for (let i = 0; i < topLine.length; i++) {
const p = topLine[i];
@@ -123,24 +132,69 @@ export function sweepBetween(topLine, botLine, out) {
const sx = ry, sy = -rx; // horizontal, across the wall
const top = p[2] - PROP.gap;
const bot = botLine[i][2];
const bot = botLine[i][2] + lift;
const h = top - bot;
if (h < PROP.minHeight) return false;
const taper = Math.min(PROP.tipH, h / 2); // tapers meet in the middle if short
const zBotTip = bot + taper;
const taper = Math.min(PROP.tipH, welded ? h / 2 : h); // tapers meet in the middle if short
const zBotTip = welded ? bot + taper : bot;
const zTopTip = top - taper;
const P = (o, z) => [p[0] + sx * o, p[1] + sy * o, z];
// one closed 8-gon section: tip-wide at both ends, th-wide through the middle
wall.push([
// one closed section: th-wide through the middle, tip-wide at the top and,
// when welded, at the bottom too
wall.push(welded ? [
P(+PROP.tip / 2, bot), P(+PROP.th / 2, zBotTip),
P(+PROP.th / 2, zTopTip), P(+PROP.tip / 2, top),
P(-PROP.tip / 2, top), P(-PROP.th / 2, zTopTip),
P(-PROP.th / 2, zBotTip), P(-PROP.tip / 2, bot),
] : [
P(+PROP.th / 2, bot), P(+PROP.th / 2, zTopTip), P(+PROP.tip / 2, top),
P(-PROP.tip / 2, top), P(-PROP.th / 2, zTopTip), P(-PROP.th / 2, bot),
]);
st.push({ p, sx, sy, top, ztip: zTopTip, bot, botTip: zBotTip, taperBot: true });
st.push({ p, sx, sy, top, ztip: zTopTip, bot, botTip: zBotTip, taperBot: welded });
}
if (!cutWall(st, wall, out, PROP)) ribbon(wall, out);
if (teeth) footTeeth(st, botLine, out);
return true;
}
/**
* The teeth under a lifted part-attached wall (PROP.footTeeth): short tapered
* blocks, tip-wide at the floor (+ footGap) and th-wide where they sink into the
* wall's bottom, toothLen long, one at each end and about footTeeth apart between
* -- the wall bridges the spans. Each is its own closed solid overlapping the
* wall, the same overlap the T-wall's flange uses.
*/
function footTeeth(st, botLine, out) {
const s = [0];
for (let i = 1; i < st.length; i++) {
s.push(s[i - 1] + Math.hypot(st[i].p[0] - st[i - 1].p[0], st[i].p[1] - st[i - 1].p[1]));
}
const L = s[s.length - 1], half = PROP.toothLen / 2;
// station frame + floor z at arclength x, interpolated
const at = (x) => {
let i = 0;
while (i < st.length - 2 && s[i + 1] < x) i++;
const t = Math.min(1, Math.max(0, (x - s[i]) / Math.max(1e-9, s[i + 1] - s[i])));
const A = st[i], B = st[i + 1];
const lerp = (u, v) => u + (v - u) * t;
return {
x: lerp(A.p[0], B.p[0]), y: lerp(A.p[1], B.p[1]),
sx: A.sx, sy: A.sy,
floor: lerp(botLine[i][2], botLine[i + 1][2]), bot: lerp(A.bot, B.bot),
};
};
const n = Math.max(2, Math.round((L - 2 * half) / PROP.footTeeth) + 1);
for (let k = 0; k < n; k++) {
const c = half + (L - 2 * half) * k / (n - 1);
const secs = [];
for (const x of [c - half, c + half]) {
const q = at(Math.min(L, Math.max(0, x)));
const P = (o, z) => [q.x + q.sx * o, q.y + q.sy * o, z];
const zb = q.floor + PROP.footGap, zt = q.bot + 0.3; // 0.3 into the wall
secs.push([P(+PROP.tip / 2, zb), P(+PROP.th / 2, zt), P(-PROP.th / 2, zt), P(-PROP.tip / 2, zb)]);
}
ribbon(secs, out);
}
}