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/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/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/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}
+
+ {#if $knifePreview.points.length > 1}
+ p[0] + ',' + p[1]).join(' ')}
+ fill="none"
+ stroke="#ff7a1a"
+ stroke-width="1.5"
+ />
+ {/if}
-
+ {#each $knifePreview.points as point, i (i)}
+
+ {/each}
Symmetrize
+
+
+ {
+ liveSymmetry.set(e.currentTarget.checked);
+ if (e.currentTarget.checked)
+ showToast(
+ 'Live symmetry on: edits are mirrored across ' +
+ $symAxis.toUpperCase() +
+ '. Symmetrize first if the mesh is not symmetric yet.'
+ );
+ }}
+ />
+ live symmetry
+
diff --git a/src/components/menu/MeshToolOptions.svelte b/src/components/menu/MeshToolOptions.svelte
index 6b358e48..093a6f2d 100644
--- a/src/components/menu/MeshToolOptions.svelte
+++ b/src/components/menu/MeshToolOptions.svelte
@@ -22,7 +22,10 @@
import {
faceEditAmount,
faceAutoApply,
- opAdjustState
+ opAdjustState,
+ knifePreview,
+ knifeFinish,
+ knifeDropCorner
} from '$lib/faceEdit';
import { proportionalRadius } from '$lib/meshEdit';
// 19-A P4: scrubbing the radius previews the falloff RING at the current
@@ -693,6 +696,26 @@
{:else if focus === 'knife'}
Knife options
- 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'}
+ knifeDropCorner()}>Back
+ knifeFinish()}>Cut
+
+ {/if}
{/if}
diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js
index ba7c1979..ddd6a9ff 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,
@@ -4235,13 +4242,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 +4273,284 @@ 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) {
+ // 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;
}
- 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 +4586,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 +4636,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'));
@@ -4440,13 +4782,18 @@ export function knifeCut(from, to) {
};
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,
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;
}
@@ -4507,14 +4854,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) => {
@@ -4522,18 +4873,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)
@@ -4544,6 +4883,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) => {
@@ -4555,9 +4896,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) {
@@ -4599,30 +4941,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) {
@@ -4640,13 +4967,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',
@@ -4658,14 +5036,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.
@@ -4815,7 +5379,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 },
@@ -4891,7 +5455,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 },
@@ -5070,7 +5634,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,
@@ -6101,7 +6665,8 @@ function commitVertexOp(uuid, object, inputTris, tris, faces, beforeFaces) {
groups: trisToGroups(tris),
uvs: trisToUVs(tris),
faces
- }
+ },
+ true
);
}
@@ -6206,7 +6771,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)' : '')
);
@@ -6437,7 +7002,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 },
@@ -6526,7 +7091,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 },
@@ -6591,7 +7156,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;
}
@@ -6639,7 +7204,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;
}
@@ -7079,6 +7644,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
@@ -7419,7 +7985,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 },
@@ -7506,7 +8072,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,
@@ -7596,16 +8162,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;
}
@@ -7672,7 +8239,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,
@@ -8098,7 +8665,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,
@@ -8967,7 +9534,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;
@@ -9025,7 +9592,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-knife.test.cjs b/tests/e2e/mesh-knife.test.cjs
index fec0168f..1532b4f4 100644
--- a/tests/e2e/mesh-knife.test.cjs
+++ b/tests/e2e/mesh-knife.test.cjs
@@ -213,6 +213,191 @@ 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(150, 120);
+ 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
+ };
+ });
+ 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');
+ 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;
+ });
+ // 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(...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 ? 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}, ${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;
+ });
+ 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');
+ 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)');
+ 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({ 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)`);
+
// --- the refusals -------------------------------------------------------
const refusals = await A.page.evaluate(() => {
const s = window.__stores;
diff --git a/tests/e2e/mesh-symmetry-live.test.cjs b/tests/e2e/mesh-symmetry-live.test.cjs
new file mode 100644
index 00000000..9c39a5d5
--- /dev/null
+++ b/tests/e2e/mesh-symmetry-live.test.cjs
@@ -0,0 +1,386 @@
+// 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)})`);
+ // 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;
+ 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');
+ 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(
+ 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?.();
+ window.__stores.meshToolParams.liveSymmetry.set(false);
+ });
+ await h.finish(browser);
+});
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);
+});
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);
+ });
+});
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);
+ });
+});