From 6a399e847e5ea3bc4ff67748a03db0b66744fcb6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 7 Oct 2026 04:14:40 +0300 Subject: [PATCH 1/5] [feat] knife polyline cuts (37 R11) - knifeCutCore (pure): corners on the mesh are inserted as vertices first (per welded edge when on one, snapped to the face corner/edge under them), then each segment is a straight cut on the previous result; a cut ending at an inserted vertex fans onto that vertex, so 3+ crossings per triangle resolve segment by segment and stay watertight - Shift/Ctrl+click (or touch multi-select) adds a corner, plain click ends the cut, Enter ends at the last corner, Backspace drops one, Esc cancels; Back/Cut buttons in Tool options while pending; the overlay draws the placed polyline - one undo entry and one broadcast per polyline; knifeCut(from,to) unchanged API - unit meshKnife 11/11 (counterfactual: no corner insertion -> 4 red); e2e mesh-knife gains a real-input polyline section Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 28 +- src/components/menu/KnifeOverlay.svelte | 14 +- src/components/menu/MeshEditPopup.svelte | 2 +- src/components/menu/MeshToolOptions.svelte | 27 +- src/lib/faceEdit.js | 476 ++++++++++++++++++--- tests/e2e/mesh-knife.test.cjs | 139 ++++++ tests/unit/meshKnife.test.js | 227 ++++++++++ 7 files changed, 818 insertions(+), 95 deletions(-) create mode 100644 tests/unit/meshKnife.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index d89e7526..d66f3841 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -56,11 +56,8 @@ import { startSnapEngine, setSnapPointer, beginSnapDrag, endSnapDrag, maybeSnapGizmo, snapAnchorPicking, snapAnchorClick, updateSnapAnchor } from '$lib/snapEngine'; import { meshPivotPicking, meshPivotClick, tickMeshPivotMarker } from '$lib/meshPivot'; import { editingObject, enterEditMode, exitEditMode, raycastHandles, clearVertexSelection, onProxyMoved, onProxyDragChanged, tickMeshEdit, selectVerticesInRect } from '$lib/meshEdit'; - import { faceEditObject, enterFaceEdit, faceEditOp, commitArmedFaceOp, exitFaceEdit, highlightFaceByTriangle, attachFaceGizmo, detachFaceGizmo, onFaceGizmoMoved, onFaceGizmoDragChanged, autoApplyFaceOp, faceEditMulti, toggleFaceSelection, clearFaceSelection, pickFaceUnit, lookupEditable, faceEditSubmode, faceEditSelectedTris, setFaceSubmode, selectElementsInRect, pickEdge, pickEdgeAt, clearEdgeSelection, knifeCut, setFaceOp, knifePreview, cancelKnife, tickEditWireframe } from '$lib/faceEdit'; + import { faceEditObject, enterFaceEdit, faceEditOp, commitArmedFaceOp, exitFaceEdit, highlightFaceByTriangle, attachFaceGizmo, detachFaceGizmo, onFaceGizmoMoved, onFaceGizmoDragChanged, autoApplyFaceOp, faceEditMulti, toggleFaceSelection, clearFaceSelection, pickFaceUnit, lookupEditable, faceEditSubmode, faceEditSelectedTris, setFaceSubmode, selectElementsInRect, pickEdge, pickEdgeAt, clearEdgeSelection, knifeClick, knifeHover, knifePreview, tickEditWireframe } from '$lib/faceEdit'; import { fireObjectClick } from '$lib/flowRuntime'; - // M9b: the first click of a knife cut, in CSS pixels. This component is lang="ts", so - // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). - let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) @@ -918,8 +915,8 @@ // 19-B: the element-snap search is cursor-based — track the pointer while a // gizmo drag runs (this listener is on window, so mid-gesture moves arrive) if ($TControls?.dragging) setSnapPointer(event.clientX, event.clientY); - // M9b: the knife's rubber band follows the pointer between the two clicks - if (knifeFrom) $knifePreview = { from: knifeFrom, to: [event.clientX, event.clientY] }; + // M9b: the knife's rubber band follows the pointer while a cut is pending + if ($knifePreview) knifeHover([event.clientX, event.clientY]); if (marqueeStart) { $marqueeRect = { x0: Math.min(marqueeStart[0], event.clientX), @@ -1140,21 +1137,12 @@ // face edit mode (135 desktop): a click highlights the face under it, // and 163 attaches the transform gizmo to it (drag = move/rotate/scale) if ($faceEditObject) { - // M9b KNIFE: two clicks, anywhere — the cut is a SCREEN line, so it does not need - // to hit the mesh at all (that is the point: you cut across a silhouette). The - // first click only records; the second cuts and disarms. + // M9b KNIFE: clicks anywhere — the cut is a SCREEN line, so it does not need to hit + // the mesh at all (that is the point: you cut across a silhouette). The first click + // only records; 37 R11: an ADDITIVE click (the selection's add gesture) places a + // polyline corner, a plain one ends the cut, cuts and disarms. if ($faceEditOp === 'knife') { - if (!knifeFrom) { - knifeFrom = [event.clientX, event.clientY]; - $knifePreview = { from: knifeFrom, to: [event.clientX, event.clientY] }; - showToast('Knife: click the far end of the cut (Esc cancels)'); - } else { - const from = knifeFrom; - knifeFrom = null; - cancelKnife(); - knifeCut(from, [event.clientX, event.clientY]); - setFaceOp('move'); // a one-shot tool: back to the default after a cut - } + knifeClick([event.clientX, event.clientY], event.ctrlKey || event.shiftKey || event.metaKey || $multiSelectMode); return; } // A8: lookupEditable also finds the scene-root collider-edit proxy diff --git a/src/components/menu/KnifeOverlay.svelte b/src/components/menu/KnifeOverlay.svelte index 184301e1..4645e995 100644 --- a/src/components/menu/KnifeOverlay.svelte +++ b/src/components/menu/KnifeOverlay.svelte @@ -12,6 +12,16 @@ {#if $knifePreview}
- Click one end of the cut, then the other. Esc drops a pending cut. + Click one end of the cut, then the other. Shift+click places a corner and keeps going + (a polyline); Enter ends at the last corner, Backspace takes one back, Esc drops the cut.
+ + {#if $knifePreview} +
+ {$knifePreview.points.length} {$knifePreview.points.length === 1 ? 'point' : 'points'} + + +
+ {/if} {/if} diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index ba7c1979..868bfc0f 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -4235,13 +4235,14 @@ function segmentCross(a, b, c, d) { return { onCut, onEdge }; } -/** M9b: the live rubber band while a cut is being placed, in CSS pixels. A store rather than - * component state because the two ends come from different places — the first CLICK (kept in - * Scene.svelte) and the moving pointer. - * @type {import('svelte/store').Writable<{from: number[], to: number[]}|null>} */ +/** M9b + 37 R11: the cut being placed, in CSS pixels. `points` are the corners clicked so far + * (points[0] is the first click), `from` is the LAST of them — the start of the live segment — + * and `to` follows the pointer. A store rather than component state because the clicks come + * from Scene.svelte and the keys (Enter, Backspace, Escape) from this module's listener. + * @type {import('svelte/store').Writable<{points: number[][], from: number[], to: number[]}|null>} */ export const knifePreview = writable(null); -/** Drop a pending cut: the first point is forgotten and the band disappears. */ +/** Drop a pending cut: every placed corner is forgotten and the band disappears. */ export function cancelKnife() { knifePreview.set(null); } @@ -4265,81 +4266,277 @@ export function escapeConsumedByKnife(event) { } /** - * M9b KNIFE: cut the edited mesh along a screen-space line. - * - * Both points are in CSS pixels, as a click gives them. Everything the cut crosses is split; - * everything else is untouched, and a triangle the line only clips at a corner is left whole - * rather than turned into slivers. - * @param {number[]} from [x, y] in pixels @param {number[]} to [x, y] - * @returns {boolean} + * 37 R11: one click of the armed knife. The first click starts a cut. After that a PLAIN click + * ends the cut there and cuts (the two-click knife, unchanged), while an ADDITIVE click — Shift + * or Ctrl, or the touch multi-select toggle, the selection's own "add to" gesture — places a + * CORNER and keeps going. So a polyline is Shift+click ... Shift+click, then a plain click (or + * Enter, which ends at the last corner). Finishing disarms the knife: it is a one-shot tool. + * @param {number[]} point [x, y] client pixels @param {boolean} [add] keep going + * @returns {'started'|'corner'|'cut'|'refused'} */ -export function knifeCut(from, to) { - interruptOpAdjust(); // 19-A P2: the knife's commit ends any live adjust first - if (!faceEdited) return false; - const camera = get(globalCamera); - if (!camera) return false; - // W9: the cut line arrives in CLIENT pixels, so the mesh has to be projected into - // the same space — against the CANVAS, offset by where it sits. With the bottom - // dock open a window-sized projection puts the mesh and the line in two different - // spaces and the cut lands somewhere else entirely. - const rect = canvasRect(); - if (Math.hypot(to[0] - from[0], to[1] - from[1]) < 4) { - showToast('Knife: drag a line across the mesh — that cut was too short'); - return false; +export function knifeClick(point, add = false) { + const pending = get(knifePreview); + if (!pending) { + knifePreview.set({ points: [point], from: point, to: point }); + showToast('Knife: click the far end — Shift+click adds a corner, Enter ends, Esc cancels'); + return 'started'; + } + const last = pending.points[pending.points.length - 1]; + // a double click must not place the same corner twice (a zero-length segment) + const repeat = Math.hypot(point[0] - last[0], point[1] - last[1]) < 3; + if (add) { + if (!repeat) knifePreview.set({ points: [...pending.points, point], from: point, to: point }); + return 'corner'; + } + const points = repeat && pending.points.length > 1 ? pending.points : [...pending.points, point]; + cancelKnife(); + const ok = knifePolyline(points); + setFaceOp('move'); + return ok ? 'cut' : 'refused'; +} + +/** the band follows the pointer @param {number[]} point */ +export function knifeHover(point) { + const pending = get(knifePreview); + if (pending) knifePreview.set({ ...pending, to: point }); +} + +/** Enter: cut through the corners placed so far. The pointer is NOT a corner — Enter ends where + * you last clicked. @returns {boolean} */ +export function knifeFinish() { + const pending = get(knifePreview); + if (!pending || pending.points.length < 2) return false; + cancelKnife(); + const ok = knifePolyline(pending.points); + setFaceOp('move'); + return ok; +} + +/** Backspace: take the last corner back; taking back the first one cancels the cut. + * @returns {boolean} */ +export function knifeDropCorner() { + const pending = get(knifePreview); + if (!pending) return false; + const points = pending.points.slice(0, -1); + if (!points.length) cancelKnife(); + else knifePreview.set({ points, from: points[points.length - 1], to: pending.to }); + return true; +} + +/** + * Enter and Backspace belong to a PENDING cut (the Escape rule: the verdict rides the event). + * @param {KeyboardEvent} event @returns {boolean} consumed + */ +export function knifeKeyConsumed(event) { + if (event.defaultPrevented || !get(knifePreview)) return false; + if (event.key === 'Enter') { + event.preventDefault(); + if (!knifeFinish()) showToast('Knife: place at least two points first'); + return true; + } + if (event.key === 'Backspace') { + event.preventDefault(); + knifeDropCorner(); + return true; } - faceEdited.updateMatrixWorld(true); - const tris = readTriangles(faceEdited.geometry); - if (!tris.length) return false; - /** project a LOCAL point to screen pixels, keeping the clip w for the perspective fix - * @param {any} local @returns {{px: number[], w: number}} */ - const project = (local) => { - const world = faceEdited.localToWorld(local.clone()); - const ndc = world.clone().project(camera); - // project() has already divided by w; recover it from the view-space depth, which is - // what the perspective correction needs (1 for an orthographic camera) - const view = world.clone().applyMatrix4(camera.matrixWorldInverse); - const w = camera.isOrthographicCamera ? 1 : Math.max(-view.z, 1e-6); - return { - px: [ - rect.left + ((ndc.x + 1) / 2) * rect.width, - rect.top + ((1 - ndc.y) / 2) * rect.height - ], - w + return false; +} + +/** How far (pixels) a knife point reaches for an existing vertex / edge of the face under it. + * Without the pull a click a pixel off a corner makes a needle triangle there. */ +const KNIFE_SNAP_VERTEX = 6; +const KNIFE_SNAP_EDGE = 3; +/** after snapping, "on" a corner or an edge means within this many pixels */ +const KNIFE_ON = 0.05; + +/** 2D barycentric coordinates of p in the screen triangle a, b, c (null when it has no area) + * @param {number[]} p @param {number[]} a @param {number[]} b @param {number[]} c + * @returns {number[]|null} */ +function screenBary(p, a, b, c) { + const area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]); + if (Math.abs(area) < 1e-9) return null; + const l1 = ((c[0] - b[0]) * (p[1] - b[1]) - (p[0] - b[0]) * (c[1] - b[1])) / area; + const l2 = ((a[0] - c[0]) * (p[1] - c[1]) - (p[0] - c[0]) * (a[1] - c[1])) / area; + return [l1, l2, 1 - l1 - l2]; +} + +/** the parameter of p's foot on segment a-b and its distance from it, in pixels + * @param {number[]} p @param {number[]} a @param {number[]} b */ +function screenFoot(p, a, b) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const length2 = dx * dx + dy * dy; + const t = length2 > 1e-12 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2 : 0; + const clamped = Math.min(Math.max(t, 0), 1); + const x = a[0] + dx * clamped; + const y = a[1] + dy * clamped; + return { t, distance: Math.hypot(p[0] - x, p[1] - y), at: [x, y] }; +} + +/** + * Pull a knife point onto a corner or an edge of the FRONT-MOST triangle under it — snapping is + * about what the user sees, so the nearest layer decides. + * @param {any[]} tris @param {number[]} p + * @param {(v: any) => {px: number[], w: number}} screenOf @returns {number[]} + */ +function snapKnifePoint(tris, p, screenOf) { + /** @type {{px: number[], w: number}[]|null} */ + let front = null; + let frontDepth = Infinity; + for (const tri of tris) { + const s = tri.map(screenOf); + const bary = screenBary(p, s[0].px, s[1].px, s[2].px); + if (!bary) continue; + let near = Math.min(...bary) >= 0; + if (!near) + for (let e = 0; e < 3 && !near; e++) + near = screenFoot(p, s[e].px, s[(e + 1) % 3].px).distance < KNIFE_SNAP_EDGE; + if (!near) continue; + // the view depth AT the point, interpolated perspective-correctly (1/w is linear on screen) + const inverse = bary.reduce((sum, l, i) => sum + Math.max(l, 0) / s[i].w, 0); + const depth = inverse > 0 ? 1 / inverse : Infinity; + if (depth < frontDepth) { + frontDepth = depth; + front = s; + } + } + if (!front) return p; + let best = null; + let bestDistance = KNIFE_SNAP_VERTEX; + for (const corner of front) { + const distance = Math.hypot(p[0] - corner.px[0], p[1] - corner.px[1]); + if (distance < bestDistance) { + bestDistance = distance; + best = corner.px; + } + } + if (best) return [best[0], best[1]]; + bestDistance = KNIFE_SNAP_EDGE; + for (let e = 0; e < 3; e++) { + const foot = screenFoot(p, front[e].px, front[(e + 1) % 3].px); + if (foot.distance < bestDistance) { + bestDistance = foot.distance; + best = foot.at; + } + } + return best ? [best[0], best[1]] : p; +} + +/** + * Make a knife CORNER a real vertex of every triangle it falls in — every layer, like the cut. + * + * Classified per WELDED EDGE first: a point lying on an edge is the SAME 3D point for both + * triangles sharing it (computed once, with the perspective parameter), so the insert cannot + * open a crack; a point strictly inside a triangle splits just that triangle three ways. + * New corners keep the parent's winding because they keep its cyclic order. + * @param {any[]} tris @param {number[]} p + * @param {(v: any) => {px: number[], w: number}} screenOf + * @returns {any[]|null} the new triangles, or null when the point is on no triangle + */ +function insertKnifeCorner(tris, p, screenOf) { + /** @type {Map} welded edge key -> the 3D point on it */ + const onEdge = new Map(); + /** @type {Map} triangle index -> the 3D point inside it */ + const inside = new Map(); + tris.forEach((/** @type {any[]} */ tri, /** @type {number} */ ti) => { + const s = tri.map(screenOf); + if (s.some((corner) => Math.hypot(p[0] - corner.px[0], p[1] - corner.px[1]) < KNIFE_ON)) return; + let edgeHit = false; + for (let e = 0; e < 3; e++) { + const foot = screenFoot(p, s[e].px, s[(e + 1) % 3].px); + if (foot.distance >= KNIFE_ON || foot.t <= 1e-4 || foot.t >= 1 - 1e-4) continue; + edgeHit = true; + const key = edgeKeyOf(tri[e], tri[(e + 1) % 3]); + if (onEdge.has(key)) continue; + const u = perspectiveParam(foot.t, s[e].w, s[(e + 1) % 3].w); + onEdge.set(key, tri[e].clone().lerp(tri[(e + 1) % 3], u)); + } + if (edgeHit) return; + const bary = screenBary(p, s[0].px, s[1].px, s[2].px); + if (!bary || Math.min(...bary) <= 0) return; + // screen barycentrics -> SPACE barycentrics: each weight divided by its corner's w + const weights = bary.map((l, i) => l / s[i].w); + const total = weights[0] + weights[1] + weights[2]; + const point = tri[0].clone().multiplyScalar(weights[0] / total); + point.addScaledVector(tri[1], weights[1] / total).addScaledVector(tri[2], weights[2] / total); + inside.set(ti, point); + }); + if (!onEdge.size && !inside.size) return null; + /** @type {any[]} */ + const out = []; + tris.forEach((/** @type {any} */ tri, /** @type {number} */ ti) => { + const uvOf = (/** @type {any} */ point) => { + if (!tri.uv) return undefined; + const bary = barycentricOf(point, tri); + return [ + tri.uv[0][0] * bary[0] + tri.uv[1][0] * bary[1] + tri.uv[2][0] * bary[2], + tri.uv[0][1] * bary[0] + tri.uv[1][1] * bary[1] + tri.uv[2][1] * bary[2] + ]; }; - }; - /** @type {Map} */ - const projected = new Map(); - const pointOf = (/** @type {any} */ v) => { - const key = keyOf(v.x, v.y, v.z); - let hit = projected.get(key); - if (!hit) projected.set(key, (hit = { ...project(v), point: v.clone() })); - return hit; - }; + /** the polygon round the triangle's boundary, with any edge points in their places */ + /** @type {{pos: any, uv: number[]|undefined, added: boolean}[]} */ + const ring = []; + for (let e = 0; e < 3; e++) { + ring.push({ pos: tri[e], uv: tri.uv?.[e], added: false }); + const mid = onEdge.get(edgeKeyOf(tri[e], tri[(e + 1) % 3])); + if (mid) ring.push({ pos: mid, uv: uvOf(mid), added: true }); + } + const centre = inside.get(ti); + if (ring.length === 3 && !centre) { + out.push(withSlot([tri[0].clone(), tri[1].clone(), tri[2].clone()], tri.mi, tri.uv)); + return; + } + if (centre) { + // strictly inside: three triangles round the new vertex + const uv = uvOf(centre); + for (let e = 0; e < 3; e++) { + const n = (e + 1) % 3; + out.push( + withSlot([centre.clone(), tri[e].clone(), tri[n].clone()], tri.mi, tri.uv && [uv, tri.uv[e], tri.uv[n]]) + ); + } + return; + } + // on an edge: fan the (convex) boundary polygon from the first added point. Its two ring + // neighbours are collinear with it, so they are exactly the pair the fan skips. + const start = ring.findIndex((entry) => entry.added); + const apex = ring[start]; + for (let i = 1; i < ring.length - 1; i++) { + const b = ring[(start + i) % ring.length]; + const c = ring[(start + i + 1) % ring.length]; + out.push( + withSlot([apex.pos.clone(), b.pos.clone(), c.pos.clone()], tri.mi, tri.uv && [apex.uv, b.uv, c.uv]) + ); + } + }); + return out; +} + +/** + * One STRAIGHT knife segment across the triangles — the M9b cut: crossings once per welded + * edge, the perspective parameter, one-crossing triangles fanned to the opposite corner, and + * two-crossing ones split by walking the boundary. + * @param {any[]} tris @param {number[]} from @param {number[]} to + * @param {(v: any) => {px: number[], w: number}} screenOf + * @returns {{tris: any[], cut: number}|null} null = the segment crossed no edge + */ +function knifeSegment(tris, from, to, screenOf) { // crossings are computed ONCE PER WELDED EDGE, so the two triangles sharing one get the // SAME 3D point and the cut cannot open a crack along a crease /** @type {Map} */ const crossings = new Map(); for (const tri of tris) for (let e = 0; e < 3; e++) { - const a = pointOf(tri[e]); - const b = pointOf(tri[(e + 1) % 3]); const key = edgeKeyOf(tri[e], tri[(e + 1) % 3]); if (crossings.has(key)) continue; + const a = screenOf(tri[e]); + const b = screenOf(tri[(e + 1) % 3]); const hit = segmentCross(from, to, a.px, b.px); if (!hit) continue; const u = perspectiveParam(hit.onEdge, a.w, b.w); - crossings.set(key, { point: a.point.clone().lerp(b.point, u), u, from: a, to: b }); + crossings.set(key, { point: tri[e].clone().lerp(tri[(e + 1) % 3], u) }); } - if (!crossings.size) { - showToast('Knife: that line did not cross the mesh'); - return false; - } - const before = { - positions: trisToPositions(tris), - groups: trisToGroups(tris), - uvs: trisToUVs(tris), - faces: readStoredFaces(faceEdited.geometry) - }; + if (!crossings.size) return null; /** @type {any[]} */ const out = []; let cut = 0; @@ -4375,11 +4572,13 @@ export function knifeCut(from, to) { ); }; if (hits.length === 1) { - // ONE crossing means the cut ends inside this triangle (or leaves through a corner). - // It still has to be split: its neighbour across that edge has the same crossing as a - // real vertex, and leaving this side whole is a T-JUNCTION — the mesh reads as - // non-manifold there (measured: 10 odd edges from a single cut across a box). - // A fan to the opposite corner is the minimal honest split. + // ONE crossing means the cut ends inside this triangle, leaves through a corner, or + // starts at a corner the polyline inserted (37 R11). It still has to be split: its + // neighbour across that edge has the same crossing as a real vertex, and leaving this + // side whole is a T-JUNCTION — the mesh reads as non-manifold there (measured: 10 odd + // edges from a single cut across a box). A fan to the opposite corner is the minimal + // honest split — and when the cut ends at an inserted corner, that corner IS the + // opposite one, so the fan edge lies exactly on the cut. const e = hits[0].e; const point = hits[0].crossing.point; const pointUv = uvOf(point); @@ -4423,6 +4622,135 @@ export function knifeCut(from, to) { push([q.crossing.point, other1, p.crossing.point], tri.uv && [qUv, uv1, pUv]); cut++; } + return { tris: out, cut }; +} + +/** + * 37 R11: the knife's PURE core — triangles in, triangles out, so the polyline cases run in + * node (tests/unit/meshKnife.test.js). `project(local)` puts a LOCAL point on screen and + * returns its view depth `w` for the perspective correction (1 for an orthographic camera). + * + * A polyline needs what the straight cut never did: a triangle can hold a CORNER of the cut and + * be crossed by 3+ cut pieces from different segments. Rather than solving a cut graph per + * triangle (a constrained triangulation of each one), the cut is BUILT UP: + * 1. every point that lands on the mesh becomes a real VERTEX first — snapped onto the corner + * or edge of the face under it when it is that close, then inserted into every triangle it + * falls in (all layers, like the cut itself) or onto the welded EDGE it sits on; + * 2. then each segment is a straight cut on the result of the previous one. A segment that + * starts or ends at an inserted vertex leaves the triangles round it through ONE crossing, + * and the one-crossing split fans to the opposite corner — which IS that vertex. Where a + * later segment crosses an earlier one it crosses that cut's EDGE and splits it like any + * other, so the many-crossings triangle resolves one segment at a time, and every step + * keeps the per-welded-edge crossing rule: watertight by construction. + * The ends become vertices too, so a two-point cut that stops inside the mesh now ends exactly + * where it was clicked instead of fanning to the far corner. + * @param {any[]} tris @param {number[][]} points the polyline in pixels, at least two + * @param {(local: any) => {px: number[], w: number}} project + * @returns {{tris: any[], cut: number, corners: number}|null} null = it touched nothing + */ +export function knifeCutCore(tris, points, project) { + /** @type {Map} */ + const screen = new Map(); + const screenOf = (/** @type {any} */ v) => { + const key = keyOf(v.x, v.y, v.z); + let hit = screen.get(key); + if (!hit) screen.set(key, (hit = project(v))); + return hit; + }; + let work = tris; + let corners = 0; + /** @type {number[][]} */ + const placed = []; + for (const point of points) { + const snapped = snapKnifePoint(work, point, screenOf); + placed.push(snapped); + const inserted = insertKnifeCorner(work, snapped, screenOf); + if (inserted) { + work = inserted; + corners++; + } + } + let cut = 0; + for (let i = 0; i + 1 < placed.length; i++) { + const a = placed[i]; + const b = placed[i + 1]; + if (Math.hypot(b[0] - a[0], b[1] - a[1]) < 1) continue; + const result = knifeSegment(work, a, b, screenOf); + if (!result) continue; + work = result.tris; + cut += result.cut; + } + if (!cut && !corners) return null; + return { tris: work, cut, corners }; +} + +/** + * M9b KNIFE: cut the edited mesh along a screen-space line. + * + * Both points are in CSS pixels, as a click gives them. Everything the cut crosses is split; + * everything else is untouched. The two-point form of `knifePolyline`. + * @param {number[]} from [x, y] in pixels @param {number[]} to [x, y] + * @returns {boolean} + */ +export function knifeCut(from, to) { + return knifePolyline([from, to]); +} + +/** + * 37 R11: cut the edited mesh along a screen-space POLYLINE — ONE undo entry, ONE broadcast for + * the whole cut, however many segments it has. + * @param {number[][]} points [x, y] client pixels, at least two + * @returns {boolean} + */ +export function knifePolyline(points) { + interruptOpAdjust(); // 19-A P2: the knife's commit ends any live adjust first + if (!faceEdited || points.length < 2) return false; + const camera = get(globalCamera); + if (!camera) return false; + // W9: the cut line arrives in CLIENT pixels, so the mesh has to be projected into + // the same space — against the CANVAS, offset by where it sits. With the bottom + // dock open a window-sized projection puts the mesh and the line in two different + // spaces and the cut lands somewhere else entirely. + const rect = canvasRect(); + let length = 0; + for (let i = 1; i < points.length; i++) + length += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]); + if (length < 4) { + showToast('Knife: drag a line across the mesh — that cut was too short'); + return false; + } + faceEdited.updateMatrixWorld(true); + const tris = readTriangles(faceEdited.geometry); + if (!tris.length) return false; + /** project a LOCAL point to screen pixels, keeping the clip w for the perspective fix + * @param {any} local @returns {{px: number[], w: number}} */ + const project = (local) => { + const world = faceEdited.localToWorld(local.clone()); + const ndc = world.clone().project(camera); + // project() has already divided by w; recover it from the view-space depth, which is + // what the perspective correction needs (1 for an orthographic camera) + const view = world.clone().applyMatrix4(camera.matrixWorldInverse); + const w = camera.isOrthographicCamera ? 1 : Math.max(-view.z, 1e-6); + return { + px: [ + rect.left + ((ndc.x + 1) / 2) * rect.width, + rect.top + ((1 - ndc.y) / 2) * rect.height + ], + w + }; + }; + const result = knifeCutCore(tris, points, project); + if (!result) { + showToast('Knife: that line did not cross the mesh'); + return false; + } + const before = { + positions: trisToPositions(tris), + groups: trisToGroups(tris), + uvs: trisToUVs(tris), + faces: readStoredFaces(faceEdited.geometry) + }; + const out = result.tris; const positions = trisToPositions(out); if (positions.length > MAX_SNAPSHOT) { showToast(tooLargeMessage(positions.length, 'cut')); @@ -4446,7 +4774,12 @@ export function knifeCut(from, to) { before, after: withFaces({ positions: after.positions, groups: after.groups, uvs: after.uvs }) }); - showToast('Knife: cut ' + cut + (cut === 1 ? ' triangle' : ' triangles')); + showToast( + 'Knife: cut ' + + result.cut + + (result.cut === 1 ? ' triangle' : ' triangles') + + (points.length > 2 ? ' along ' + (points.length - 1) + ' segments' : '') + ); return true; } @@ -7079,6 +7412,7 @@ export function enterFaceEdit(uuid) { /** @param {KeyboardEvent} event */ function onFaceKeydown(event) { if (!viewportHasKeys(event)) return; // 36 U11: Esc in the node editor leaves a GROUP, not the session + if (knifeKeyConsumed(event)) return; // 37 R11: Enter ends a pending cut, Backspace drops a corner if (event.key === 'Escape') { if (escapeConsumedByKnife(event)) return; if (escapeConsumedByPivotPick(event)) return; // an armed pivot pick first diff --git a/tests/e2e/mesh-knife.test.cjs b/tests/e2e/mesh-knife.test.cjs index fec0168f..51d2c48c 100644 --- a/tests/e2e/mesh-knife.test.cjs +++ b/tests/e2e/mesh-knife.test.cjs @@ -213,6 +213,145 @@ h.run(async () => { h.check(!afterEscape.preview, 'Escape drops a pending cut'); h.check(afterEscape.session, '...and does NOT leave the edit session (that is the second Escape)'); + // --- 37 R11: a POLYLINE cut, all real input ------------------------------ + // Shift+click places corners, Backspace takes one back, Enter ends at the last corner. + // The cut is watertight, every corner on the mesh is a VERTEX, and it is ONE undo step. + const poly0 = await A.page.evaluate(() => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Box 2 2 2'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + window.__box = g.children[g.children.length - 1]; + s.faceEdit.exitFaceEdit?.(); + s.faceEdit.enterFaceEdit(window.__box.uuid); + s.faceEdit.setFaceOp('knife'); + return s.faceEdit.readTriangles(window.__box.geometry).length; + }); + h.check(poly0 === 12, `a fresh box to cut (premise, ${poly0} triangles)`); + const mid = await h.projectPoint(A.page, [0, 0, 0]); + const P = (dx, dy) => [Math.round(mid.x + dx), Math.round(mid.y + dy)]; + const c1 = P(-260, -20); + const c2 = P(-20, -25); // inside the front face: becomes a vertex + const c3 = P(15, 60); // inside too + const stray = P(200, 200); + const c4 = P(260, 70); + await A.page.mouse.click(c1[0], c1[1]); + await A.page.keyboard.down('Shift'); + await A.page.mouse.click(c2[0], c2[1]); + await A.page.mouse.click(c3[0], c3[1]); + await A.page.mouse.click(stray[0], stray[1]); + await A.page.keyboard.up('Shift'); + await A.page.waitForTimeout(120); + const placed = await A.page.evaluate(() => { + let preview; + window.__stores.faceEdit.knifePreview.subscribe((v) => (preview = v))(); + return { + points: preview?.points.length ?? 0, + polyline: !!document.querySelector('.knife-overlay polyline'), + corners: document.querySelectorAll('.knife-overlay circle').length, + tris: window.__stores.faceEdit.readTriangles(window.__box.geometry).length + }; + }); + h.check(placed.points === 4, `Shift+click placed corners (${placed.points} points pending)`); + h.check(placed.polyline && placed.corners >= 4, '...drawn as a polyline with a dot per corner'); + h.check(placed.tris === 12, '...and nothing is cut yet'); + await A.page.keyboard.press('Backspace'); + await A.page.waitForTimeout(100); + const dropped = await A.page.evaluate(() => { + let preview; + window.__stores.faceEdit.knifePreview.subscribe((v) => (preview = v))(); + let session; + window.__stores.faceEdit.faceEditObject.subscribe((v) => (session = v))(); + return { points: preview?.points.length ?? 0, session: !!session }; + }); + h.check(dropped.points === 3 && dropped.session, `Backspace took the stray corner back (${dropped.points} left), session intact`); + // the last corner by plain click ENDS the cut + await A.page.mouse.click(c4[0], c4[1]); + await A.page.waitForTimeout(250); + const polyCut = await A.page.evaluate( + ({ c2, c3 }) => { + const s = window.__stores; + const fe = s.faceEdit; + const tris = fe.readTriangles(window.__box.geometry); + let camera; + s.globalCamera.subscribe((c) => (camera = c))(); + let renderer; + s.globalRenderer.subscribe((r) => (renderer = r))(); + const rect = renderer.domElement.getBoundingClientRect(); + // a vertex on the FRONT face (z = 1) whose projection is the clicked corner + const nearestPx = (target) => { + let best = 1e9; + for (const t of tris) + for (const v of t) { + if (Math.abs(v.z - 1) > 1e-4) continue; + const p = v.clone().applyMatrix4(window.__box.matrixWorld).project(camera); + const x = rect.left + ((p.x + 1) / 2) * rect.width; + const y = rect.top + ((1 - p.y) / 2) * rect.height; + best = Math.min(best, Math.hypot(x - target[0], y - target[1])); + } + return best; + }; + let op; + fe.faceEditOp.subscribe((v) => (op = v))(); + return { tris: tris.length, at2: nearestPx(c2), at3: nearestPx(c3), op }; + }, + { c2, c3 } + ); + h.check(polyCut.tris > 12, `the polyline cut the mesh (12 -> ${polyCut.tris})`); + h.check(polyCut.at2 < 1.5 && polyCut.at3 < 1.5, `both inner corners are real VERTICES where they were clicked (${polyCut.at2.toFixed(2)} / ${polyCut.at3.toFixed(2)} px)`); + h.check(polyCut.op === 'move', '...and the knife disarmed after the cut'); + const polyOdd = await oddEdges(A.page); + h.check(polyOdd === 0, `WATERTIGHT after a polyline cut (${polyOdd} odd edges)`); + const polyUndo = await A.page.evaluate(() => { + const s = window.__stores; + const count = () => s.faceEdit.readTriangles(window.__box.geometry).length; + const after = count(); + s.history.undo(); + const undone = count(); + s.history.redo(); + return { after, undone, redone: count() }; + }); + h.check(polyUndo.undone === 12 && polyUndo.redone === polyUndo.after, `ONE undo takes the whole polyline back (${polyUndo.after} -> ${polyUndo.undone} -> ${polyUndo.redone})`); + // Enter ends at the last corner; the toolbox Cut button is the same action for touch + const viaEnter = await A.page.evaluate(() => { + window.__stores.faceEdit.setFaceOp('knife'); + return window.__stores.faceEdit.readTriangles(window.__box.geometry).length; + }); + await A.page.mouse.click(...P(-260, 90)); + await A.page.keyboard.down('Shift'); + await A.page.mouse.click(...P(0, 95)); + await A.page.mouse.click(...P(260, 100)); + await A.page.keyboard.up('Shift'); + await A.page.keyboard.press('Enter'); + await A.page.waitForTimeout(250); + const enterCut = await A.page.evaluate(() => { + let preview; + window.__stores.faceEdit.knifePreview.subscribe((v) => (preview = v))(); + return { tris: window.__stores.faceEdit.readTriangles(window.__box.geometry).length, pending: !!preview }; + }); + h.check(enterCut.tris > viaEnter && !enterCut.pending, `Enter ends the cut at the last corner (${viaEnter} -> ${enterCut.tris})`); + const buttonCut0 = await A.page.evaluate(() => { + window.__stores.faceEdit.setFaceOp('knife'); + window.__stores.meshToolParams.focusTool('knife'); + return window.__stores.faceEdit.readTriangles(window.__box.geometry).length; + }); + await A.page.mouse.click(...P(-260, -80)); + await A.page.keyboard.down('Shift'); + await A.page.mouse.click(...P(260, -70)); + await A.page.keyboard.up('Shift'); + await A.page.waitForTimeout(150); + const cutButton = A.page.locator('#knife-finish'); + const buttonShown = (await cutButton.count()) > 0; + h.check(buttonShown, 'the Tool options pane offers a Cut button while a cut is pending (the touch path)'); + if (buttonShown) { + await cutButton.click(); + await A.page.waitForTimeout(250); + const buttonCut = await A.page.evaluate(() => window.__stores.faceEdit.readTriangles(window.__box.geometry).length); + h.check(buttonCut > buttonCut0, `...and pressing it cuts (${buttonCut0} -> ${buttonCut})`); + } + const finalOdd = await oddEdges(A.page); + h.check(finalOdd === 0, `still watertight after three polyline cuts (${finalOdd} odd edges)`); + // --- the refusals ------------------------------------------------------- const refusals = await A.page.evaluate(() => { const s = window.__stores; diff --git a/tests/unit/meshKnife.test.js b/tests/unit/meshKnife.test.js new file mode 100644 index 00000000..92fc2d24 --- /dev/null +++ b/tests/unit/meshKnife.test.js @@ -0,0 +1,227 @@ +// @ts-nocheck — fixture geometry built ad hoc (the checkJs rule for test fixtures) +// 37 R11: the knife's pure core — POLYLINE cuts. Triangles in, triangles out, a projection +// function standing in for the camera, so every case runs in node next to the e2e suite that +// drives the same core through real clicks (mesh-knife). +// +// The gate is the mesh gate: WATERTIGHT (welded edges used other than twice = 0 on a closed +// box) and consistently WOUND (no directed edge walked twice the same way) — plus the things a +// polyline adds: every corner the user placed on the mesh is a real VERTEX, the cut runs along +// mesh edges between them, and a triangle crossed by 3+ cut pieces resolves cleanly. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { readTriangles, knifeCutCore, trisToUVs } from '$lib/faceEdit.js'; + +const keyOf = (v) => `${Math.round(v.x * 1e4)},${Math.round(v.y * 1e4)},${Math.round(v.z * 1e4)}`; +const ek = (a, b) => (a < b ? a + '|' + b : b + '|' + a); + +function oddEdges(tris) { + const use = new Map(); + for (const t of tris) + for (let e = 0; e < 3; e++) { + const k = ek(keyOf(t[e]), keyOf(t[(e + 1) % 3])); + use.set(k, (use.get(k) || 0) + 1); + } + return [...use.values()].filter((n) => n !== 2).length; +} + +function sameWayEdges(tris) { + const seen = new Set(); + let bad = 0; + for (const t of tris) + for (let e = 0; e < 3; e++) { + const k = keyOf(t[e]) + '>' + keyOf(t[(e + 1) % 3]); + if (seen.has(k)) bad++; + seen.add(k); + } + return bad; +} + +function volume(tris) { + let v = 0; + for (const t of tris) v += t[0].dot(new THREE.Vector3().crossVectors(t[1], t[2])) / 6; + return v; +} + +const box = () => readTriangles(new THREE.BoxGeometry(2, 2, 2)); + +/** looking down -Z at the box: x right, y up, 100 px per unit, centred on (500, 500). + * The front face (z = +1) and the back face (z = -1) land on the same pixels — two layers. */ +const ortho = (v) => ({ px: [500 + v.x * 100, 500 - v.y * 100], w: 1 }); + +/** pixel of a point on the front face in the ortho view */ +const px = (x, y) => [500 + x * 100, 500 - y * 100]; + +function hasVertex(tris, x, y, z, tol = 1e-4) { + return tris.some((t) => t.some((v) => Math.abs(v.x - x) < tol && Math.abs(v.y - y) < tol && Math.abs(v.z - z) < tol)); +} + +/** every sample along a 3D segment lies on some mesh EDGE — the cut is made of edges */ +function segmentOnEdges(tris, a, b, samples = 9) { + const edges = []; + for (const t of tris) for (let e = 0; e < 3; e++) edges.push([t[e], t[(e + 1) % 3]]); + const line = new THREE.Line3(); + const closest = new THREE.Vector3(); + for (let i = 1; i < samples; i++) { + const p = a.clone().lerp(b, i / samples); + const onSome = edges.some(([u, v]) => { + line.set(u, v); + line.closestPointToPoint(p, true, closest); + return closest.distanceTo(p) < 1e-4; + }); + if (!onSome) return false; + } + return true; +} + +function minArea(tris) { + let min = Infinity; + for (const t of tris) { + const area = new THREE.Vector3().crossVectors(t[1].clone().sub(t[0]), t[2].clone().sub(t[0])).length() / 2; + min = Math.min(min, area); + } + return min; +} + +function gate(tris, startVolume) { + expect(oddEdges(tris)).toBe(0); + expect(sameWayEdges(tris)).toBe(0); + expect(volume(tris)).toBeCloseTo(startVolume, 6); +} + +describe('knifeCutCore — straight cuts (the M9b contract, unchanged)', () => { + it('a line across the whole silhouette splits front AND back, watertight', () => { + const tris = box(); + const result = knifeCutCore(tris, [px(-3, 0.2), px(3, 0.2)], ortho); + expect(result).not.toBeNull(); + expect(result.corners).toBe(0); // both ends are off the mesh + expect(result.tris.length).toBeGreaterThan(tris.length); + gate(result.tris, volume(tris)); + expect(segmentOnEdges(result.tris, new THREE.Vector3(-1, 0.2, 1), new THREE.Vector3(1, 0.2, 1))).toBe(true); + expect(segmentOnEdges(result.tris, new THREE.Vector3(-1, 0.2, -1), new THREE.Vector3(1, 0.2, -1))).toBe(true); + }); + + it('a line that misses the mesh touches nothing', () => { + expect(knifeCutCore(box(), [[2, 2], [2, 400]], ortho)).toBeNull(); + }); +}); + +describe('knifeCutCore — the ENDS become vertices', () => { + it('a cut that stops inside the face ends exactly where it was clicked', () => { + const tris = box(); + const result = knifeCutCore(tris, [px(-3, 0.3), px(0.25, 0.3)], ortho); + gate(result.tris, volume(tris)); + expect(hasVertex(result.tris, 0.25, 0.3, 1)).toBe(true); // front layer + expect(hasVertex(result.tris, 0.25, 0.3, -1)).toBe(true); // ...and the back one under it + expect(segmentOnEdges(result.tris, new THREE.Vector3(-1, 0.3, 1), new THREE.Vector3(0.25, 0.3, 1))).toBe(true); + }); +}); + +describe('knifeCutCore — POLYLINE cuts', () => { + it('an L through a corner inside the face: the corner is a vertex, both legs are edges', () => { + const tris = box(); + const points = [px(-3, 0.4), px(0.3, 0.4), px(0.3, -3)]; + const result = knifeCutCore(tris, points, ortho); + expect(result.corners).toBeGreaterThan(0); + gate(result.tris, volume(tris)); + expect(hasVertex(result.tris, 0.3, 0.4, 1)).toBe(true); + const corner = new THREE.Vector3(0.3, 0.4, 1); + expect(segmentOnEdges(result.tris, new THREE.Vector3(-1, 0.4, 1), corner)).toBe(true); + expect(segmentOnEdges(result.tris, corner, new THREE.Vector3(0.3, -1, 1))).toBe(true); + }); + + it('a zig-zag with every corner inside the face, watertight', () => { + const tris = box(); + const points = [px(-0.8, -0.6), px(-0.4, 0.6), px(0, -0.6), px(0.4, 0.6), px(0.8, -0.6)]; + const result = knifeCutCore(tris, points, ortho); + gate(result.tris, volume(tris)); + for (const [x, y] of [[-0.8, -0.6], [-0.4, 0.6], [0, -0.6], [0.4, 0.6], [0.8, -0.6]]) { + expect(hasVertex(result.tris, x, y, 1)).toBe(true); + } + for (let i = 0; i + 1 < points.length; i++) { + const a = new THREE.Vector3((points[i][0] - 500) / 100, (500 - points[i][1]) / 100, 1); + const b = new THREE.Vector3((points[i + 1][0] - 500) / 100, (500 - points[i + 1][1]) / 100, 1); + expect(segmentOnEdges(result.tris, a, b)).toBe(true); + } + }); + + it('a polyline that CROSSES ITSELF: the crossing becomes a vertex and nothing cracks', () => { + const tris = box(); + // a bow-tie: segment 1 and segment 3 cross at (0, 0) + const points = [px(-0.6, -0.6), px(0.6, 0.6), px(0.6, -0.6), px(-0.6, 0.6)]; + const result = knifeCutCore(tris, points, ortho); + gate(result.tris, volume(tris)); + expect(hasVertex(result.tris, 0, 0, 1)).toBe(true); + }); + + it('3+ cut pieces inside ONE triangle resolve one segment at a time', () => { + const tris = box(); + // a tight star inside one front triangle (the box's front face is two triangles split + // along a diagonal; this stays in the corner near (-0.7, -0.7) whichever way it runs) + const c = [-0.7, -0.75]; + const points = []; + for (let i = 0; i < 6; i++) { + const angle = (i * 4 * Math.PI) / 5; + points.push(px(c[0] + Math.cos(angle) * 0.12, c[1] + Math.sin(angle) * 0.12)); + } + const result = knifeCutCore(tris, points, ortho); + gate(result.tris, volume(tris)); + expect(result.tris.length).toBeGreaterThan(tris.length + 10); + }); + + it('a corner clicked NEXT TO an existing vertex snaps onto it — no needle triangle', () => { + const tris = box(); + // the front face's top-right corner is (1, 1, 1); click 2 px away from it + const near = px(1, 1); + const result = knifeCutCore(tris, [px(-0.5, -0.5), [near[0] - 2, near[1] + 1]], ortho); + gate(result.tris, volume(tris)); + expect(minArea(result.tris)).toBeGreaterThan(1e-3); + // and no new vertex appeared within the snap radius of the corner + const stray = result.tris.some((t) => + t.some((v) => Math.abs(v.z - 1) < 1e-4 && v.distanceTo(new THREE.Vector3(1, 1, 1)) > 1e-4 && v.distanceTo(new THREE.Vector3(1, 1, 1)) < 0.06) + ); + expect(stray).toBe(false); + }); + + it('a corner ON an edge splits that edge for BOTH faces sharing it', () => { + const tris = box(); + // start on the front face's top edge (a crease with the top face), cut down + const result = knifeCutCore(tris, [px(0.2, 1), px(0.2, -0.5)], ortho); + gate(result.tris, volume(tris)); + expect(hasVertex(result.tris, 0.2, 1, 1)).toBe(true); + }); + + it('keeps a COMPLETE uv mapping (new points interpolated)', () => { + const tris = box(); + const result = knifeCutCore(tris, [px(-3, 0.4), px(0.3, 0.4), px(0.3, -3)], ortho); + const uvs = trisToUVs(result.tris); + expect(uvs).not.toBeNull(); + expect(uvs.length).toBe(result.tris.length * 6); + expect(uvs.every((n) => Number.isFinite(n) && n >= -1e-6 && n <= 1 + 1e-6)).toBe(true); + }); +}); + +describe('knifeCutCore — PERSPECTIVE', () => { + it('a corner aimed at a known point lands on it in SPACE (screen barycentrics would drift)', () => { + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); + camera.position.set(3.5, 3, 4.5); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + const size = 1000; + const project = (v) => { + const ndc = v.clone().project(camera); + const view = v.clone().applyMatrix4(camera.matrixWorldInverse); + return { px: [((ndc.x + 1) / 2) * size, ((1 - ndc.y) / 2) * size], w: Math.max(-view.z, 1e-6) }; + }; + const target = new THREE.Vector3(0.35, -0.2, 1); // on the front face, off-centre + // where the first leg enters the front face: the line (-3, 0.6) -> target, at x = -1 + const far = new THREE.Vector3(-3, 0.6, 1); + const start = far.clone().lerp(target, 2 / (target.x - far.x)); + const tris = box(); + const result = knifeCutCore(tris, [project(far).px, project(target).px, project(new THREE.Vector3(0.35, -3, 1)).px], project); + gate(result.tris, volume(tris)); + let best = Infinity; + for (const t of result.tris) for (const v of t) best = Math.min(best, v.distanceTo(target)); + expect(best).toBeLessThan(1e-3); + expect(segmentOnEdges(result.tris, start, target)).toBe(true); + }); +}); From 12aa658e59ad477d4e4e132b327f8078abbcae2e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 7 Oct 2026 04:14:51 +0300 Subject: [PATCH 2/5] [feat] live symmetry at the operator boundary (37 R11) - toolbox Symmetry > "live symmetry" (local, not persisted): every edit is mirrored across symAxis from the side the edit CHANGED (editSide); symKeep only breaks a tie - hooks the OPERATOR boundary, never the restore paths: one-shot ops record via recordOp (mirror on a microtask, entry.after rewritten = ONE undo entry), the adjust engine mirrors the pure result (mirrorAdjustResult), vertex-mode ops pass op=true to commitMeshGeoTriple/Snapshot; undo/redo/cancel/peer edits are applied as sent - vertex drags move each vertex's mirror twin live; plane vertices stay on it - mirrorTrisCore = the pure half shared with one-shot Symmetrize - unit meshSymmetryLive 8/8 (counterfactual: a fixed side discards the edit); e2e mesh-symmetry-live (two peers) - first run still queued Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 13 + src/components/menu/MeshEditPopup.svelte | 23 ++ src/lib/faceEdit.js | 382 ++++++++++++++++++----- src/lib/meshEdit.js | 117 ++++++- src/lib/meshToolParams.js | 7 + tests/e2e/mesh-symmetry-live.test.cjs | 366 ++++++++++++++++++++++ tests/unit/meshSymmetryLive.test.js | 120 +++++++ 7 files changed, 947 insertions(+), 81 deletions(-) create mode 100644 tests/e2e/mesh-symmetry-live.test.cjs create mode 100644 tests/unit/meshSymmetryLive.test.js diff --git a/CLAUDE.md b/CLAUDE.md index d8954dbf..a02626a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -845,6 +845,19 @@ loadable play content. Everything a user does must be visible to connected peers 0 welded edges used other than twice AND 0 directed edges walked twice the same way. CSG output (three-bvh-csg, `meshBooleanCore`) is full of T-junctions — closed to the eye, cracked to every welded-key tool — so `repairTJunctions` runs on every result. + 37 R11 (1.28): the KNIFE is a polyline (`knifeCutCore`, pure): every corner on the mesh is + INSERTED as a vertex first (per welded edge when it sits on one), then each segment is a + straight cut on the previous result — a cut ending at an inserted vertex leaves through ONE + crossing and the one-crossing fan lands on that vertex, so no per-triangle cut graph is + needed. LIVE SYMMETRY (`meshToolParams.liveSymmetry`, local, not persisted) hooks the + OPERATOR boundary, never applyGeometrySnapshot/applyMeshGeo (restore paths): one-shot ops + record through `recordOp` (mirror on a microtask, after the op's own selection code, then + `entry.after` is rewritten — ONE undo entry), the adjust engine mirrors the pure result + (`mirrorAdjustResult`, side fixed per adjust), vertex-mode ops pass `op=true` to + commitMeshGeoTriple/Snapshot, and a vertex DRAG moves each vertex's mirror twin live + (meshEdit `liveTwins`; plane vertices are pinned to the plane). The source side is the side + the edit CHANGED (`editSide`), `symKeep` only breaks a tie. `mirrorTrisCore` is the shared + pure half of Symmetrize. Two traps live here. The LIVE PREVIEW (`liveGeometryUpdate`) swaps geometry every frame, so topology has to survive the preview or there is nothing left for the commit to carry — that was the real reason a rotated band still lost its quads after diff --git a/src/components/menu/MeshEditPopup.svelte b/src/components/menu/MeshEditPopup.svelte index 3ccc24db..ec990b8a 100644 --- a/src/components/menu/MeshEditPopup.svelte +++ b/src/components/menu/MeshEditPopup.svelte @@ -37,6 +37,7 @@ mergeDistance, symAxis, symKeep, + liveSymmetry, optionsFocus, focusTool, hasOptions, @@ -1447,6 +1448,28 @@ })}>Symmetrize + + diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 868bfc0f..44187d4c 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -38,7 +38,7 @@ import { editOverlaysParked } from './editOverlays'; // 19-A P3: the desktop pane's extrude/inset extras, read at BEGIN so a click- // extrude matches the toolbox's Apply. meshToolParams is a svelte/store-only // leaf, so this cannot close a cycle into history. -import { extrudeIndividual, insetDepth, insetIndividual } from './meshToolParams'; +import { extrudeIndividual, insetDepth, insetIndividual, liveSymmetry, symAxis, symKeep } from './meshToolParams'; // 19-A P4: proportional editing shared with the vertex path. Both are LEAVES // (proportional = svelte/store only; proportionalRing = three + sceneStore + // proportional) — this module must NEVER import meshEdit (meshEdit imports us), @@ -1257,7 +1257,7 @@ export function bridgeFaces(cuts = 0, twist = 0, invert = false) { composeFaces(priorFaces, result.origin, result.authored) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -2453,7 +2453,7 @@ export function commitLoopCut(cuts = 1, position = 0.5) { composeFaces(priorFaces, result.origin, result.authored) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -2628,10 +2628,14 @@ export function setFaceSubmode(next) { /** vertices live in meshEdit, which imports THIS module — the reverse edge * would close a TDZ cycle, so meshEdit REGISTERS its accessors here instead. - * @type {{snapshot: () => {uuid: string, sel: number[]} | null, apply: (sel: number[]) => boolean} | null} */ + * 37 R11 adds the POSITION pair: a live-symmetry mirror rebuilds the handles in triangle + * order, so the picks cross it as positions and are re-found after. + * @type {{snapshot: () => {uuid: string, sel: number[]} | null, apply: (sel: number[]) => boolean, + * positions?: () => any, reselect?: (picks: any) => void} | null} */ let vertexSelectionHistory = null; -/** @param {{snapshot: () => any, apply: (sel: number[]) => boolean}} hooks */ +/** @param {{snapshot: () => any, apply: (sel: number[]) => boolean, positions?: () => any, + * reselect?: (picks: any) => void}} hooks */ export function registerVertexSelectionHistory(hooks) { vertexSelectionHistory = hooks; } @@ -3203,7 +3207,7 @@ export function bevelFaces(width = 0.15, segments = 1, profile = 1, direction = composeFaces(priorFaces, appendOrigin(workingTris.length, tris.length), result.authored) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -3541,7 +3545,7 @@ export function bevelVertices(uuid, vertexKeys, options = {}) { uvs: trisToUVs(tris), faces: composeFaces(null, appendOrigin(0, tris.length), authored) }; - if (!commitMeshGeoTriple(uuid, before, after)) return false; + if (!commitMeshGeoTriple(uuid, before, after, true)) return false; showToast( 'Bevelled ' + done + @@ -3556,9 +3560,11 @@ export function bevelVertices(uuid, vertexKeys, options = {}) { * * `commitMeshGeoSnapshot` is positions-only, and a bevel CHANGES the triangle count, so the * carry-over cannot save the groups and uvs — a textured or multi-material mesh lost them. - * @param {string} uuid @param {any} before @param {any} after @returns {boolean} + * @param {string} uuid @param {any} before @param {any} after + * @param {boolean} [op] an OPERATOR's commit — the live-symmetry boundary (37 R11) + * @returns {boolean} */ -export function commitMeshGeoTriple(uuid, before, after) { +export function commitMeshGeoTriple(uuid, before, after, op = false) { if (after.positions.length > MAX_SNAPSHOT) { showToast(tooLargeMessage(after.positions.length, 'edit')); return false; @@ -3567,7 +3573,8 @@ export function commitMeshGeoTriple(uuid, before, after) { applyMeshGeo(uuid, after.positions, after.groups, after.uvs, packed?.faceCounts, packed?.faceTris); // broadcastMeshGeo reads the topology off the object we just applied to broadcastMeshGeo(uuid, after.positions, after.groups, after.uvs); - recordEntry({ kind: 'meshgeo', uuid, before, after }); + // 37 R11: `op` = an operator's commit (vertex mode) — the live-symmetry boundary + (op ? recordOp : recordEntry)({ kind: 'meshgeo', uuid, before, after }); return true; } @@ -3624,7 +3631,7 @@ export function deleteVertices(uuid, vertexKeys) { // goes away with them — the mergeByDistance shape faces: composeFaces(priorFaces, survivorOrigin(inputTris.length, drop), []) }; - if (!commitMeshGeoTriple(uuid, before, after)) return false; + if (!commitMeshGeoTriple(uuid, before, after, true)) return false; showToast( 'Deleted ' + drop.size + @@ -3723,7 +3730,7 @@ export function smoothVertices(uuid, vertexKeys, options = {}) { const before = trisToPositions(inputTris); const after = trisToPositions(out); if (JSON.stringify(before) === JSON.stringify(after)) return false; // factor 0 / already flat - return commitMeshGeoSnapshot(uuid, before, after); + return commitMeshGeoSnapshot(uuid, before, after, true); } /** @@ -3821,7 +3828,7 @@ export function bevelEdges(width = 0.1, segments = 1, profile = 0) { faceEditHighlight.set(-1); applyGeometrySnapshot(positions, trisToGroups(tris), trisToUVs(tris), null); broadcastMeshGeo(faceEdited.uuid, positions, trisToGroups(tris), trisToUVs(tris)); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, @@ -4768,7 +4775,7 @@ export function knifePolyline(points) { }; applyGeometrySnapshot(after.positions, after.groups, after.uvs, null); broadcastMeshGeo(faceEdited.uuid, after.positions, after.groups, after.uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, @@ -4840,14 +4847,18 @@ function mirrorInto(out, pairs, kept, tri, uv) { let mirrorComponent = () => {}; /** - * M7 SYMMETRIZE: mirror one half of the edited mesh onto the other. - * @param {'x'|'y'|'z'} axis the object-local axis to mirror across - * @param {number} keep +1 keeps the positive side, -1 the negative - * @returns {boolean} + * The PURE half of symmetrize (37 R11: shared by the one-shot command and LIVE symmetry): + * keep one side of a triangle soup, clip what straddles the plane, and append the mirror of + * everything kept. Triangles in, triangles out — no session, no scene. + * @param {any[]} tris @param {number[][]|null} prior the partition to carry (null = derive) + * @param {'x'|'y'|'z'} axis @param {number} keep +1 keeps the positive side, -1 the negative + * @param {number} tolerance vertices this close to the plane are pinned ONTO it + * @returns {{tris: any[], faces: number[][]|null, kept: Map, pairs: number, + * clipped: number, dropped: number}|null} null = nothing on the kept side to mirror. + * `kept` maps a wholly-kept source triangle to its index in the output (a selection + * survives through it). */ -export function symmetrizeMesh(axis = 'x', keep = 1) { - interruptOpAdjust(); // 19-A P2: a one-shot commit ends any live adjust first - if (!faceEdited) return false; +export function mirrorTrisCore(tris, prior, axis, keep, tolerance) { const index = axis === 'y' ? 1 : axis === 'z' ? 2 : 0; const component = (/** @type {any} */ v) => (index === 0 ? v.x : index === 1 ? v.y : v.z); const setComponent = (/** @type {any} */ v, /** @type {number} */ value) => { @@ -4855,18 +4866,6 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { else if (index === 1) v.y = value; else v.z = value; }; - const tris = readTriangles(faceEdited.geometry); - if (!tris.length) return false; - const before = { - positions: trisToPositions(tris), - groups: trisToGroups(tris), - uvs: trisToUVs(tris), - faces: readStoredFaces(faceEdited.geometry) - }; - // the snap tolerance scales with the object, so it means the same thing on a chair and on - // a terrain; 0.1% of the bounding diagonal is below anything a user models deliberately - const box = new THREE.Box3().setFromObject(faceEdited); - const tolerance = Math.max(box.getSize(new THREE.Vector3()).length() * 0.001, 1e-5); mirrorComponent = (v) => setComponent(v, -component(v)); const working = cloneTris(tris); for (const tri of working) @@ -4877,6 +4876,8 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { const origin = []; /** @type {{source: number, mirrored: number}[]} */ const pairs = []; + /** @type {Map} */ + const kept = new Map(); let dropped = 0; let clipped = 0; working.forEach((/** @type {any} */ tri, /** @type {number} */ ti) => { @@ -4888,9 +4889,10 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { if (!negatives) { // wholly on the keep side origin[out.length] = ti; - const kept = out.length; + kept.set(ti, out.length); + const keptAt = out.length; out.push(withSlot([tri[0].clone(), tri[1].clone(), tri[2].clone()], tri.mi, tri.uv)); - mirrorInto(out, pairs, kept, tri, tri.uv); + mirrorInto(out, pairs, keptAt, tri, tri.uv); return; } if (!positives) { @@ -4932,30 +4934,15 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { const wound = flip ? [points[0], points[2], points[1]] : points; const woundUv = uvs && (flip ? [uvs[0], uvs[2], uvs[1]] : uvs); origin[out.length] = ti; - const kept = out.length; + const keptAt = out.length; out.push(withSlot(wound, tri.mi, woundUv)); - mirrorInto(out, pairs, kept, wound, woundUv); + mirrorInto(out, pairs, keptAt, wound, woundUv); } }); - if (!pairs.length) { - showToast( - 'Nothing to mirror: no geometry on the ' + - (keep > 0 ? 'positive' : 'negative') + - ' side of the ' + - axis.toUpperCase() + - ' plane' - ); - return false; - } - const positions = trisToPositions(out); - if (positions.length > MAX_SNAPSHOT) { - showToast(tooLargeMessage(positions.length, 'mirror')); - return false; - } + if (!pairs.length) return null; // the partition: a kept triangle keeps its face, and each mirrored triangle joins the // MIRROR of that face — so a quad stays a quad on both sides instead of becoming loose // triangles that coplanarity has to re-guess - const prior = currentPartition(); /** @type {Map} source face index -> mirrored out indices */ const mirroredFaces = new Map(); if (prior) { @@ -4973,13 +4960,64 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { const authored = [...mirroredFaces.values()].filter((list) => list.length); const fullOrigin = []; for (let i = 0; i < out.length; i++) fullOrigin[i] = origin[i] ?? -1; + return { + tris: out, + faces: composeFaces(prior, fullOrigin, authored), + kept, + pairs: pairs.length, + clipped, + dropped + }; +} + +/** the snap tolerance scales with the object, so it means the same thing on a chair and on + * a terrain; 0.1% of the bounding diagonal is below anything a user models deliberately + * @param {any} object @returns {number} */ +function mirrorTolerance(object) { + const box = new THREE.Box3().setFromObject(object); + return Math.max(box.getSize(new THREE.Vector3()).length() * 0.001, 1e-5); +} + +/** + * M7 SYMMETRIZE: mirror one half of the edited mesh onto the other. + * @param {'x'|'y'|'z'} axis the object-local axis to mirror across + * @param {number} keep +1 keeps the positive side, -1 the negative + * @returns {boolean} + */ +export function symmetrizeMesh(axis = 'x', keep = 1) { + interruptOpAdjust(); // 19-A P2: a one-shot commit ends any live adjust first + if (!faceEdited) return false; + const tris = readTriangles(faceEdited.geometry); + if (!tris.length) return false; + const before = { + positions: trisToPositions(tris), + groups: trisToGroups(tris), + uvs: trisToUVs(tris), + faces: readStoredFaces(faceEdited.geometry) + }; + const result = mirrorTrisCore(tris, currentPartition(), axis, keep, mirrorTolerance(faceEdited)); + if (!result) { + showToast( + 'Nothing to mirror: no geometry on the ' + + (keep > 0 ? 'positive' : 'negative') + + ' side of the ' + + axis.toUpperCase() + + ' plane' + ); + return false; + } + const positions = trisToPositions(result.tris); + if (positions.length > MAX_SNAPSHOT) { + showToast(tooLargeMessage(positions.length, 'mirror')); + return false; + } faceEditSelectedTris.set([]); faceEditHighlight.set(-1); faceEditHoverTri.set(-1); clearEdgeSelectionInner(); - const groups = trisToGroups(out); - const uvs = trisToUVs(out); - applyGeometrySnapshot(positions, groups, uvs, composeFaces(prior, fullOrigin, authored)); + const groups = trisToGroups(result.tris); + const uvs = trisToUVs(result.tris); + applyGeometrySnapshot(positions, groups, uvs, result.faces); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); recordEntry({ kind: 'meshgeo', @@ -4991,14 +5029,200 @@ export function symmetrizeMesh(axis = 'x', keep = 1) { 'Symmetrized across ' + axis.toUpperCase() + ': mirrored ' + - pairs.length + - (pairs.length === 1 ? ' triangle' : ' triangles') + - (clipped ? ', clipped ' + clipped : '') + - (dropped ? ', dropped ' + dropped : '') + result.pairs + + (result.pairs === 1 ? ' triangle' : ' triangles') + + (result.clipped ? ', clipped ' + result.clipped : '') + + (result.dropped ? ', dropped ' + result.dropped : '') ); return true; } +// ---- 37 R11: LIVE SYMMETRY ------------------------------------------------ +// M7 shipped a one-shot Symmetrize because a live mode "post-processes every committed +// meshgeo" and several commit call sites are RESTORE paths (cancel, exit, undo replay, a +// peer's edit) that must not mirror. So this hooks the OPERATOR boundary instead, twice: +// - the adjust engine mirrors the pure op result before it is applied (beginOpAdjust, +// every scrub re-run, and the settle all see mirrored triangles — one broadcast each); +// - every one-shot operator records through `recordOp`, which mirrors right after the op +// has finished (a microtask, so the op's own "select the new cap/band" code has run) +// and rewrites the SAME history entry's `after`. +// Restore paths never pass through either, by construction. UNDO: one entry per op — a +// single Ctrl+Z takes back the edit and its mirror together (QUESTIONS-37-mesh #2). +// THE SIDE: whichever side the edit CHANGED (the triangles in `after` that `before` did not +// have, and the ones it lost), so an extrude on the left is mirrored to the right and not +// discarded in favour of the old right half; an edit sitting on the plane uses `symKeep`. + +/** a triangle's identity in a soup: its three welded vertex keys, order-free @param {any[]} tri */ +function triIdentity(tri) { + return tri + .map((/** @type {any} */ v) => keyOf(v.x, v.y, v.z)) + .sort() + .join('|'); +} + +/** + * Which side of the plane an edit happened on: +1, -1, or null when it is balanced (an edit + * on the plane itself, or the same change on both sides). + * @param {any[]} beforeTris @param {any[]} afterTris @param {'x'|'y'|'z'} axis + * @returns {number|null} + */ +export function editSide(beforeTris, afterTris, axis) { + const index = axis === 'y' ? 1 : axis === 'z' ? 2 : 0; + const before = new Set(beforeTris.map(triIdentity)); + const after = new Set(afterTris.map(triIdentity)); + let sum = 0; + let count = 0; + let extent = 0; + const add = (/** @type {any[]} */ tri) => { + for (const v of tri) { + const c = index === 0 ? v.x : index === 1 ? v.y : v.z; + sum += c; + extent = Math.max(extent, Math.abs(c)); + } + count++; + }; + for (const tri of afterTris) if (!before.has(triIdentity(tri))) add(tri); + for (const tri of beforeTris) if (!after.has(triIdentity(tri))) add(tri); + if (!count) return null; + // balanced within 1% of how far the change reaches: an edit ON the plane + if (Math.abs(sum / (count * 3)) <= extent * 0.01) return null; + return sum > 0 ? 1 : -1; +} + +/** positions as triangles, for an entry `before` that is a triple or a bare array + * @param {any} state @returns {any[]} */ +function trisOfState(state) { + const positions = state?.positions ?? state; + /** @type {any[]} */ + const tris = []; + if (!positions) return tris; + for (let i = 0; i + 8 < positions.length; i += 9) + tris.push([ + new THREE.Vector3(positions[i], positions[i + 1], positions[i + 2]), + new THREE.Vector3(positions[i + 3], positions[i + 4], positions[i + 5]), + new THREE.Vector3(positions[i + 6], positions[i + 7], positions[i + 8]) + ]); + return tris; +} + +/** @param {ArrayLike} a @param {ArrayLike} b */ +function sameFloats(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > 1e-6) return false; + return true; +} + +/** set while the live mirror applies its own result, so nothing re-enters it */ +let liveMirroring = false; + +/** + * Record a one-shot operator's meshgeo entry, then — when live symmetry is on — mirror the + * result into that same entry. THE operator boundary for every op outside the adjust engine. + * @param {any} entry + */ +function recordOp(entry) { + recordEntry(entry); + if (!get(liveSymmetry) || liveMirroring) return; + queueMicrotask(() => liveMirrorEntry(entry)); +} + +/** + * Mirror the edited mesh after an operator committed `entry`, and make `entry.after` the + * mirrored state. The object is the face session's, or the VERTEX session's (meshEdit's + * one-shot ops commit through commitMeshGeoTriple/Snapshot on it). Refuses when the geometry + * moved on since the entry (an undo or a peer's edit in between), so it can never mirror + * something that is not the op's result. + * @param {any} entry @returns {boolean} + */ +export function liveMirrorEntry(entry) { + if (liveMirroring || !entry?.uuid) return false; + const session = !!faceEdited && entry.uuid === faceEdited.uuid; + if (!session && vertexSelectionHistory?.snapshot()?.uuid !== entry.uuid) return false; + const object = session ? faceEdited : lookupEditable(entry.uuid); + if (!object?.geometry) return false; + const tris = readTriangles(object.geometry); + const current = trisToPositions(tris); + const recorded = entry.after?.positions ?? entry.after; + if (!recorded || !sameFloats(current, recorded)) return false; + const axis = /** @type {'x'|'y'|'z'} */ (get(symAxis)); + const side = editSide(trisOfState(entry.before), tris, axis) ?? get(symKeep); + const prior = session ? currentPartition() : readStoredFaces(object.geometry); + const result = mirrorTrisCore(tris, prior, axis, side, mirrorTolerance(object)); + if (!result) return false; + const positions = trisToPositions(result.tris); + if (sameFloats(positions, current)) return false; // already symmetric: nothing to send + if (positions.length > MAX_SNAPSHOT) { + showToast(tooLargeMessage(positions.length, 'mirror')); + return false; + } + const groups = trisToGroups(result.tris); + const uvs = trisToUVs(result.tris); + liveMirroring = true; + try { + if (session) { + // the selection rides through the reorder: a wholly-kept triangle has a known + // new index, anything that was clipped or lay on the replaced side is dropped + const picked = get(faceEditSelectedTris) + .map((ti) => result.kept.get(ti)) + .filter((ti) => ti !== undefined); + const hadHighlight = get(faceEditHighlight) >= 0; + faceEditSelectedTris.set(/** @type {number[]} */ (picked)); + faceEditHighlight.set(-1); + faceEditHoverTri.set(-1); + applyGeometrySnapshot(positions, groups, uvs, result.faces); + if (hadHighlight && picked.length) { + faceEditHighlight.set(faceIndexForTriangle(/** @type {number} */ (picked[0]))); + refreshFaceOverlay(); + } + // edge keys are positions: the kept side's survive as they are + edgeEditSelected.set(get(edgeEditSelected).filter((k) => !!edgeEndpoints(k))); + refreshEdgeOverlay(); + if (gizmoTarget && typeof window !== 'undefined') attachFaceGizmo(); + } else { + // the vertex session rebuilds its handles in triangle order on the swap, so the + // picks are carried as POSITIONS (the commitFalloffSnapshot rule) + const picks = vertexSelectionHistory?.positions?.() ?? null; + const packed = result.faces?.length ? packFaces(result.faces) : null; + applyMeshGeo(entry.uuid, positions, groups, uvs, packed?.faceCounts, packed?.faceTris); + if (picks) vertexSelectionHistory?.reselect?.(picks); + } + broadcastMeshGeo(entry.uuid, positions, groups, uvs); + const live = session ? faceEdited : lookupEditable(entry.uuid); + entry.after = withFacesOn(live, { positions, groups, uvs }); + } finally { + liveMirroring = false; + } + return true; +} + +/** + * The adjust engine's half: mirror a run's pure result before anything applies it. The side is + * decided ONCE per adjust (on its first run), so scrubbing a parameter never flips it. Only + * inside a face session — the engine's no-session callers (VR on a bare object) are not edits + * a mirror plane is defined for. + * @param {any} a the adjust state @param {any} result runAdjustCore's output @returns {any} + */ +function mirrorAdjustResult(a, result) { + if (!get(liveSymmetry) || !a.session || result.error || !faceEdited) return result; + const axis = /** @type {'x'|'y'|'z'} */ (get(symAxis)); + if (a.mirrorSide === undefined) + a.mirrorSide = editSide(a.originalTris, result.tris, axis) ?? get(symKeep); + const mirrored = mirrorTrisCore(result.tris, result.faces ?? null, axis, a.mirrorSide, mirrorTolerance(faceEdited)); + if (!mirrored) return result; + const map = (/** @type {number[]} */ list) => + /** @type {number[]} */ (list.map((ti) => mirrored.kept.get(ti)).filter((ti) => ti !== undefined)); + let select = result.select; + if (select?.kind === 'cap' || select?.kind === 'set') select = { ...select, tris: map(select.tris) }; + else if (select?.kind === 'band') { + /** @type {number[]} */ + const band = []; + for (let ti = select.firstNew; ti < select.total; ti++) band.push(ti); + select = { kind: 'set', tris: map(band) }; + } + if (select?.kind === 'cap' && !select.tris.length) select = { kind: 'cleared' }; + return { ...result, tris: mirrored.tris, faces: mirrored.faces, select }; +} + /** * M4: dissolve the selected edges — genuinely REMOVE each one by merging the * two faces it joins and re-triangulating the merged polygon WITHOUT it. @@ -5148,7 +5372,7 @@ export function dissolveEdges() { composeFaces(currentPartition(), origin, fanFaces) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -5224,7 +5448,7 @@ export function deleteSelectedEdges() { faceEditHoverTri.set(-1); applyGeometrySnapshot(positions, groups, uvs, composeFaces(priorFaces, origin, [])); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -5403,7 +5627,7 @@ export function subdivideSelectedEdges() { faceEditHoverTri.set(-1); applyGeometrySnapshot(positions, groups, uvs, composeFaces(priorFaces, origin, [])); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, @@ -6434,7 +6658,8 @@ function commitVertexOp(uuid, object, inputTris, tris, faces, beforeFaces) { groups: trisToGroups(tris), uvs: trisToUVs(tris), faces - } + }, + true ); } @@ -6539,7 +6764,7 @@ export function fillHole() { composeFaces(priorFaces, appendOrigin(origLen, r.tris.length), [r.cap]) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ kind: 'meshgeo', uuid: faceEdited.uuid, before, after: withFaces({ positions, groups, uvs }) }); + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, after: withFaces({ positions, groups, uvs }) }); showToast( 'Filled a ' + r.loopKeys.length + '-edge hole' + (r.centroid ? ' (fanned from its centre — the outline is not a flat convex polygon)' : '') ); @@ -6770,7 +6995,7 @@ export function recalculateNormals() { const uvs = trisToUVs(next); applyGeometrySnapshot(positions, groups, uvs); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -6859,7 +7084,7 @@ export function mergeByDistance(threshold = 0.001) { // keeps the face it was in; a face whose triangles all collapsed simply goes away applyGeometrySnapshot(positions, groups, uvs, composeFaces(priorFaces, survived, [])); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -6924,7 +7149,7 @@ export function triangulateMesh() { faceEditSelectedTris.set([]); faceEditHighlight.set(-1); faceEditHoverTri.set(-1); - if (!commitMeshGeoTriple(faceEdited.uuid, before, after)) return false; + if (!commitMeshGeoTriple(faceEdited.uuid, before, after, true)) return false; showToast('Triangulated: ' + singles.length + ' triangles'); return true; } @@ -6972,7 +7197,7 @@ export function trisToQuadsMesh() { faceEditSelectedTris.set([]); faceEditHighlight.set(-1); faceEditHoverTri.set(-1); - if (!commitMeshGeoTriple(faceEdited.uuid, before, after)) return false; + if (!commitMeshGeoTriple(faceEdited.uuid, before, after, true)) return false; showToast(quads + (quads === 1 ? ' quad paired' : ' quads paired')); return true; } @@ -7753,7 +7978,7 @@ export function commitFaceOp(op, amount) { } applyGeometrySnapshot(positions, groups, uvs, nextFaces); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before: { positions: before, groups: beforeGroups, uvs: beforeUVs, faces: beforeFaces }, @@ -7840,7 +8065,7 @@ export function duplicateSelectedFaces() { composeFaces(priorFaces, appendOrigin(base, next.length), authored) ); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, @@ -7930,16 +8155,17 @@ function broadcastMeshGeo(uuid, positions, groups, uvs) { * Commit a full geometry snapshot for ANY object (161 stretch, 162/163 face * transforms): swap locally, replicate, record ONE undoable meshgeo. Size- * capped like the face ops. @param {string} uuid @param {number[]} before - * @param {number[]} after @returns {boolean} + * @param {number[]} after @param {boolean} [op] an OPERATOR's commit (37 R11) + * @returns {boolean} */ -export function commitMeshGeoSnapshot(uuid, before, after) { +export function commitMeshGeoSnapshot(uuid, before, after, op = false) { if (after.length > MAX_SNAPSHOT) { showToast(tooLargeMessage(after.length, 'edit')); return false; } applyMeshGeo(uuid, after); broadcastMeshGeo(uuid, after); - recordEntry({ kind: 'meshgeo', uuid, before, after }); + (op ? recordOp : recordEntry)({ kind: 'meshgeo', uuid, before, after }); return true; } @@ -8006,7 +8232,7 @@ export function createFaceFromVerts(uuid, verts, viewerPos = null) { } const before = trisToPositions(readTriangles(object.geometry)); const after = before.concat(appended); - return commitMeshGeoSnapshot(uuid, before, after); + return commitMeshGeoSnapshot(uuid, before, after, true); } // ---- VR face grab + live extrude/inset (122): a pending edit applied live, @@ -8432,7 +8658,7 @@ export function commitFaceGrab() { remapEdgeSelectionAfterGrab(before.positions); applyGeometrySnapshot(positions, groups, uvs); broadcastMeshGeo(faceEdited.uuid, positions, groups, uvs); - recordEntry({ + recordOp({ kind: 'meshgeo', uuid: faceEdited.uuid, before, @@ -9301,7 +9527,7 @@ export function beginOpAdjust(op, params, opts = {}) { verts: vertexSelectionHistory?.snapshot()?.sel ?? null }; // run the pure core + apply - const result = runAdjustCore(a); + const result = mirrorAdjustResult(a, runAdjustCore(a)); if (result.error) { showToast(result.error); return false; @@ -9359,7 +9585,7 @@ export function reapplyOpAdjust(patch = {}) { return false; } mergeAdjustParams(a, patch); - const result = runAdjustCore(a); + const result = mirrorAdjustResult(a, runAdjustCore(a)); if (result.error) return false; // keep the last good geometry on a refusal const positions = trisToPositions(result.tris); if (positions.length > MAX_SNAPSHOT) { diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index dcf42495..6d1c92b2 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -53,7 +53,7 @@ export { proportionalEdit, proportionalRadius, falloffWeight } from './proportio import { showProportionalRingAt, hideProportionalRing } from './proportionalRing'; // 19-A P7b: the vertex slide's clamp toggle — meshToolParams is a svelte/store // leaf, so this closes no cycle -import { slideClamp } from './meshToolParams'; +import { slideClamp, liveSymmetry, symAxis } from './meshToolParams'; // W9: where the viewport is. A leaf (svelte/store + sceneStore) — no new edge out of // the history-cycle family this module belongs to. import { canvasRect } from './canvasRect'; @@ -238,6 +238,24 @@ registerVertexSelectionHistory({ setAnchor(live.length ? live[live.length - 1] : -1); syncVertexSelection(); return true; + }, + // 37 R11: the picks as POSITIONS across a live-symmetry rebuild (commitFalloffSnapshot's rule) + positions: () => + edited && handles.length + ? { + anchor: selectedHandle >= 0 ? handles[selectedHandle]?.position.clone() ?? null : null, + members: [...vertexSelection].map((i) => handles[i]?.position.clone()).filter(Boolean) + } + : null, + /** @param {any} picks */ + reselect: (picks) => { + if (!edited || !picks) return; + const find = (/** @type {any} */ p) => handles.findIndex((h) => h.position.distanceToSquared(p) < 1e-10); + vertexSelection = new Set(picks.members.map(find).filter((/** @type {number} */ i) => i >= 0)); + const anchor = picks.anchor ? find(picks.anchor) : -1; + if (anchor >= 0) vertexSelection.add(anchor); + setAnchor(anchor); + syncVertexSelection(); } }); @@ -1546,10 +1564,98 @@ function applyPivotTransform() { } } +/** + * 37 R11 LIVE SYMMETRY in vertex mode: each dragged vertex's TWIN across the mirror plane + * follows it, mirrored, every frame of the gesture — and a vertex ON the plane stays on it + * (it is its own twin; moving it off would tear the seam). Twins are found by position at + * DRAG START, so they are stable for the whole gesture. A vertex with no twin (an + * asymmetric mesh) simply moves alone; Symmetrize first makes every vertex have one. + * @type {{axis: number, tol: number, twinOf: (i: number) => number|undefined, moved: Set} | null} */ +let liveTwins = null; + +/** @param {any} v @param {number} axis */ +function axisOf(v, axis) { + return axis === 0 ? v.x : axis === 1 ? v.y : v.z; +} + +/** Build the twin lookup from the handles as they stand NOW (drag start). */ +function beginLiveTwins() { + liveTwins = null; + if (!get(liveSymmetry) || !edited || !handles.length) return; + const axisName = get(symAxis); + const axis = axisName === 'y' ? 1 : axisName === 'z' ? 2 : 0; + const box = new THREE.Box3().setFromObject(edited); + const tol = Math.max(box.getSize(new THREE.Vector3()).length() * 0.001, 1e-5); + const cell = (/** @type {number} */ n) => Math.round(n / tol); + const keyAt = (/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ z) => + cell(x) + ',' + cell(y) + ',' + cell(z); + /** @type {Map} */ + const byKey = new Map(); + const starts = handles.map((h) => h.position.clone()); + starts.forEach((p, i) => byKey.set(keyAt(p.x, p.y, p.z), i)); + /** @type {Map} */ + const cache = new Map(); + liveTwins = { + axis, + tol, + moved: new Set(), + twinOf: (i) => { + if (cache.has(i)) return cache.get(i); + const p = starts[i]; + let twin; + if (p) { + if (Math.abs(axisOf(p, axis)) < tol) twin = i; + else { + const m = p.clone(); + if (axis === 0) m.x = -m.x; + else if (axis === 1) m.y = -m.y; + else m.z = -m.z; + twin = byKey.get(keyAt(m.x, m.y, m.z)); + } + } + cache.set(i, twin); + return twin; + } + }; +} + +/** Write every moved vertex's twin (and pin plane vertices) — after the gesture's own writes. */ +function applyLiveTwins() { + const twins = liveTwins; + if (!twins) return; + const moved = falloffActive() + ? handles.map((_, i) => i).filter((i) => /** @type {number[]} */ (falloffWeights)[i] > 0) + : gestureIndices(); + const movedSet = new Set(moved); + const p = new THREE.Vector3(); + for (const i of moved) { + const twin = twins.twinOf(i); + if (twin === undefined) continue; + p.copy(handles[i].position); + if (twin === i) { + // on the plane: it may slide along the plane, never off it + if (axisOf(p, twins.axis) !== 0) { + if (twins.axis === 0) p.x = 0; + else if (twins.axis === 1) p.y = 0; + else p.z = 0; + writeHandle(i, p); + } + continue; + } + if (movedSet.has(twin)) continue; // the twin is part of the gesture itself + if (twins.axis === 0) p.x = -p.x; + else if (twins.axis === 1) p.y = -p.y; + else p.z = -p.z; + writeHandle(twin, p); + twins.moved.add(twin); + } +} + /** Apply whichever transform the gesture is (translate / rotate / scale). */ function applyProxyGesture() { if (/** @type {any} */ (proxyGesture).mode === 'translate') applyTranslate(translateDelta()); else applyPivotTransform(); + applyLiveTwins(); // 37 R11: the mirror twins follow (no-op unless live symmetry is on) refreshGeometryAfterWrite(); } @@ -1578,6 +1684,8 @@ export function onProxyMoved() { function broadcastGesture() { broadcastSelected(handles[selectedHandle].position.toArray()); for (const index of gestureIndices()) if (index !== selectedHandle) broadcastHandle(index); + // 37 R11: the live-symmetry twins this gesture moved ride the same channel + if (liveTwins) for (const index of liveTwins.moved) broadcastHandle(index); } /** Broadcast one handle's current LOCAL position over the `verts` channel @@ -1676,6 +1784,7 @@ export function onProxyDragChanged(dragging) { scale: proxy.scale.clone(), starts: handles.map((/** @type {any} */ handle) => handle.position.clone()) }; + beginLiveTwins(); // 37 R11: find each vertex's mirror twin before anything moves // a falloff drag moves many handles, so it undoes as ONE meshgeo snapshot for the same // reason a multi-drag does (a `verts` entry holds one position for all its indices). // P7b: captured whenever falloff COULD engage (`falloffStart`, i.e. the tool is on), @@ -1684,7 +1793,7 @@ export function onProxyDragChanged(dragging) { // A rotate/scale joins them: even a single handle turning about a placed pivot // is easier to reason about as one geometry snapshot than as a 'verts' delta. dragStartExpanded = - vertexSelection.size > 1 || !!falloffStart || mode !== 'translate' + vertexSelection.size > 1 || !!falloffStart || mode !== 'translate' || !!liveTwins ? trisToPositions(readTriangles(edited.geometry)) : null; } else if (dragStartLocal && proxyGesture) { @@ -1704,7 +1813,8 @@ export function onProxyDragChanged(dragging) { if (!committedWhole) broadcastGesture(); // final unthrottled state, every moved handle if (committedWhole) { // the commit applied, sent and recorded everything - } else if (vertexSelection.size > 1 || falloffActive() || mode !== 'translate') { + } else if (vertexSelection.size > 1 || falloffActive() || mode !== 'translate' || liveTwins) { + // 37 R11: twins moved too, which a one-position 'verts' entry cannot hold const afterExpanded = trisToPositions(readTriangles(edited.geometry)); if (dragStartExpanded && JSON.stringify(dragStartExpanded) !== JSON.stringify(afterExpanded)) recordEntry({ @@ -1737,6 +1847,7 @@ export function onProxyDragChanged(dragging) { // the next gesture would read the leftover as its own starting delta setAnchor(selectedHandle); dragStartExpanded = null; + liveTwins = null; } } diff --git a/src/lib/meshToolParams.js b/src/lib/meshToolParams.js index 761b3569..d559d201 100644 --- a/src/lib/meshToolParams.js +++ b/src/lib/meshToolParams.js @@ -99,6 +99,13 @@ export const mergeDistance = writable(0.001); /** M7: symmetrize axis + which half to keep @type {import('svelte/store').Writable<'x'|'y'|'z'>} */ export const symAxis = writable('x'); export const symKeep = writable(1); +/** 37 R11: LIVE symmetry. While on, every operator's result is mirrored across `symAxis` + * from the side the edit happened on (`symKeep` decides only when the edit sits ON the + * plane), and vertex drags move each vertex's mirror twin with it. A LOCAL tool setting — + * the mirrored geometry replicates like any other edit, the switch never does — and it is + * deliberately not persisted: a toggle that silently comes back on in the next session + * rewrites half of the next mesh. @type {import('svelte/store').Writable} */ +export const liveSymmetry = writable(false); /** * Which tool's options the pane shows. Not the same thing as the ARMED op diff --git a/tests/e2e/mesh-symmetry-live.test.cjs b/tests/e2e/mesh-symmetry-live.test.cjs new file mode 100644 index 00000000..03afb497 --- /dev/null +++ b/tests/e2e/mesh-symmetry-live.test.cjs @@ -0,0 +1,366 @@ +// 37 R11: LIVE SYMMETRY — while the toggle is on, every edit is mirrored across the plane +// from the side it happened on, and a vertex drag moves its mirror twin with it. +// +// What has to hold (and how each could go quietly wrong): +// - the result is SYMMETRIC and WATERTIGHT after one-shot ops, adjust-engine ops (scrub + +// settle), the knife, and vertex drags; +// - the EDITED side wins: an extrude on -X is mirrored to +X, never discarded in favour of +// the old +X half; +// - ONE undo takes back the edit AND its mirror; +// - RESTORE paths never mirror: undo/redo replays and a PEER's edit arrive exactly as sent +// (the reason M7 shipped one-shot — hooking the commit path would mirror those too); +// - peers receive the mirrored geometry. +const h = require('./helpers.cjs'); + +/** geometry facts of an object, LOCAL space: bounds, odd/same-way edges, lonely vertices */ +const facts = (page, uuid) => + page.evaluate((uuid) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const object = g.getObjectByProperty('uuid', uuid); + if (!object?.geometry) return null; + const tris = s.faceEdit.readTriangles(object.geometry); + const keyOf = (v) => [v.x, v.y, v.z].map((n) => Math.round(n * 1e4)).join(','); + const use = new Map(); + const directed = new Set(); + let sameWay = 0; + let minX = 1e9; + let maxX = -1e9; + const keys = new Set(); + for (const t of tris) { + const k = t.map(keyOf); + for (let e = 0; e < 3; e++) { + const [a, b] = [k[e], k[(e + 1) % 3]].sort(); + use.set(a + '|' + b, (use.get(a + '|' + b) ?? 0) + 1); + const d = k[e] + '>' + k[(e + 1) % 3]; + if (directed.has(d)) sameWay++; + directed.add(d); + } + for (const v of t) { + minX = Math.min(minX, v.x); + maxX = Math.max(maxX, v.x); + keys.add(keyOf(v)); + } + } + let lonely = 0; + for (const key of keys) { + const [x, y, z] = key.split(',').map(Number); + if (!keys.has([-x, y, z].join(','))) lonely++; + } + return { + tris: tris.length, + odd: [...use.values()].filter((n) => n !== 2).length, + sameWay, + lonely, + minX: +minX.toFixed(4), + maxX: +maxX.toFixed(4) + }; + }, uuid); + +/** a fresh box, symmetrized so its seam sits ON x = 0, in a face session */ +const symmetricBox = (page) => + page.evaluate(() => { + const s = window.__stores; + const fe = s.faceEdit; + s.meshToolParams.liveSymmetry.set(false); + s.commandsHandler.sceneCommand('/create Box 2 2 2'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + window.__box = g.children[g.children.length - 1]; + s.meshEdit.exitEditMode?.(); + fe.exitFaceEdit?.(); + fe.enterFaceEdit(window.__box.uuid); + fe.setFaceGranularity('face'); + fe.symmetrizeMesh('x', 1); + return window.__box.uuid; + }); + +/** select the face whose normal points along `dir` (x sign), by its first triangle */ +const pickFace = (page, nx) => + page.evaluate((nx) => { + const fe = window.__stores.faceEdit; + const faces = fe.currentFaces(); + const face = faces.find((f) => f.normal.x * nx > 0.9); + if (!face) return false; + fe.highlightFaceByTriangle(face.triIndices[0]); + return true; + }, nx); + +const settle = (page) => page.waitForTimeout(150); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await symmetricBox(A.page); + const start = await facts(A.page, uuid); + h.check( + start && start.odd === 0 && start.lonely === 0 && start.minX === -1 && start.maxX === 1, + `premise: a symmetric, watertight box with its seam on the plane (${JSON.stringify(start)})` + ); + + // --- the toggle, through the real toolbox --------------------------------- + const toggle = A.page.locator('#mesh-sym-live'); + if (!(await toggle.isVisible().catch(() => false))) { + const head = A.page.locator('#mesh-sec-symmetry'); + if (await head.count()) await head.first().click(); + await A.page.waitForTimeout(150); + } + const toggleShown = await toggle.isVisible().catch(() => false); + h.check(toggleShown, 'the Symmetry section has a "live symmetry" toggle'); + if (toggleShown) await toggle.click(); + else await A.page.evaluate(() => window.__stores.meshToolParams.liveSymmetry.set(true)); + const on = await A.page.evaluate(() => { + let v; + window.__stores.meshToolParams.liveSymmetry.subscribe((x) => (v = x))(); + return v; + }); + h.check(on === true, 'live symmetry is ON'); + + // --- a one-shot op on -X is mirrored to +X --------------------------------- + const depth0 = await A.page.evaluate(() => { + let st; + window.__stores.history.undoStack.subscribe((v) => (st = v))(); + return st.length; + }); + h.check(await pickFace(A.page, -1), 'picked the -X face (premise)'); + await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('extrude', 0.6)); + await settle(A.page); + const extruded = await facts(A.page, uuid); + h.check( + extruded.minX === -1.6 && extruded.maxX === 1.6, + `an extrude on -X is MIRRORED to +X: x spans ${extruded.minX} .. ${extruded.maxX} (the edited side won)` + ); + h.check(extruded.lonely === 0, `...every vertex has its mirror twin (${extruded.lonely} lonely)`); + h.check(extruded.odd === 0 && extruded.sameWay === 0, `...watertight and consistently wound (${extruded.odd} odd, ${extruded.sameWay} same-way)`); + const keptPick = await A.page.evaluate(() => { + const s = window.__stores; + let sel; + s.faceEdit.faceEditSelectedTris.subscribe((v) => (sel = v))(); + const tris = s.faceEdit.readTriangles(window.__box.geometry); + const xs = sel.flatMap((ti) => (tris[ti] ? tris[ti].map((v) => v.x) : [])); + return { count: sel.length, maxX: xs.length ? Math.max(...xs) : null }; + }); + h.check( + keptPick.count > 0 && keptPick.maxX !== null && keptPick.maxX < -1.5, + `...and the extruded CAP is still the selection, through the mirror's reorder (${keptPick.count} tris at x <= ${keptPick.maxX})` + ); + const depth1 = await A.page.evaluate(() => { + let st; + window.__stores.history.undoStack.subscribe((v) => (st = v))(); + return st.length; + }); + h.check(depth1 - depth0 === 1, `the op AND its mirror are ONE history entry (+${depth1 - depth0})`); + await A.page.evaluate(() => window.__stores.history.undo()); + const undone = await facts(A.page, uuid); + h.check(undone.minX === -1 && undone.maxX === 1 && undone.tris === start.tris, `ONE undo takes back the edit and its mirror (${undone.minX} .. ${undone.maxX})`); + await A.page.evaluate(() => window.__stores.history.redo()); + const redone = await facts(A.page, uuid); + h.check(redone.minX === -1.6 && redone.maxX === 1.6, `redo brings both back (${redone.minX} .. ${redone.maxX})`); + + // --- the peer gets the mirrored geometry ----------------------------------- + await h.eventually( + () => facts(B.page, uuid), + (f) => !!f && f.minX === -1.6 && f.maxX === 1.6 && f.lonely === 0, + 'peer B holds the MIRRORED result', + 20000 + ); + + // --- the adjust engine: apply, scrub, settle — mirrored every run ----------- + h.check(await pickFace(A.page, -1), 'picked the -X cap again (premise)'); + const adjusted = await A.page.evaluate(() => { + const fe = window.__stores.faceEdit; + const ok = fe.beginOpAdjust('extrude', { distance: 0.3 }); + fe.reapplyOpAdjust({ distance: 0.5 }); + const tris = fe.readTriangles(window.__box.geometry); + let minX = 1e9; + let maxX = -1e9; + for (const t of tris) for (const v of t) (minX = Math.min(minX, v.x)), (maxX = Math.max(maxX, v.x)); + fe.settleOpAdjust(); + fe.endOpAdjust(); + return { ok, scrubMin: +minX.toFixed(4), scrubMax: +maxX.toFixed(4) }; + }); + h.check(adjusted.ok, 'the adjust engine applied the extrude (premise)'); + h.check(adjusted.scrubMin === -2.1 && adjusted.scrubMax === 2.1, `a SCRUB is mirrored live (${adjusted.scrubMin} .. ${adjusted.scrubMax})`); + const settled = await facts(A.page, uuid); + h.check(settled.minX === -2.1 && settled.maxX === 2.1 && settled.odd === 0 && settled.lonely === 0, `...and the settle commits it symmetric + watertight (${JSON.stringify(settled)})`); + await A.page.evaluate(() => window.__stores.history.undo()); + const adjustUndone = await facts(A.page, uuid); + h.check(adjustUndone.minX === -1.6 && adjustUndone.maxX === 1.6, `ONE undo takes the adjusted extrude back (${adjustUndone.minX} .. ${adjustUndone.maxX})`); + + // --- RESTORE paths never mirror -------------------------------------------- + await A.page.evaluate(() => window.__stores.meshToolParams.liveSymmetry.set(false)); + h.check(await pickFace(A.page, 1), 'picked the +X cap (premise)'); + await A.page.evaluate(() => window.__stores.faceEdit.commitFaceOp('extrude', 0.4)); + await settle(A.page); + const lopsided = await facts(A.page, uuid); + h.check(lopsided.maxX === 2 && lopsided.minX === -1.6, `with the toggle OFF an edit stays one-sided (premise: ${lopsided.minX} .. ${lopsided.maxX})`); + await A.page.evaluate(() => window.__stores.meshToolParams.liveSymmetry.set(true)); + await A.page.evaluate(() => window.__stores.history.undo()); + await settle(A.page); + const undoReplay = await facts(A.page, uuid); + h.check(undoReplay.maxX === 1.6 && undoReplay.minX === -1.6, `with it ON, an UNDO replays exactly (${undoReplay.minX} .. ${undoReplay.maxX})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await settle(A.page); + const redoReplay = await facts(A.page, uuid); + h.check( + redoReplay.maxX === 2 && redoReplay.minX === -1.6, + `...and a REDO replays the one-sided edit as it was, NOT mirrored (${redoReplay.minX} .. ${redoReplay.maxX})` + ); + // a PEER's edit arrives as sent: B (no live symmetry) pushes the +X face further out + await h.eventually( + () => facts(B.page, uuid), + (f) => !!f && f.maxX === 2, + 'peer B holds the one-sided edit (premise)', + 20000 + ); + await B.page.evaluate((uuid) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const object = g.getObjectByProperty('uuid', uuid); + const before = Array.from(object.geometry.attributes.position.array); + const after = before.slice(); + for (let i = 0; i < after.length; i += 3) if (after[i] > 1.99) after[i] = 2.5; + s.faceEdit.commitMeshGeoSnapshot(uuid, before, after); + }, uuid); + await h.eventually( + () => facts(A.page, uuid), + (f) => !!f && f.maxX === 2.5, + "A receives B's edit", + 20000 + ); + await settle(A.page); + const peerEdit = await facts(A.page, uuid); + h.check(peerEdit.maxX === 2.5 && peerEdit.minX === -1.6, `...and does NOT mirror it (a peer's edit is not A's operator: ${peerEdit.minX} .. ${peerEdit.maxX})`); + + // --- the knife: a cut on the -X half is mirrored ----------------------------- + const knifeBox = await symmetricBox(A.page); + await A.page.evaluate(() => window.__stores.meshToolParams.liveSymmetry.set(true)); + const cutPoints = await A.page.evaluate(() => { + const s = window.__stores; + let camera; + s.globalCamera.subscribe((c) => (camera = c))(); + let renderer; + s.globalRenderer.subscribe((r) => (renderer = r))(); + const rect = renderer.domElement.getBoundingClientRect(); + const px = (x, y, z) => { + const p = new s.THREE.Vector3(x, y, z).applyMatrix4(window.__box.matrixWorld).project(camera); + return [rect.left + ((p.x + 1) / 2) * rect.width, rect.top + ((1 - p.y) / 2) * rect.height]; + }; + return [px(-1.6, 0.3, 1), px(-0.5, 0.35, 1), px(-0.45, -0.4, 1)]; + }); + await A.page.evaluate((points) => window.__stores.faceEdit.knifePolyline(points), cutPoints); + await settle(A.page); + const knifed = await facts(A.page, knifeBox); + h.check(knifed.tris > start.tris, `the knife cut on the -X half (premise: ${start.tris} -> ${knifed.tris})`); + h.check(knifed.lonely === 0 && knifed.odd === 0, `...is mirrored to +X: symmetric and watertight (${knifed.lonely} lonely, ${knifed.odd} odd)`); + + // --- vertex mode: a dragged vertex's TWIN follows ---------------------------- + const vbox = await symmetricBox(A.page); + await A.page.evaluate(() => { + const s = window.__stores; + s.faceEdit.exitFaceEdit(); + s.meshToolParams.liveSymmetry.set(true); + s.meshEdit.enterEditMode(window.__box.uuid); + }); + await A.page.waitForTimeout(200); + const selectNearLocal = (point) => + A.page.evaluate((point) => { + const s = window.__stores; + const me = s.meshEdit; + let controls; + s.TControls.subscribe((c) => (controls = c))(); + let best = -1; + let bestDistance = 1e9; + for (let i = 0; i < 400; i++) { + me.selectHandle(i); + const p = controls.object?.position; + if (!p) break; + const local = window.__box.worldToLocal(p.clone()); + const d = Math.hypot(local.x - point[0], local.y - point[1], local.z - point[2]); + if (d < bestDistance) (bestDistance = d), (best = i); + } + if (best >= 0) me.selectHandle(best); + return { best, bestDistance }; + }, point); + const corner = await selectNearLocal([-1, 1, 1]); + h.check(corner.best >= 0 && corner.bestDistance < 1e-3, `picked the (-1, 1, 1) corner (premise, ${corner.bestDistance.toFixed(4)})`); + const dragged = await A.page.evaluate(() => { + const s = window.__stores; + const me = s.meshEdit; + let controls; + s.TControls.subscribe((c) => (controls = c))(); + me.onProxyDragChanged(true); + controls.object.position.x -= 0.3; + controls.object.position.y += 0.2; + me.onProxyMoved(); + me.onProxyDragChanged(false); + const position = window.__box.geometry.attributes.position; + const has = (x, y, z) => { + for (let i = 0; i < position.count; i++) + if (Math.abs(position.getX(i) - x) < 1e-4 && Math.abs(position.getY(i) - y) < 1e-4 && Math.abs(position.getZ(i) - z) < 1e-4) + return true; + return false; + }; + return { moved: has(-1.3, 1.2, 1), twin: has(1.3, 1.2, 1), oldTwin: has(1, 1, 1) }; + }); + h.check(dragged.moved, 'the dragged corner moved to (-1.3, 1.2, 1) (premise)'); + h.check(dragged.twin && !dragged.oldTwin, 'its TWIN followed, mirrored, to (1.3, 1.2, 1)'); + const vfacts = await facts(A.page, vbox); + h.check(vfacts.lonely === 0 && vfacts.odd === 0, `the mesh stays symmetric + watertight (${vfacts.lonely} lonely, ${vfacts.odd} odd)`); + await h.eventually( + () => + B.page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const position = g.getObjectByProperty('uuid', uuid)?.geometry?.attributes.position; + if (!position) return false; + for (let i = 0; i < position.count; i++) + if (Math.abs(position.getX(i) - 1.3) < 1e-4 && Math.abs(position.getY(i) - 1.2) < 1e-4) return true; + return false; + }, vbox), + (v) => v === true, + 'peer B sees the twin move too', + 20000 + ); + const onPlane = await selectNearLocal([0, 1, 1]); + h.check(onPlane.best >= 0 && onPlane.bestDistance < 1e-3, `picked a seam vertex ON the plane (premise, ${onPlane.bestDistance.toFixed(4)})`); + const pinned = await A.page.evaluate(() => { + const s = window.__stores; + const me = s.meshEdit; + let controls; + s.TControls.subscribe((c) => (controls = c))(); + me.onProxyDragChanged(true); + controls.object.position.x += 0.4; + controls.object.position.y += 0.3; + me.onProxyMoved(); + me.onProxyDragChanged(false); + const position = window.__box.geometry.attributes.position; + let onPlaneAtNewHeight = false; + let offPlane = false; + for (let i = 0; i < position.count; i++) { + if (Math.abs(position.getY(i) - 1.3) < 1e-4 && Math.abs(position.getZ(i) - 1) < 1e-4) { + if (Math.abs(position.getX(i)) < 1e-6) onPlaneAtNewHeight = true; + else offPlane = true; + } + } + return { onPlaneAtNewHeight, offPlane }; + }); + h.check(pinned.onPlaneAtNewHeight && !pinned.offPlane, 'a vertex ON the plane slides along it, never off it'); + await A.page.evaluate(() => { + window.__stores.history.undo(); + window.__stores.history.undo(); + }); + const vundone = await facts(A.page, vbox); + h.check(vundone.maxX === 1 && vundone.minX === -1 && vundone.lonely === 0, `two undos take both drags back, twins included (${vundone.minX} .. ${vundone.maxX})`); + + await A.page.evaluate(() => { + window.__stores.meshEdit.exitEditMode?.(); + window.__stores.meshToolParams.liveSymmetry.set(false); + }); + await h.finish(browser); +}); diff --git a/tests/unit/meshSymmetryLive.test.js b/tests/unit/meshSymmetryLive.test.js new file mode 100644 index 00000000..737779fe --- /dev/null +++ b/tests/unit/meshSymmetryLive.test.js @@ -0,0 +1,120 @@ +// @ts-nocheck — fixture geometry built ad hoc (the checkJs rule for test fixtures) +// 37 R11: LIVE symmetry's pure pieces — `mirrorTrisCore` (the half Symmetrize and the live +// mode share) and `editSide` (which side of the plane an edit happened on). The live mode +// itself is session plumbing and is driven end to end by e2e `mesh-symmetry-live`. +// +// The gate is the mesh gate (watertight + consistently wound) plus SYMMETRY: every vertex +// of the result has a twin at its mirror position. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { readTriangles, mirrorTrisCore, editSide, trisToUVs } from '$lib/faceEdit.js'; + +const keyOf = (v) => `${Math.round(v.x * 1e4)},${Math.round(v.y * 1e4)},${Math.round(v.z * 1e4)}`; +const ek = (a, b) => (a < b ? a + '|' + b : b + '|' + a); + +function oddEdges(tris) { + const use = new Map(); + for (const t of tris) + for (let e = 0; e < 3; e++) { + const k = ek(keyOf(t[e]), keyOf(t[(e + 1) % 3])); + use.set(k, (use.get(k) || 0) + 1); + } + return [...use.values()].filter((n) => n !== 2).length; +} + +function sameWayEdges(tris) { + const seen = new Set(); + let bad = 0; + for (const t of tris) + for (let e = 0; e < 3; e++) { + const k = keyOf(t[e]) + '>' + keyOf(t[(e + 1) % 3]); + if (seen.has(k)) bad++; + seen.add(k); + } + return bad; +} + +/** vertices with no twin across x = 0 */ +function asymmetricVertices(tris) { + const keys = new Set(); + for (const t of tris) for (const v of t) keys.add(keyOf(v)); + let lonely = 0; + for (const t of tris) + for (const v of t) if (!keys.has(keyOf(new THREE.Vector3(-v.x, v.y, v.z)))) lonely++; + return lonely; +} + +const box = () => readTriangles(new THREE.BoxGeometry(2, 2, 2)); + +/** push every vertex with x below `limit` further out by `by` — an "edit" on the -x side */ +function pullLeft(tris, limit, by) { + return tris.map((t) => { + const out = t.map((v) => (v.x < limit ? new THREE.Vector3(v.x - by, v.y, v.z) : v.clone())); + if (t.uv) out.uv = t.uv; + out.mi = t.mi; + return out; + }); +} + +describe('mirrorTrisCore', () => { + it('a plain box: clipped at the plane, mirrored, watertight and symmetric', () => { + const result = mirrorTrisCore(box(), null, 'x', 1, 1e-5); + expect(result).not.toBeNull(); + expect(result.clipped).toBeGreaterThan(0); // a box has no vertex on x = 0 + expect(oddEdges(result.tris)).toBe(0); + expect(sameWayEdges(result.tris)).toBe(0); + expect(asymmetricVertices(result.tris)).toBe(0); + expect(trisToUVs(result.tris).length).toBe(result.tris.length * 6); + }); + + it('`kept` maps a wholly-kept triangle to an IDENTICAL output triangle', () => { + const tris = mirrorTrisCore(box(), null, 'x', 1, 1e-5).tris; // seam on the plane now + const again = mirrorTrisCore(tris, null, 'x', 1, 1e-5); + expect(again.kept.size).toBeGreaterThan(0); + for (const [from, to] of again.kept) { + expect(again.tris[to].map(keyOf)).toEqual(tris[from].map(keyOf)); + } + }); + + it('nothing on the kept side: null', () => { + const right = box().filter((t) => t.every((v) => v.x > 0.5)); // the +x face only + expect(mirrorTrisCore(right, null, 'x', -1, 1e-5)).toBeNull(); + }); +}); + +describe('editSide', () => { + it('names the side an edit CHANGED, either way round', () => { + const sym = mirrorTrisCore(box(), null, 'x', 1, 1e-5).tris; + expect(editSide(sym, pullLeft(sym, -0.5, 0.4), 'x')).toBe(-1); + const pushedRight = sym.map((t) => t.map((v) => (v.x > 0.5 ? new THREE.Vector3(v.x + 0.4, v.y, v.z) : v.clone()))); + expect(editSide(sym, pushedRight, 'x')).toBe(1); + }); + + it('an edit ON the plane (or the same on both sides) is balanced: null', () => { + const sym = mirrorTrisCore(box(), null, 'x', 1, 1e-5).tris; + const lifted = sym.map((t) => t.map((v) => (v.y > 0.5 ? new THREE.Vector3(v.x, v.y + 0.5, v.z) : v.clone()))); + expect(editSide(sym, lifted, 'x')).toBeNull(); + expect(editSide(sym, sym, 'x')).toBeNull(); // no change at all + }); +}); + +describe('live symmetry: the edited side wins', () => { + it('an edit on -x is mirrored to +x (and keeping a FIXED +x side would have thrown it away)', () => { + const sym = mirrorTrisCore(box(), null, 'x', 1, 1e-5).tris; + const edited = pullLeft(sym, -0.5, 0.4); // the -x face now sits at x = -1.4 + const side = editSide(sym, edited, 'x'); + const live = mirrorTrisCore(edited, null, 'x', side, 1e-5); + expect(oddEdges(live.tris)).toBe(0); + expect(sameWayEdges(live.tris)).toBe(0); + expect(asymmetricVertices(live.tris)).toBe(0); + const maxX = Math.max(...live.tris.flatMap((t) => t.map((v) => v.x))); + const minX = Math.min(...live.tris.flatMap((t) => t.map((v) => v.x))); + expect(maxX).toBeCloseTo(1.4, 6); + expect(minX).toBeCloseTo(-1.4, 6); + // the counterfactual: always keeping +x (the one-shot command's default) restores the + // OLD -x half over the user's edit + const fixed = mirrorTrisCore(edited, null, 'x', 1, 1e-5); + const fixedMin = Math.min(...fixed.tris.flatMap((t) => t.map((v) => v.x))); + expect(fixedMin).toBeCloseTo(-1, 6); + }); +}); From 7b5810753567536e3672adc5e4b88ec95564ed9e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 7 Oct 2026 04:14:51 +0300 Subject: [PATCH 3/5] [feat] UV editor re-reads unwrap backends on menu open + Smart unwrap e2e (37 R11) - a module installed while the UV editor is mounted (smart-unwrap) now appears in the Unwrap menus without reopening the editor - e2e uv-smart-unwrap installs the modules repo's smart-unwrap.zip (MODULES_REPO) and checks range/degenerate/overlap/undo/peer on box + sphere Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/UvEditor.svelte | 11 +- tests/e2e/uv-smart-unwrap.test.cjs | 137 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/uv-smart-unwrap.test.cjs diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index 392f48d4..74c5dada 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -1293,7 +1293,7 @@ tooltip: pickedTris ? 'Applies to the ' + pickedTris + ' face triangles selected in Edit Mesh' : 'Applies to the whole mesh', - children: backends.map((backend) => ({ + children: unwrapBackends().map((backend) => ({ label: backend.label, action: () => runUnwrap(backend.key) })) @@ -1431,7 +1431,9 @@ } let unwrapOpen = $state(false); - const backends = unwrapBackends(); + // re-read whenever a menu opens: a module (37 R11's Smart unwrap) can register a backend + // while this editor is already mounted, and a list read once at mount never showed it + let backends = $state(unwrapBackends()); /** @param {string} key */ async function runUnwrap(key) { @@ -1732,7 +1734,10 @@ ? 'Generate new UVs for this mesh, or just the faces selected in Edit Mesh' : editable.reason} disabled={!editable.ok} - onclick={() => (unwrapOpen = !unwrapOpen)} + onclick={() => { + if (!unwrapOpen) backends = unwrapBackends(); + unwrapOpen = !unwrapOpen; + }} >Unwrap ▾ {#if unwrapOpen}
diff --git a/tests/e2e/uv-smart-unwrap.test.cjs b/tests/e2e/uv-smart-unwrap.test.cjs new file mode 100644 index 00000000..9053eb65 --- /dev/null +++ b/tests/e2e/uv-smart-unwrap.test.cjs @@ -0,0 +1,137 @@ +// 37 R11: the xatlas "Smart unwrap" MODULE, installed from its real zip, running in the app. +// +// `uv-unwrap-module` proved the seam with a 41-byte wasm; this is the real thing: the +// `smart-unwrap` module (modules repo, xatlas-wasm inside the zip) registers +// `mod-smart-unwrap-xatlas`, the UV editor's registry lists it, and an unwrap through it +// gives a mapping a texture can use — every uv inside 0..1, no degenerate triangle, islands +// that do not overlap — as ONE undo step that replicates like any other unwrap. +// Skips (never fails) when the modules checkout has no packed zip: MODULES_REPO=/. +const h = require('./helpers.cjs'); + +const KEY = 'mod-smart-unwrap-xatlas'; + +/** uv facts of an object: range, degenerate triangles, and an overlap count on a raster */ +const uvFacts = (page, uuid) => + page.evaluate((uuid) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const object = g.getObjectByProperty('uuid', uuid); + const uv = object?.geometry?.attributes?.uv; + if (!uv) return null; + const position = object.geometry.attributes.position; + const count = object.geometry.index ? object.geometry.index.count : position.count; + const at = (i) => (object.geometry.index ? object.geometry.index.getX(i) : i); + let outside = 0; + let degenerate = 0; + const N = 256; + const grid = new Uint16Array(N * N); + let overlap = 0; + const distinct = new Set(); + for (let t = 0; t + 2 < count; t += 3) { + const p = [0, 1, 2].map((k) => [uv.getX(at(t + k)), uv.getY(at(t + k))]); + for (const [u, v] of p) { + if (u < -1e-4 || u > 1 + 1e-4 || v < -1e-4 || v > 1 + 1e-4) outside++; + distinct.add(u.toFixed(3) + ',' + v.toFixed(3)); + } + const area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]); + if (Math.abs(area) < 1e-9) { + degenerate++; + continue; + } + // rasterise the triangle's INTERIOR (cell centres strictly inside): a cell claimed by + // two triangles is an overlap; shared edges are excluded by the strict test + const minU = Math.max(0, Math.floor(Math.min(p[0][0], p[1][0], p[2][0]) * N)); + const maxU = Math.min(N - 1, Math.ceil(Math.max(p[0][0], p[1][0], p[2][0]) * N)); + const minV = Math.max(0, Math.floor(Math.min(p[0][1], p[1][1], p[2][1]) * N)); + const maxV = Math.min(N - 1, Math.ceil(Math.max(p[0][1], p[1][1], p[2][1]) * N)); + for (let y = minV; y <= maxV; y++) + for (let x = minU; x <= maxU; x++) { + const cx = (x + 0.5) / N; + const cy = (y + 0.5) / N; + const e = (a, b) => (b[0] - a[0]) * (cy - a[1]) - (b[1] - a[1]) * (cx - a[0]); + const w0 = e(p[0], p[1]); + const w1 = e(p[1], p[2]); + const w2 = e(p[2], p[0]); + const inside = (w0 > 1e-9 && w1 > 1e-9 && w2 > 1e-9) || (w0 < -1e-9 && w1 < -1e-9 && w2 < -1e-9); + if (!inside) continue; + if (grid[y * N + x]++) overlap++; + } + } + return { triangles: count / 3, outside, degenerate, overlap, distinct: distinct.size }; + }, uuid); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + const installed = await h.installModule(A, 'smart-unwrap'); + if (!installed) { + console.log('SKIP: no smart-unwrap.zip under MODULES_REPO — pack it in the modules checkout'); + await h.finish(browser); + return; + } + await h.eventually( + () => A.page.evaluate(() => window.__stores.uvUnwrap.unwrapBackends().map((b) => b.key + '=' + b.label)), + (list) => list.includes(KEY + '=Smart (xatlas)'), + 'the module registered "Smart (xatlas)" in the unwrap registry', + 20000 + ); + + for (const [what, command] of [ + ['a box', '/create Box 1 1 1'], + ['a sphere', '/create Sphere 1 24 16'] + ]) { + const uuid = await A.page.evaluate((command) => { + const s = window.__stores; + s.commandsHandler.sceneCommand(command); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + return g.children[g.children.length - 1].uuid; + }, command); + const depth = () => + A.page.evaluate(() => { + let st; + window.__stores.history.undoStack.subscribe((v) => (st = v))(); + return st.length; + }); + const before = await uvFacts(A.page, uuid); + const d0 = await depth(); + const ok = await A.page.evaluate( + ({ uuid, KEY }) => window.__stores.uvEditor.unwrapObject(uuid, KEY, { margin: 0.02 }), + { uuid, KEY } + ); + h.check(ok === true, `${what}: the Smart unwrap ran and committed`); + const after = await uvFacts(A.page, uuid); + h.check(!!after && after.outside === 0, `${what}: every uv inside 0..1 (${after?.outside} outside)`); + h.check(after.degenerate === 0, `${what}: no triangle collapsed in uv space (${after.degenerate})`); + h.check(after.overlap === 0, `${what}: islands do not overlap on a 256² raster (${after.overlap} shared cells)`); + h.check( + after.distinct > (before?.distinct ?? 0) / 2 && after.distinct > 8, + `${what}: a real spread of coordinates, not a collapsed map (${after.distinct} distinct)` + ); + h.check((await depth()) - d0 === 1, `${what}: ONE undo entry`); + await h.eventually( + () => uvFacts(B.page, uuid), + (f) => !!f && f.distinct === after.distinct && f.overlap === 0, + `${what}: peer B holds the same unwrap`, + 20000 + ); + await A.page.evaluate(() => window.__stores.history.undo()); + const undone = await uvFacts(A.page, uuid); + h.check( + undone.distinct === (before?.distinct ?? undone.distinct), + `${what}: one undo restores the previous mapping (${after.distinct} -> ${undone.distinct} distinct)` + ); + } + + // the UV editor's menu lists it even though the module arrived after boot + const listed = await A.page.evaluate(() => { + const s = window.__stores; + s.uvEditorClose?.set?.(false); + return s.uvUnwrap.unwrapBackends().some((b) => b.key === 'mod-smart-unwrap-xatlas'); + }); + h.check(listed, 'the registry the UV editor reads lists Smart (xatlas)'); + await h.finish(browser); +}); From 180dff1d7a9504812e5b520e66f2afca865220aa Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 7 Oct 2026 08:07:11 +0300 Subject: [PATCH 4/5] [fix] knife Enter/Backspace reach a pending cut; live-symmetry + knife suite premises (37 R11) - knifeKeyConsumed no longer defers to defaultPrevented: the shortcut registry's viewport Backspace row (objects.delete-backspace) preventDefaults before its action stands down in a mesh session, so Backspace never dropped a corner (found by e2e r1: 3 points stayed 3) - mesh-symmetry-live: picking cycles selectHandle (each a 'selection' undo entry), so the drag undo check now walks to a mark and counts geometry entries (2 = one per drag) instead of "two undos" - mesh-knife: polyline points projected from the box face, with an elementFromPoint check (fixed pixel offsets landed under the toolbox) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/faceEdit.js | 9 ++++- tests/e2e/mesh-knife.test.cjs | 50 ++++++++++++++++++++++----- tests/e2e/mesh-symmetry-live.test.cjs | 28 ++++++++++++--- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 44187d4c..ddd6a9ff 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -4335,13 +4335,20 @@ export function knifeDropCorner() { * @param {KeyboardEvent} event @returns {boolean} consumed */ export function knifeKeyConsumed(event) { - if (event.defaultPrevented || !get(knifePreview)) return false; + // NOT gated on defaultPrevented (unlike Escape): the shortcut registry's viewport + // Backspace row (objects.delete-backspace) preventDefaults before its action stands down + // in a mesh session, so the flag says nothing about whether the cut was answered. One + // listener calls this, so a private mark is enough to stop a second answer. + const marked = /** @type {any} */ (event); + if (marked.__knifeKey || !get(knifePreview)) return false; if (event.key === 'Enter') { + marked.__knifeKey = true; event.preventDefault(); if (!knifeFinish()) showToast('Knife: place at least two points first'); return true; } if (event.key === 'Backspace') { + marked.__knifeKey = true; event.preventDefault(); knifeDropCorner(); return true; diff --git a/tests/e2e/mesh-knife.test.cjs b/tests/e2e/mesh-knife.test.cjs index 51d2c48c..6252f5b5 100644 --- a/tests/e2e/mesh-knife.test.cjs +++ b/tests/e2e/mesh-knife.test.cjs @@ -233,7 +233,7 @@ h.run(async () => { const c1 = P(-260, -20); const c2 = P(-20, -25); // inside the front face: becomes a vertex const c3 = P(15, 60); // inside too - const stray = P(200, 200); + const stray = P(150, 120); const c4 = P(260, 70); await A.page.mouse.click(c1[0], c1[1]); await A.page.keyboard.down('Shift'); @@ -252,7 +252,11 @@ h.run(async () => { tris: window.__stores.faceEdit.readTriangles(window.__box.geometry).length }; }); - h.check(placed.points === 4, `Shift+click placed corners (${placed.points} points pending)`); + const underStray = await A.page.evaluate(([x, y]) => { + const el = document.elementFromPoint(x, y); + return el ? el.tagName + '#' + (el.id || '') + '.' + String(el.className || '').slice(0, 40) : 'nothing'; + }, stray); + h.check(placed.points === 4, `Shift+click placed corners (${placed.points} points pending; the stray click landed on ${underStray})`); h.check(placed.polyline && placed.corners >= 4, '...drawn as a polyline with a dot per corner'); h.check(placed.tris === 12, '...and nothing is cut yet'); await A.page.keyboard.press('Backspace'); @@ -317,33 +321,61 @@ h.run(async () => { window.__stores.faceEdit.setFaceOp('knife'); return window.__stores.faceEdit.readTriangles(window.__box.geometry).length; }); - await A.page.mouse.click(...P(-260, 90)); + // points projected FROM the box's front face (the toolbox can sit over a fixed pixel offset) + const facePx = (pts) => + A.page.evaluate((pts) => { + const s = window.__stores; + let camera; + s.globalCamera.subscribe((c) => (camera = c))(); + let renderer; + s.globalRenderer.subscribe((r) => (renderer = r))(); + const rect = renderer.domElement.getBoundingClientRect(); + return pts.map(([x, y]) => { + const p = new s.THREE.Vector3(x, y, 1).applyMatrix4(window.__box.matrixWorld).project(camera); + const px = [Math.round(rect.left + ((p.x + 1) / 2) * rect.width), Math.round(rect.top + ((1 - p.y) / 2) * rect.height)]; + const el = document.elementFromPoint(px[0], px[1]); + return { px, canvas: el === renderer.domElement, under: el ? el.tagName + '#' + (el.id || '') : 'nothing' }; + }); + }, pts); + const enterPts = await facePx([[-1.6, 0.6], [0, 0.65], [1.6, 0.7]]); + h.check(enterPts.every((p) => p.canvas), `the Enter-section points are on the canvas (${enterPts.map((p) => p.under).join(', ')})`); + await A.page.mouse.click(...enterPts[0].px); await A.page.keyboard.down('Shift'); - await A.page.mouse.click(...P(0, 95)); - await A.page.mouse.click(...P(260, 100)); + await A.page.mouse.click(...enterPts[1].px); + await A.page.mouse.click(...enterPts[2].px); await A.page.keyboard.up('Shift'); await A.page.keyboard.press('Enter'); await A.page.waitForTimeout(250); const enterCut = await A.page.evaluate(() => { let preview; window.__stores.faceEdit.knifePreview.subscribe((v) => (preview = v))(); - return { tris: window.__stores.faceEdit.readTriangles(window.__box.geometry).length, pending: !!preview }; + return { + tris: window.__stores.faceEdit.readTriangles(window.__box.geometry).length, + pending: preview ? preview.points.length : 0, + focus: document.activeElement?.tagName + '#' + (document.activeElement?.id || '') + }; }); - h.check(enterCut.tris > viaEnter && !enterCut.pending, `Enter ends the cut at the last corner (${viaEnter} -> ${enterCut.tris})`); + h.check( + enterCut.tris > viaEnter && !enterCut.pending, + `Enter ends the cut at the last corner (${viaEnter} -> ${enterCut.tris}, ${enterCut.pending} still pending, focus ${enterCut.focus})` + ); const buttonCut0 = await A.page.evaluate(() => { window.__stores.faceEdit.setFaceOp('knife'); window.__stores.meshToolParams.focusTool('knife'); return window.__stores.faceEdit.readTriangles(window.__box.geometry).length; }); - await A.page.mouse.click(...P(-260, -80)); + const cutPts = await facePx([[-1.6, -0.5], [1.6, -0.45]]); + h.check(cutPts.every((p) => p.canvas), `the Cut-section points are on the canvas (${cutPts.map((p) => p.under).join(', ')})`); + await A.page.mouse.click(...cutPts[0].px); await A.page.keyboard.down('Shift'); - await A.page.mouse.click(...P(260, -70)); + await A.page.mouse.click(...cutPts[1].px); await A.page.keyboard.up('Shift'); await A.page.waitForTimeout(150); const cutButton = A.page.locator('#knife-finish'); const buttonShown = (await cutButton.count()) > 0; h.check(buttonShown, 'the Tool options pane offers a Cut button while a cut is pending (the touch path)'); if (buttonShown) { + await cutButton.scrollIntoViewIfNeeded().catch(() => {}); await cutButton.click(); await A.page.waitForTimeout(250); const buttonCut = await A.page.evaluate(() => window.__stores.faceEdit.readTriangles(window.__box.geometry).length); diff --git a/tests/e2e/mesh-symmetry-live.test.cjs b/tests/e2e/mesh-symmetry-live.test.cjs index 03afb497..9c39a5d5 100644 --- a/tests/e2e/mesh-symmetry-live.test.cjs +++ b/tests/e2e/mesh-symmetry-live.test.cjs @@ -289,6 +289,13 @@ h.run(async () => { }, point); const corner = await selectNearLocal([-1, 1, 1]); h.check(corner.best >= 0 && corner.bestDistance < 1e-3, `picked the (-1, 1, 1) corner (premise, ${corner.bestDistance.toFixed(4)})`); + // mark the stack BEFORE the first drag: picking cycles selectHandle, and every pick is a + // 'selection' undo entry, so "two undos" would only walk picks back + await A.page.evaluate(() => { + let st; + window.__stores.history.undoStack.subscribe((v) => (st = v))(); + window.__mark = st[st.length - 1]; + }); const dragged = await A.page.evaluate(() => { const s = window.__stores; const me = s.meshEdit; @@ -351,12 +358,25 @@ h.run(async () => { return { onPlaneAtNewHeight, offPlane }; }); h.check(pinned.onPlaneAtNewHeight && !pinned.offPlane, 'a vertex ON the plane slides along it, never off it'); - await A.page.evaluate(() => { - window.__stores.history.undo(); - window.__stores.history.undo(); + const geometryUndos = await A.page.evaluate(() => { + const s = window.__stores; + const top = () => { + let st; + s.history.undoStack.subscribe((v) => (st = v))(); + return st[st.length - 1]; + }; + let geometry = 0; + for (let i = 0; i < 2000 && top() && top() !== window.__mark; i++) { + if (top().kind !== 'selection') geometry++; + s.history.undo(); + } + return geometry; }); const vundone = await facts(A.page, vbox); - h.check(vundone.maxX === 1 && vundone.minX === -1 && vundone.lonely === 0, `two undos take both drags back, twins included (${vundone.minX} .. ${vundone.maxX})`); + h.check( + geometryUndos === 2 && vundone.maxX === 1 && vundone.minX === -1 && vundone.lonely === 0, + `each drag is ONE geometry entry (${geometryUndos}), and undoing both takes the twins back too (${vundone.minX} .. ${vundone.maxX})` + ); await A.page.evaluate(() => { window.__stores.meshEdit.exitEditMode?.(); From a094147719506c667c7c5267218c6dfda37bec49 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 7 Oct 2026 11:09:10 +0300 Subject: [PATCH 5/5] [test] mesh-knife: Cut-button section aims left of the floating toolbox - the right-hand point landed on the toolbox (elementFromPoint said

); both points now sit on the left half of the face, a pending cut is cleared before the peer section, and an unclickable Cut button reports its rect - mesh-knife 41/0 (two peers) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/mesh-knife.test.cjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/e2e/mesh-knife.test.cjs b/tests/e2e/mesh-knife.test.cjs index 6252f5b5..1532b4f4 100644 --- a/tests/e2e/mesh-knife.test.cjs +++ b/tests/e2e/mesh-knife.test.cjs @@ -364,7 +364,7 @@ h.run(async () => { window.__stores.meshToolParams.focusTool('knife'); return window.__stores.faceEdit.readTriangles(window.__box.geometry).length; }); - const cutPts = await facePx([[-1.6, -0.5], [1.6, -0.45]]); + const cutPts = await facePx([[-1.2, 0.25], [0.15, 0.3]]); // the toolbox floats over the right h.check(cutPts.every((p) => p.canvas), `the Cut-section points are on the canvas (${cutPts.map((p) => p.under).join(', ')})`); await A.page.mouse.click(...cutPts[0].px); await A.page.keyboard.down('Shift'); @@ -374,13 +374,27 @@ h.run(async () => { const cutButton = A.page.locator('#knife-finish'); const buttonShown = (await cutButton.count()) > 0; h.check(buttonShown, 'the Tool options pane offers a Cut button while a cut is pending (the touch path)'); + const where = await A.page.evaluate(() => { + const b = document.querySelector('#knife-finish'); + const r = b?.getBoundingClientRect(); + const hidden = []; + for (let el = b; el; el = el.parentElement) { + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden') hidden.push(el.tagName + '#' + el.id + '.' + String(el.className).slice(0, 30)); + } + return { rect: r ? [r.x, r.y, r.width, r.height].map(Math.round) : null, hidden, disabled: b?.disabled, vw: innerWidth, vh: innerHeight }; + }); if (buttonShown) { - await cutButton.scrollIntoViewIfNeeded().catch(() => {}); - await cutButton.click(); + await cutButton.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); + await cutButton.click({ timeout: 5000 }).catch(async () => { + h.check(false, `the Cut button could not be clicked (${JSON.stringify(where)})`); + await A.page.evaluate(() => window.__stores.faceEdit.knifeFinish()); + }); await A.page.waitForTimeout(250); const buttonCut = await A.page.evaluate(() => window.__stores.faceEdit.readTriangles(window.__box.geometry).length); h.check(buttonCut > buttonCut0, `...and pressing it cuts (${buttonCut0} -> ${buttonCut})`); } + await A.page.evaluate(() => window.__stores.faceEdit.cancelKnife()); const finalOdd = await oddEdges(A.page); h.check(finalOdd === 0, `still watertight after three polyline cuts (${finalOdd} odd edges)`);