Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions native/nvidia-kernel/adapter.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,22 @@ function wrap(nk) {
const dir = [b[0] - a[0], b[1] - a[1], 0];
const angle = +(p.angle ?? 360);
const profile = sk.geoms.filter((g) => g !== axis);
// SMLib sweeps a full turn from the open profile, but a part turn only
// from the closed one (axis edge included); without it, status 1001
const part = Math.abs(angle) < 360;
const h = nk.revolve((part ? sk.geoms : profile).map((g) => seg3(g)), a[0], a[1], 0, dir[0], dir[1], 0, angle);
const len = Math.hypot(dir[0], dir[1]), u = [dir[0] / len, dir[1] / len];
// which side of the axis the profile lies on, in the sketch plane
const side = Math.sign(profile.flatMap((g) => g.ps).reduce((t, [x, y]) => t + (x - a[0]) * u[1] - (y - a[1]) * u[0], 0)) || 1;
// SMLib sweeps a full turn from the open profile, but a part turn only
// from the closed one (axis edge included); without it, status 1001. A
// ring's cross-section, clear of the axis, is closed on its own: the axis
// line drawn beside it would only be a stray edge.
const part = Math.abs(angle) < 360;
const key = ([x, y]) => `${x.toFixed(6)},${y.toFixed(6)}`;
const ends = new Set(profile.flatMap((g) => [key(g.ps[0]), key(g.ps[g.ps.length - 1])]));
const closed = part && ends.has(key(a)) && ends.has(key(b));
// SMLib turns about the axis line as it is drawn; the Rust kernel turns
// the same way whichever way the axis edge runs (the profile's side of it
// decides), so a part turn of a clockwise profile lands where it does there
const turn = [-side * u[0], -side * u[1]];
const h = nk.revolve((closed ? sk.geoms : profile).map((g) => seg3(g)), a[0], a[1], 0, turn[0], turn[1], 0, angle);
const faceNames = faceInfo(h).map(({ p: q, n }) => {
// a part revolve has two flat caps, each a plane through the axis: the
// start one in the sketch plane on the profile's side, the end one swung
Expand All @@ -308,9 +317,12 @@ function wrap(nk) {
const start = Math.abs(q[2]) < 1e-6 && Math.sign((q[0] - a[0]) * u[1] - (q[1] - a[1]) * u[0]) === side;
return `${p.id}:L${sk.geoms[0].id}_${start ? "START" : "END"}`;
}
// a point on a wall, swung back into the sketch plane, sits on its curve
const r = Math.hypot(q[0] - a[0], q[2]);
const g = nearestGeom(profile, [a[0] + Math.sign(q[0] - a[0] || 1) * r, q[1]]);
// a point on a wall, swung back into the sketch plane on the profile's
// side (a face's sample point can be anywhere round the turn, and the
// mirror image of an outer wall lies nearer an inner one), sits on its curve
const v = [q[0] - a[0], q[1] - a[1], q[2]], t = v[0] * u[0] + v[1] * u[1];
const r = Math.hypot(v[0] - t * u[0], v[1] - t * u[1], v[2]);
const g = nearestGeom(profile, [a[0] + t * u[0] + side * r * u[1], a[1] + t * u[1] - side * r * u[0]]);
return g ? `${p.profile}:${wallOf(g, "RV")}` : `${p.id}:${p.profile}:CAP_END`;
});
return own(h, faceNames);
Expand Down
59 changes: 59 additions & 0 deletions test/design.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,11 @@ console.log("\nPhase 2: press-pull\n");
check("pull the bottom down: height + 3 and moved down 3 (the top stays put)", rb.next === `return translate([0, 0, -3], cube([40, 30, 13]));`, rb.next);
const rc = F.pressPullText(`return cube([40, 30, 10], { center: true });`, { ...ctx, siteSpan: { start: 7, end: 43 }, centered: true, target: findResizeTarget(`return cube([40, 30, 10], { center: true });`, { kind: "cube", start: 7, end: 43 }, 2) }, 4);
check("a centred box grows and moves half way", rc.next === `return translate([0, 0, 2], cube([40, 30, 14], { center: true }));`, rc.next);
// the size is a const above the call and gains a digit: the translate still wraps the call
const named = `const w = 9;\nreturn union(cube([w, 10, 5]), sphere({ r: 2 }));`;
const nsite = { kind: "cube", start: named.indexOf("cube"), end: named.indexOf(")", named.indexOf("cube")) + 1 };
const rn = F.pressPullText(named, { body: nsite, outline: null, target: findResizeTarget(named, nsite, 0), sign: -1, axis: 0, siteSpan: nsite }, 2);
check("a named size above the call: the const changes and the translate wraps the call", rn.next === `const w = 11;\nreturn union(translate([-2, 0, 0], cube([w, 10, 5])), sphere({ r: 2 }));`, rn.next);
const pocket = rectFace(10, 10, 10, 30, 20);
const rp = F.pressPullText(`return union(cube([40, 30, 10]), sphere({ r: 1 }));`, { body: { start: 7, end: 50 }, outline: pocket, target: null, toPlane: planeOf(pocket) }, 3);
check("a face with no size behind it: joined as its outline", rp.how === "join" && /^return union\(union\(cube/.test(rp.next) === false && /linearExtrude\(\{ h: 3, on:/.test(rp.next), rp.next);
Expand Down Expand Up @@ -548,6 +553,16 @@ console.log("\nPhase 2: rotate and scale gizmo writes\n");
s = F.scaleText(s.next, { start: s.start, end: s.end }, 2, [20, 15, 5]);
check("…and folds by multiplying", s.next === `return scale(3, { about: [20, 15, 5] }, cube([40, 30, 10]));`, s.next);
await onEveryKernel("scaled ×3 about its centre", s.next, 12000 * 27);
// as the gizmo hands it over: shapeSpan of the cube's own call, which grows
// out through the rotate / scale already around it
const viaShell = (t, fn) => { const c = t.indexOf("cube("); return fn(t, F.shapeSpan(t, { start: c, end: t.indexOf(")", c) + 1 })); };
const once = `return union(rotate([0, 0, 30], { about: [5, 5, 5] }, cube([10, 10, 10])), sphere({ r: 2 }));`;
const g1 = viaShell(once, (t, sp) => F.rotateText(t, sp, 2, 30, [5, 5, 5]));
check("a second gizmo turn folds into the rotate around the shape", g1.how === "fold" && g1.next === `return union(rotate([0, 0, 60], { about: [5, 5, 5] }, cube([10, 10, 10])), sphere({ r: 2 }));`, g1.next);
const g2 = viaShell(once, (t, sp) => F.rotateText(t, sp, 0, 10, [5, 5, 5]));
check("…a turn about another axis wraps outside it (it turns after)", g2.how === "wrap" && g2.next.startsWith("return union(rotate([10, 0, 0], { about: [5, 5, 5] }, rotate([0, 0, 30]"), g2.next);
const g3 = viaShell(`return scale(2, { about: [5, 5, 5] }, cube([10, 10, 10]));`, (t, sp) => F.scaleText(t, sp, 1.5, [5, 5, 5]));
check("a second gizmo scale folds by multiplying", g3.how === "fold" && g3.next === `return scale(3, { about: [5, 5, 5] }, cube([10, 10, 10]));`, g3.next);
}

console.log("\nPhase 2: a face's outline, and the failed step\n");
Expand Down Expand Up @@ -597,6 +612,28 @@ console.log("\nPhase 2: wiring\n");
const gizmo = readFileSync(new URL("../viewer/design-gizmo.js", import.meta.url), "utf8");
const build_ = readFileSync(new URL("../build-site.mjs", import.meta.url), "utf8");
const D = await import("../viewer/design/dialogs.js");
{
// an in-thread build: edges come without segments, by the faces they join.
// A click near the top's +X side picks P|PX, near +Y picks P|PY (the top
// face borders all four, so only the face across can tell them apart)
const quad = (name, q) => ({ userData: { faceName: name }, geometry: { index: { array: [0, 1, 2, 0, 2, 3] }, attributes: { position: { array: q.flat() } } } });
const meshes = [
quad("PZ", [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]), quad("PX", [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]),
quad("NX", [[0, 0, 0], [0, 1, 0], [0, 1, 1], [0, 0, 1]]), quad("PY", [[0, 1, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1]]),
quad("NY", [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]])];
const edges = ["NX", "NY", "PX", "PY"].map((f) => ({ name: `${f}|PZ[0]`, faces: [f, "PZ"].sort() }));
const picks = [[0.95, 0.5, 1], [0.5, 0.95, 1], [0.05, 0.4, 1], [0.6, 0.05, 1]].map((p) => D.nearestEdge(edges, p, meshes)?.name);
check("an edge pick without segments takes the edge nearest the click", picks.join() === "PX|PZ[0],PY|PZ[0],NX|PZ[0],NY|PZ[0]", picks.join());
}
{
// a face or body whose feature has no call in the code: a message, not a TypeError
const env = { bodyOf: () => null };
const sq = { fid: "X1", outline: { loops: [[[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0]]], normal: [0, 0, 1], point: [5, 5, 0] } };
let rv, cb;
try { rv = D._test.SPECS.revolve.write("return cube(1);", { face: sq, axis: "0", angle: 360, op: "join" }, env); } catch (e) { rv = { threw: String(e) }; }
try { cb = D._test.SPECS.combine.write("return cube(1);", { fid: "X1", bodies: [{ fid: "X2" }], op: "union" }, env); } catch (e) { cb = { threw: String(e) }; }
check("revolve / combine on a shape with no call: an error message, no throw", !!rv?.error && !rv.threw && !!cb?.error && !cb.threw, JSON.stringify({ rv, cb }));
}
check("ten feature dialogs", ["extrude", "revolve", "hole", "fillet", "chamfer", "shell", "mirror", "linear", "circular", "combine"].every((d) => D.DIALOGS.includes(d)));
check("the dialogs load lazily (dynamic import on first use)", /await import\("\.\/design\/dialogs\.js"\)/.test(shell) && !/^import .*design\/dialogs/m.test(shell));
check("…and press-pull too", /await import\("\.\/design\/presspull\.js"\)/.test(shell));
Expand All @@ -605,6 +642,16 @@ console.log("\nPhase 2: wiring\n");
check("fix (a): in Design mode a pick does not open the code card", /const design = !!designUI\?\.isActive\(\);\s*if \(!design\) \{\s*toggleCard\(panel, openBtn, true\);/.test(html));
check("fix (b): the hint talks dots, not squares and balls", !/drag a <b>square<\/b>|corner ball<\/b>/.test(html) && /double-click<\/b> a dot to type the numbers/.test(html));
check("fix (c): no plane squares or centre block on the move gizmo", /translate: \["XY", "YZ", "XZ", "XYZ"\]/.test(gizmo));
check("Ask about this: re-pinning a spot keeps one pin-tracking loop", /if \(!pinTracking\) \{ pinTracking = true; requestAnimationFrame\(trackPin\); \}/.test(html)
&& /askPin\.hidden = true; pinTracking = false; return;/.test(html));
// macOS and Linux fire contextmenu on the press, before any drag: the menu
// must wait for the release there, or every right-drag pan opens it
check("right-click menu: a contextmenu during the press waits for the release, and opens only if the pointer stayed put",
/if \(rightHeld\) \{ menuOnRelease = true; return; \}/.test(html)
&& /const still = rightDown && Math\.hypot\(e\.clientX - rightDown\[0\], e\.clientY - rightDown\[1\]\) <= 6;[\s\S]{0,80}if \(still\) openShapeMenuAt\(e\.clientX, e\.clientY\);/.test(html));
const sketcherSrc = readFileSync(new URL("../viewer/design/sketcher.js", import.meta.url), "utf8");
check("sketch Finish refuses when the code changed while it was open (it would undo that)", /async function finish\(\) \{[\s\S]{0,400}if \(host\.text\(\) !== S\.orig\) \{ setMsg\(/.test(sketcherSrc));
check("a feature dialog closes an open sketch first", /if \(dialog\) await dialog\.cancel\(\);\s*\/\/[^\n]*\n\s*if \(sketcher\?\.active \|\| sketcher\?\.choosing\) sketcher\.cancel\(\);/.test(shell));
check("rotate and scale are gizmo modes", /rotate: "rotate", scale: "scale"/.test(shell));
check("a failed build reaches Design mode with the kernel's reason", /designUI\?\.buildResult\?\.\(ok, ok \? null : lastBuildFailure\)/.test(html)
&& /reason: String\(e\?\.message \|\| e\), stage: attempt/.test(html));
Expand Down Expand Up @@ -699,6 +746,18 @@ console.log("\nPhase 3: the sketcher's model\n");
const again = SM.readSketch(named, codemap.parseCode(named).calls.find((c) => c.name === "sketch"));
check("…and reads back with its name and the param's value", again.dims[0].name === "width" && again.dims[0].value === 42);
check("a name that is taken is refused", !!SM.promoteDim(named, again, 0, "width").error);
{
// an existing sketch written with names: its plane, a radius and a point
// coordinate go back as written; an edited radius goes back as its number
const src2 = `const rr = 4, cx = 2;\nconst P = { origin: [0, 0, 5], normal: [0, 0, 1] };\nconst s1 = sketch({ on: P, points: [[cx, 0]], curves: [["circle", 0, rr]] });\nreturn linearExtrude({ h: 3 }, s1);`;
const sk2 = SM.readSketch(src2, codemap.parseCode(src2).calls.find((c) => c.name === "sketch"));
const out2 = SM.sketchCode(sk2);
check("editing a sketch keeps its named plane, radius and point", /on: P,/.test(out2) && /\["circle", 0, rr\]/.test(out2) && /\[\[cx, 0\]\]/.test(out2), out2);
sk2.curves[0].r = 6;
sk2.points[0][0] = 3;
const out3 = SM.sketchCode(sk2);
check("…an edited radius or point is written as its new number", /\["circle", 0, 6\]/.test(out3) && /\[\[3, 0\]\]/.test(out3) && /on: P,/.test(out3), out3);
}
// trim and offset
const t = SM.emptySketch();
SM.addLine(t, [0, 0], [20, 0], { auto: false });
Expand Down
52 changes: 52 additions & 0 deletions test/nvidia.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,58 @@ const near3 = (a, b, tol = 0.02) => a.every((v, k) => Math.abs(v - b[k]) <= tol)
check(`jump-rope-handles volume ${v.toFixed(0)} ~ 150027`, Math.abs(v - 150027.5) / 150027.5 < 0.005);
}

// a revolve's faces carry the names the Rust kernel puts on the SAME faces
// (not just the same set of names): each named face's r / z span about the
// spin axis and, for a part turn, which way round it lies. A face's sample
// point on the far side of the axis once named a ring's outer wall after its
// inner one; a part turn of a clockwise profile or of a ring once swept the
// other way round.
{
const { PartHistory } = await import("brep-io-kernel");
const { runEngine } = await import("../src/engine.js");
const { loadRustKernel, loadManifoldNode } = await import("../src/engine-node.js");
const kernels = [["rust", await loadRustKernel()], ["nvidia", loadNvidiaKernel()]];
const facesOn = async (k, src) => {
const ph = new PartHistory();
await dsl.compile(shape(src), ph);
const out = await runEngine(await ph.toJSON(), { rust: k, manifold: loadManifoldNode });
const by = new Map();
for (const s of out.solids) for (const f of s.faces) {
const P = f.positions, b = [Infinity, -Infinity, Infinity, -Infinity];
let X = 0, Y = 0;
for (let i = 0; i < P.length; i += 3) {
const r = Math.hypot(P[i], P[i + 1]);
b[0] = Math.min(b[0], r); b[1] = Math.max(b[1], r); b[2] = Math.min(b[2], P[i + 2]); b[3] = Math.max(b[3], P[i + 2]);
X += P[i]; Y += P[i + 1];
}
by.set(f.name, { b, a: Math.atan2(Y, X) * 180 / PI });
}
return by;
};
const REVOLVES = [
["revolved ring cup", 360, "[[4, 0], [10, 0], [10, 20], [8, 20], [8, 2], [4, 2]]"],
["part-turned ring", 120, "[[4, 0], [10, 0], [10, 20], [4, 20]]"],
["part-turned ring, clockwise", 120, "[[4, 20], [10, 20], [10, 0], [4, 0]]"],
["part-turned block, clockwise", 120, "[[0, 20], [10, 20], [10, 0], [0, 0]]"],
["part-turned block", 270, "[[0, 0], [10, 0], [10, 20], [0, 20]]"],
];
for (const [label, angle, pts] of REVOLVES) {
const src = `return revolve({ angle: ${angle} }, polygon(${pts}));`;
let rust, nv;
try { [rust, nv] = [await facesOn(kernels[0][1], src), await facesOn(kernels[1][1], src)]; }
catch (e) { check(`${label}: builds on both kernels`, false, String(e.message).slice(0, 160)); continue; }
const bad = [];
for (const [name, r] of rust) {
const n = nv.get(name);
if (!n) { bad.push(`${name} missing`); continue; }
if (r.b.some((v, i) => Math.abs(v - n.b[i]) > 0.3)) bad.push(`${name} spans ${n.b.map((v) => v.toFixed(1))} not ${r.b.map((v) => v.toFixed(1))}`);
const da = Math.abs(((r.a - n.a + 540) % 360) - 180);
if (angle < 360 && da > 15) bad.push(`${name} at ${n.a.toFixed(0)} deg not ${r.a.toFixed(0)}`);
}
check(`${label}: every face named as on the Rust kernel`, !bad.length && nv.size === rust.size, bad.join("; ") || `${nv.size} vs ${rust.size} faces`);
}
}

// a model NVIDIA's kernel refuses (a pyramid, for now) still builds, on the
// Rust kernel, and says why
{
Expand Down
2 changes: 2 additions & 0 deletions viewer/design-shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,8 @@ export function initDesignShell(host) {
async function openFeature(id) {
dialogsMod ??= await import("./design/dialogs.js");
if (dialog) await dialog.cancel();
// one tool at a time: a dialog over an open sketch could not take its picks
if (sketcher?.active || sketcher?.choosing) sketcher.cancel();
const sel = { fid: selectedFid, face: null };
if (face?.mesh) {
const fp = dialogsMod.facePick({ object: face.mesh, point: face.point ?? face.mesh.position }, THREE);
Expand Down
Loading
Loading