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/design.js b/test/design.js index b0cd9e5..f10ffe7 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,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)); @@ -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 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)); @@ -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 }); 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 { 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 a80c460..b45d9c7 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; } } @@ -147,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); @@ -278,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); @@ -423,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; @@ -568,7 +586,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); @@ -578,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?.(); } @@ -589,7 +609,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 }); @@ -691,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/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/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() { diff --git a/viewer/index.html b/viewer/index.html index 0886018..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 @@ -7918,12 +7934,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;