From e1bbb75c146feb83773ebb0b59aeec423fdc9d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:35:24 +0000 Subject: [PATCH 1/4] NVIDIA kernel: revolve walls named on the profile's side; part turns of rings and clockwise profiles sweep as on Rust A face's sample point can lie anywhere round a full turn. Swinging it back on its own side of the axis put it on the mirror image of the profile, where an outer wall lies nearer an inner one: a ring cup's outer wall came out named after its inner wall, and G101 went missing from a 270-degree block. The point now goes back on the profile's side, along the axis as drawn. A part turn of a ring (profile clear of the axis) passed the stray axis line in with the closed loop and SMLib refused it (1001); it now passes the loop alone. SMLib turns about the axis line as drawn, so a clockwise profile (axis edge running the other way) swept to +120 where Rust sweeps to -120; the turn direction now follows the profile's side of the axis. test/nvidia.js compares each named face's r/z span and angle against the Rust kernel's for five revolves (5 failed before, 0 now). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGrpCLT8AW3vBotHJbhRwf --- native/nvidia-kernel/adapter.cjs | 26 +++++++++++----- test/nvidia.js | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/native/nvidia-kernel/adapter.cjs b/native/nvidia-kernel/adapter.cjs index 265903a..48752bf 100644 --- a/native/nvidia-kernel/adapter.cjs +++ b/native/nvidia-kernel/adapter.cjs @@ -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 @@ -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); diff --git a/test/nvidia.js b/test/nvidia.js index e366053..eb86dce 100644 --- a/test/nvidia.js +++ b/test/nvidia.js @@ -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 { From 358a031f2d178f701e390005fbc11788b4e99b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:47:39 +0000 Subject: [PATCH 2/4] Design mode: press-pull on a named size, edge picks off-worker, gizmo turns fold, sketch edits keep names, OK builds what was typed, one Ask pin loop - press-pull (features.js pressPullText): when the size number is a const above the call (const w = 9) and gains a digit, the compensating translate went in one character early ("uniontranslate("); the span start now shifts too, as boxCornerText does. - fillet / chamfer edge pick without segments (dialogs.js nearestEdge): every edge shares the picked face, so all tied and the first one won; distance is now to the triangles of the face across. - rotate / scale gizmo (features.js): shapeSpan hands over the rotate / scale itself, so the fold check never matched and every release nested another wrapper; a same-axis, same-pivot turn folds again, any other wraps outside. - editing an existing sketch (sketch-model.js): on: P (a name) was written back as "XY" and named radii / coordinates as numbers; they now go back as written while untouched. - feature dialogs: OK / Enter right after typing committed the previous preview; OK now builds the fields' current text first. - Ask about this (index.html): each pin started another requestAnimationFrame loop; one loop now. Tests in test/design.js for each (the edge pick, press-pull, fold and sketch ones fail on the old code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGrpCLT8AW3vBotHJbhRwf --- test/design.js | 42 +++++++++++++++++++++++++++++++++++ viewer/design/dialogs.js | 27 ++++++++++++++++------ viewer/design/features.js | 23 ++++++++++++++----- viewer/design/sketch-model.js | 30 ++++++++++++++++++++----- viewer/index.html | 6 +++-- 5 files changed, 108 insertions(+), 20 deletions(-) diff --git a/test/design.js b/test/design.js index b0cd9e5..672611d 100644 --- a/test/design.js +++ b/test/design.js @@ -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); @@ -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"); @@ -597,6 +612,19 @@ 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()); + } 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)); @@ -605,6 +633,8 @@ 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 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)); 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)); @@ -699,6 +729,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 }); diff --git a/viewer/design/dialogs.js b/viewer/design/dialogs.js index a80c460..0570167 100644 --- a/viewer/design/dialogs.js +++ b/viewer/design/dialogs.js @@ -85,17 +85,25 @@ export function nearestEdge(edges, point, meshes) { const t = L ? Math.max(0, Math.min(1, (ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / L)) : 0; return Math.hypot(ap[0] - t * ab[0], ap[1] - t * ab[1], ap[2] - t * ab[2]); }; + // the picked face borders every edge; the one across it tells them apart + const picked = edges.length > 1 ? edges[0].faces?.find((f) => edges.every((e) => e.faces?.includes(f))) : null; for (const e of edges) { let d = Infinity; if (e.segments?.length) for (const [a, b] of e.segments) d = Math.min(d, segDist(point, a, b)); else { - const other = e.faces?.find((f) => !e.name.startsWith(f + "|") || true) ?? null; + // the nearest triangle side of the face across: its sides along the + // shared edge lie right where the click is + const across = (e.faces || []).filter((f) => f !== picked); for (const m of meshes) { - if (!e.faces?.includes(m.userData.faceName)) continue; - const pa = m.geometry.attributes.position.array; - for (let i = 0; i < pa.length; i += 3) d = Math.min(d, Math.hypot(pa[i] - point[0], pa[i + 1] - point[1], pa[i + 2] - point[2])); + if (!across.includes(m.userData.faceName)) continue; + const pa = m.geometry.attributes.position.array, ix = m.geometry.index?.array; + const n = ix ? ix.length : pa.length / 3; + const v = (k) => { const i = (ix ? ix[k] : k) * 3; return [pa[i], pa[i + 1], pa[i + 2]]; }; + for (let t = 0; t + 2 < n; t += 3) { + const [a, b, c] = [v(t), v(t + 1), v(t + 2)]; + d = Math.min(d, segDist(point, a, b), segDist(point, b, c), segDist(point, c, a)); + } } - void other; } if (d < bestD) { bestD = d; best = e; } } @@ -568,7 +576,7 @@ export function openDialog(id, ctx) { box.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Escape") { e.preventDefault(); cancel(); } - else if (e.key === "Enter" && okReady()) { e.preventDefault(); ok(); } + else if (e.key === "Enter" && (okReady() || last)) { e.preventDefault(); ok(); } }); const onKey = (e) => { if (e.key === "Escape" && !closed) cancel(); }; addEventListener("keydown", onKey); @@ -589,7 +597,12 @@ export function openDialog(id, ctx) { if (host.text() !== base) await host.preview(base); } async function ok() { - if (!okReady()) return; + if (closed) return; + // a field typed a moment ago has its preview still pending: build what the + // fields say now, so OK never keeps the one before + const r = spec.write(base, state, env); + if (!r.wait && !r.error && r.next !== last?.text) { clearTimeout(previewT); await runPreview(); } + if (closed || !okReady()) return; const text = last.text; close(); await host.commit(text, { built: host.text() === text }); diff --git a/viewer/design/features.js b/viewer/design/features.js index 07f63b8..49abcfa 100644 --- a/viewer/design/features.js +++ b/viewer/design/features.js @@ -177,13 +177,22 @@ export function combineText(text, target, tools, op = "union") { // ---------------------------------------------------------- rotate, scale // rotateText(text, span, axis 0|1|2, deg, about) -> { next, how, start, end } +// The span as shapeSpan hands it over is the rotate / scale itself when one is +// already around the shape: its child is what a second turn folds onto. +function childOf(text, span, name) { + const c = callFor(parseCode(text), span); + const child = c?.name === name ? c.args[c.args.length - 1] : null; + return child ? { ...span, start: child.start, end: child.end } : span; +} + // Folds into a rotate([…], { about: }, …) already around the // span when that one turns about the same single axis; otherwise wraps. const ROT_HEAD = /rotate\(\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]\s*,\s*\{\s*about\s*:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]\s*\}\s*,\s*$/; export function rotateText(text, span, axis, deg, about) { const d = round(deg, 100); if (!d) return { next: text, how: "none", start: span.start, end: span.end }; - const head = text.slice(0, span.start); + const child = childOf(text, span, "rotate"); + const head = text.slice(0, child.start); const w = ROT_HEAD.exec(head); if (w) { const angles = [+w[1], +w[2], +w[3]], at = [+w[4], +w[5], +w[6]]; @@ -193,9 +202,10 @@ export function rotateText(text, span, axis, deg, about) { angles[axis] = round(angles[axis] + d, 100); const lead = `rotate(${vec(angles, 100)}, { about: ${vec(at, 100)} }, `; const start = w.index + lead.length; - return { next: head.slice(0, w.index) + lead + text.slice(span.start), how: "fold", start, end: start + span.end - span.start }; + return { next: head.slice(0, w.index) + lead + text.slice(child.start), how: "fold", start, end: start + child.end - child.start }; } } + // anything else turns after what is already there: outside it const a = [0, 0, 0]; a[axis] = d; const r = wrapText(text, span, `rotate(${vec(a, 100)}, { about: ${vec(about, 100)} }, `); @@ -206,12 +216,13 @@ const SCALE_HEAD = /scale\(\s*(-?[\d.]+)\s*,\s*\{\s*about\s*:\s*\[\s*(-?[\d.]+)\ export function scaleText(text, span, k, about) { const f = round(k, 1000); if (!(f > 0) || Math.abs(f - 1) < 1e-9) return { next: text, how: "none", start: span.start, end: span.end }; - const head = text.slice(0, span.start); + const inner = childOf(text, span, "scale"); + const head = text.slice(0, inner.start); const w = SCALE_HEAD.exec(head); if (w && [+w[2], +w[3], +w[4]].every((v, i) => Math.abs(v - round(about[i], 100)) < 1e-6)) { const lead = `scale(${round(+w[1] * f, 1000)}, { about: ${vec([+w[2], +w[3], +w[4]], 100)} }, `; const start = w.index + lead.length; - return { next: head.slice(0, w.index) + lead + text.slice(span.start), how: "fold", start, end: start + span.end - span.start }; + return { next: head.slice(0, w.index) + lead + text.slice(inner.start), how: "fold", start, end: start + inner.end - inner.start }; } const lead = `scale(${f}, { about: ${vec(about, 100)} }, `; const r = wrapText(text, span, lead); @@ -400,8 +411,10 @@ export function pressPullText(text, { body, outline, target = null, sign = 1, ax // the pulled side); a corner-anchored one grows from its min side const comp = centered ? (sign * step) / 2 : sign < 0 ? -step : 0; if (comp) { + // the number can sit in the call or in a declaration above it (const w = 9) const dLen = String(value).length - (target.numEnd - target.numStart); - const span = { start: siteSpan.start, end: siteSpan.end + dLen }; + const at = (i) => (i >= target.numEnd ? i + dLen : i); + const span = { start: at(siteSpan.start), end: at(siteSpan.end) }; const v = [0, 0, 0]; v[axis] = round(comp, 100); const head = next.slice(0, span.start); diff --git a/viewer/design/sketch-model.js b/viewer/design/sketch-model.js index 0167f0e..a1ec776 100644 --- a/viewer/design/sketch-model.js +++ b/viewer/design/sketch-model.js @@ -329,7 +329,7 @@ export function compact(sk) { sk.points.forEach((p, i) => { if (keep.has(i)) { map.set(i, points.length); points.push(p); } }); const m = (ids) => ids.map((i) => map.get(i)); return { - on: sk.on, points, + on: sk.on, ...(sk.onSrc ? { onSrc: sk.onSrc } : {}), points, curves: sk.curves.map((c) => ({ ...c, ids: m(c.ids) })), constraints: sk.constraints.filter((c) => c.ids.every((i) => map.has(i))).map((c) => ({ ...c, ids: m(c.ids) })), dims: sk.dims.filter((d) => d.ids.every((i) => map.has(i))).map((d) => ({ ...d, ids: m(d.ids) })), @@ -344,8 +344,15 @@ export function sketchCode(skIn, indent = "") { const sk = compact(skIn); const i1 = indent + " "; const list = (items) => `[${items.join(", ")}]`; - const lines = [`on: ${onText(sk.on)}`, `points: ${list(sk.points.map((p) => `[${num(p[0])}, ${num(p[1])}]`))}`, - `curves: ${list(sk.curves.map((c) => `[${curveArr(c).map((v) => (typeof v === "string" ? JSON.stringify(v) : num(v))).join(", ")}]`))}`]; + // a name read from the code goes back as that name while its value is untouched + const coord = (p, k) => (p.names?.[k] && p[k] === p.read[k] ? p.names[k] : num(p[k])); + const curve = (c) => { + const a = curveArr(c).map((v) => (typeof v === "string" ? JSON.stringify(v) : num(v))); + if (c.kind === "circle" && c.rName && c.r === c.rRead) a[2] = c.rName; + return `[${a.join(", ")}]`; + }; + const lines = [`on: ${sk.onSrc ?? onText(sk.on)}`, `points: ${list(sk.points.map((p) => `[${coord(p, 0)}, ${coord(p, 1)}]`))}`, + `curves: ${list(sk.curves.map(curve))}`]; if (sk.constraints.length) lines.push(`constraints: ${list(sk.constraints.map((c) => `[${[JSON.stringify(c.type), ...c.ids].join(", ")}]`))}`); if (sk.dims.length) lines.push(`dims: ${list(sk.dims.map((d) => `[${[JSON.stringify(d.type), ...d.ids, d.name ?? num(d.value)].join(", ")}]`))}`); return `sketch({\n${lines.map((l) => i1 + l + ",").join("\n")}\n${indent}})`; @@ -372,11 +379,22 @@ export function readSketch(text, call) { }; const named = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : { value: v }); const on = val(prop("on")) ?? "XY"; - const points = (val(prop("points")) || []).map((p) => p.map((v) => named(v).value)); + // a plane written as a name or an expression is drawn on XY here but goes + // back exactly as written, so finishing the sketch never moves it + const onNode = prop("on"); + const literal = (n) => n && (n.type === "Literal" || (n.type === "UnaryExpression" && n.argument.type === "Literal") + || (n.type === "ArrayExpression" && n.elements.every(literal)) + || (n.type === "ObjectExpression" && n.properties.every((p) => !p.computed && literal(p.value)))); + const onSrc = onNode && !literal(onNode) ? src.slice(onNode.start, onNode.end) : null; + const points = (val(prop("points")) || []).map((p) => { + const q = p.map((v) => named(v).value); + if (p.some((v) => named(v).name)) { q.names = p.map((v) => named(v).name ?? null); q.read = [...q]; } + return q; + }); const curves = (val(prop("curves")) || []).map((c) => { const construction = c[c.length - 1] === "construction"; const args = construction ? c.slice(1, -1) : c.slice(1); - if (c[0] === "circle") return { kind: "circle", ids: [args[0]], r: named(args[1]).value, rName: named(args[1]).name, construction }; + if (c[0] === "circle") return { kind: "circle", ids: [args[0]], r: named(args[1]).value, rName: named(args[1]).name, rRead: named(args[1]).value, construction }; return { kind: c[0], ids: args, construction }; }); const constraints = (val(prop("constraints")) || []).map((c) => ({ type: c[0], ids: c.slice(1) })); @@ -384,7 +402,7 @@ export function readSketch(text, call) { const v = named(d[d.length - 1]); return { type: d[0], ids: d.slice(1, -1), value: v.value, ...(v.name ? { name: v.name } : {}) }; }); - return { on: typeof on === "object" && on && "name" in on ? "XY" : on, points, curves, constraints, dims }; + return { on: onSrc ? "XY" : on, ...(onSrc ? { onSrc } : {}), points, curves, constraints, dims }; } function constValues(text) { const out = new Map(); diff --git a/viewer/index.html b/viewer/index.html index 0886018..16a985c 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -7918,12 +7918,14 @@ chatInput.placeholder = chatInput.dataset.placeholder; } askPin.title = selCtx ? SC.selectionLabel(selCtx) : ""; - if (selCtx) requestAnimationFrame(trackPin); + // one loop, however many times a spot is pinned + if (selCtx) { if (!pinTracking) { pinTracking = true; requestAnimationFrame(trackPin); } } else askPin.hidden = true; } const pinV = new THREE.Vector3(); + let pinTracking = false; function trackPin() { - if (!selCtx?.point) { askPin.hidden = true; return; } + if (!selCtx?.point) { askPin.hidden = true; pinTracking = false; return; } const rect = renderer.domElement.getBoundingClientRect(); pinV.set(...selCtx.point).project(camera); const off = pinV.z > 1 || Math.abs(pinV.x) > 1.02 || Math.abs(pinV.y) > 1.02; From 1465a49b91705c313ce6830d4e104f821ee2158d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:17:42 +0000 Subject: [PATCH 3/4] Right-click menu: on macOS and Linux a right-drag pan no longer opens it Chromium on macOS and Linux sends contextmenu as the right button goes down, before any drag (Windows sends it on the release), so the 'moved more than 6 px: it was a pan' check always saw no movement and the shape menu opened at the start of every right-drag pan. When contextmenu arrives with the button still held, the menu now waits for the release and opens only if the pointer stayed put; Windows keeps the path it had. Found by scripts/ask-check-run.cjs in headless Chromium on Linux ('a right-drag (pan) opens no menu' failed); it passes now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGrpCLT8AW3vBotHJbhRwf --- test/design.js | 5 +++++ viewer/index.html | 30 +++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/test/design.js b/test/design.js index 672611d..7e14717 100644 --- a/test/design.js +++ b/test/design.js @@ -635,6 +635,11 @@ console.log("\nPhase 2: wiring\n"); 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)); 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)); diff --git a/viewer/index.html b/viewer/index.html index 16a985c..007436d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -7831,29 +7831,45 @@ }, true); // A right-DRAG pans the view: the menu is for a still right-click only, so // a pan never ends in a menu (or a pin) at wherever the drag let go. - let rightDown = null; - stage.addEventListener("pointerdown", (e) => { if (e.button === 2) rightDown = [e.clientX, e.clientY]; }, true); + // Windows sends contextmenu when the button comes up; macOS and Linux send + // it as it goes DOWN, before any drag, so there the menu waits for the + // release and opens only if the pointer stayed put. + let rightDown = null, rightHeld = false, menuOnRelease = false; + stage.addEventListener("pointerdown", (e) => { if (e.button === 2) { rightDown = [e.clientX, e.clientY]; rightHeld = true; menuOnRelease = false; } }, true); + addEventListener("pointerup", (e) => { + if (e.button !== 2 || !rightHeld) return; + rightHeld = false; + if (!menuOnRelease) return; + menuOnRelease = false; + const still = rightDown && Math.hypot(e.clientX - rightDown[0], e.clientY - rightDown[1]) <= 6; + rightDown = null; + if (still) openShapeMenuAt(e.clientX, e.clientY); + }, true); stage.addEventListener("contextmenu", (e) => { e.preventDefault(); + if (rightHeld) { menuOnRelease = true; return; } if (rightDown && Math.hypot(e.clientX - rightDown[0], e.clientY - rightDown[1]) > 6) { rightDown = null; return; } rightDown = null; + openShapeMenuAt(e.clientX, e.clientY); + }); + function openShapeMenuAt(clientX, clientY) { const rect = renderer.domElement.getBoundingClientRect(); raycaster.setFromCamera(new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1), camera); + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1), camera); const hit = pickModel(raycaster)[0]; if (!hit?.object.userData.featureId) { shapeMenu.classList.remove("open"); return; } askHit = { hit, ray: raycaster.ray.direction.clone() }; shapeMenuFid = hit.object.userData.featureId; buildSwapChips(shapeMenuFid); - shapeMenu.style.left = `${Math.min(e.clientX, innerWidth - 240)}px`; - shapeMenu.style.top = `${Math.min(e.clientY, innerHeight - 200)}px`; + shapeMenu.style.left = `${Math.min(clientX, innerWidth - 240)}px`; + shapeMenu.style.top = `${Math.min(clientY, innerHeight - 200)}px`; shapeMenu.classList.add("open"); // The material block stays open between right-clicks — somebody assigning // a material to six pieces should not re-open it six times. if (!$("sm-matblock").hidden) { fillShapeMaterials(); syncShapeMatUI(shapeMenuFid); } fitShapeMenu(); - }); + } // ------------------------------------------------- "Ask about this…" // Right-click → Ask about this… pins a marker at the clicked point and From e05531fb4e268966c27cdf5619ba638a32b09aad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:40:07 +0000 Subject: [PATCH 4/4] Design mode: revolve / combine guard a body with no call, dialogs dispose their overlay, sketch Finish never undoes newer code - revolve and combine dialogs passed a null body on to the text writers (a TypeError inside the async preview, so the dialog just went quiet); they now say the shape has no call in the code, as extrude / hole / fillet do. - feature dialogs made two materials per open and a PointsMaterial per hole pick and never disposed them (nor the edge line geometries on close). - sketch Finish wrote the code as it was when the sketch opened, so an edit made meanwhile was reverted without a word; it now refuses, as press-pull does, and opening a feature dialog closes an open sketch first (the sketcher took every pick, so the dialog could not get its own). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGrpCLT8AW3vBotHJbhRwf --- test/design.js | 12 ++++++++++++ viewer/design-shell.js | 2 ++ viewer/design/dialogs.js | 18 +++++++++++++++--- viewer/design/sketcher.js | 5 +++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/test/design.js b/test/design.js index 7e14717..f10ffe7 100644 --- a/test/design.js +++ b/test/design.js @@ -625,6 +625,15 @@ console.log("\nPhase 2: wiring\n"); 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)); @@ -640,6 +649,9 @@ console.log("\nPhase 2: wiring\n"); 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)); diff --git a/viewer/design-shell.js b/viewer/design-shell.js index 49005f7..9d48763 100644 --- a/viewer/design-shell.js +++ b/viewer/design-shell.js @@ -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); diff --git a/viewer/design/dialogs.js b/viewer/design/dialogs.js index 0570167..b45d9c7 100644 --- a/viewer/design/dialogs.js +++ b/viewer/design/dialogs.js @@ -155,6 +155,7 @@ const SPECS = { const call = revolveCall(s.face.outline, ax, s.angle); if (call.error) return call; const body = env.bodyOf(s.face.fid); + if (!body) return { error: "That face has no call in the code to add to." }; if (s.op === "cut") return F.addTool(base, body, call.text); if (s.op === "join") return F.addTool(base, body, call.text, "union"); return F.newBodyText(base, body, call.text); @@ -286,6 +287,7 @@ const SPECS = { if (!s.fid) return { wait: "Select the target body first." }; if (!s.bodies.length) return { wait: "Click the bodies to combine with it." }; const target = env.bodyOf(s.fid); + if (!target) return { error: "The target body has no call in the code." }; const tools = s.bodies.map((b) => env.bodyOf(b.fid)); if (tools.some((t) => !t || (t.start === target.start && t.end === target.end))) return { error: "Pick bodies other than the target." }; return F.combineText(base, target, tools, s.op); @@ -431,8 +433,16 @@ export function openDialog(id, ctx) { const pickMat = new THREE.MeshBasicMaterial({ color: 0xffd166, transparent: true, opacity: 0.4, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -3, side: THREE.DoubleSide }); const lineMat = new THREE.LineBasicMaterial({ color: 0xff5c8a, depthTest: false, transparent: true }); + // a face's tint borrows the model's own geometry; lines and points are ours + const clearPicks = () => { + for (const c of [...overlay.children]) { + overlay.remove(c); + if (c.isLine || c.isLineSegments || c.isPoints) c.geometry.dispose(); + if (c.isPoints) c.material.dispose(); + } + }; const drawPicks = () => { - for (const c of [...overlay.children]) { overlay.remove(c); if (c.isLine || c.isLineSegments || c.isPoints) c.geometry.dispose(); } + clearPicks(); const faces = [state.face, ...(state.faces || []), state.at].filter(Boolean); for (const f of faces) { if (!f.mesh?.geometry) continue; @@ -586,7 +596,9 @@ export function openDialog(id, ctx) { clearTimeout(previewT); removeEventListener("keydown", onKey); box.remove(); - for (const c of [...overlay.children]) overlay.remove(c); + clearPicks(); + pickMat.dispose(); + lineMat.dispose(); scene.remove(overlay); ctx.onClose?.(); } @@ -704,7 +716,7 @@ function shortReason(r) { } export const DIALOGS = Object.keys(SPECS); -export const _test = { revolveAxes, revolveCall, changedLine, diffSpan }; +export const _test = { revolveAxes, revolveCall, changedLine, diffSpan, SPECS }; // The range of `next` that differs from `base`: where a dialog's edit went. function diffSpan(base, next) { diff --git a/viewer/design/sketcher.js b/viewer/design/sketcher.js index 5cbf64b..2942698 100644 --- a/viewer/design/sketcher.js +++ b/viewer/design/sketcher.js @@ -735,14 +735,15 @@ export function createSketcher({ host, THREE, scene, camera, orbit, renderer }) async function finish() { const p = M.profileProblem(S.sk); if (S.feat !== "none" && p) { setMsg(`Not a profile yet: ${esc(p)}`, "err"); return; } + // the sketch is written into the code as it was when it opened: anything + // changed since (typed, or another tool) would be silently undone + if (host.text() !== S.orig) { setMsg("The code changed while this sketch was open, so Finish would undo that. Cancel and sketch again.", "err"); return; } let next; try { next = finishText(); } catch (e) { setMsg(esc(e.message || e), "err"); return; } - const base = S.text; close(); const res = await host.commit(next); host.setHint(res.ok ? "Sketch finished: written into the code as sketch({ … })." : `The sketch was written, but the build failed: ${esc(res.failure?.reason ?? "")}. Ctrl+Z takes it back.`); - void base; } function cancel() { endChoose(); close(); host.setHint("Sketch cancelled; the code is as it was."); } function close() {