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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 8 additions & 20 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/components/editors/UvEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}))
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ▾</button>
{#if unwrapOpen}
<div id="uv-unwrap-menu" class="absolute left-0 top-full z-30 mt-1 w-44 rounded-sm border border-gray-600 bg-gray-800 py-1 shadow-lg">
Expand Down
14 changes: 13 additions & 1 deletion src/components/menu/KnifeOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@

{#if $knifePreview}
<svg class="knife-overlay" width="100%" height="100%" aria-hidden="true">
<!-- 37 R11: the corners placed so far (a polyline cut), then the live segment -->
{#if $knifePreview.points.length > 1}
<polyline
class="knife-placed"
points={$knifePreview.points.map((p) => p[0] + ',' + p[1]).join(' ')}
fill="none"
stroke="#ff7a1a"
stroke-width="1.5"
/>
{/if}
<line
x1={$knifePreview.from[0]}
y1={$knifePreview.from[1]}
Expand All @@ -21,7 +31,9 @@
stroke-width="1.5"
stroke-dasharray="6 4"
/>
<circle cx={$knifePreview.from[0]} cy={$knifePreview.from[1]} r="3.5" fill="#ff7a1a" />
{#each $knifePreview.points as point, i (i)}
<circle cx={point[0]} cy={point[1]} r="3.5" fill="#ff7a1a" />
{/each}
<circle
cx={$knifePreview.to[0]}
cy={$knifePreview.to[1]}
Expand Down
25 changes: 24 additions & 1 deletion src/components/menu/MeshEditPopup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
mergeDistance,
symAxis,
symKeep,
liveSymmetry,
optionsFocus,
focusTool,
hasOptions,
Expand Down Expand Up @@ -238,7 +239,7 @@
{ op: 'move', label: 'Move', hint: 'G', oneShot: false, param: false, icon: 'move', desc: 'seat the gizmo on the selection and drag it' },
{ op: 'extrude', label: 'Extrude', hint: 'E', oneShot: false, param: false, icon: 'extrude', desc: 'pull the face out along its normal' },
{ op: 'inset', label: 'Inset', hint: 'I', oneShot: false, param: false, icon: 'inset', desc: 'shrink a copy inside a stitched ring' },
{ op: 'knife', label: 'Knife', hint: 'K', oneShot: false, param: false, icon: 'knife', desc: 'cut across the mesh: click one end of the line, then the other' }
{ op: 'knife', label: 'Knife', hint: 'K', oneShot: false, param: false, icon: 'knife', desc: 'cut across the mesh: click one end of the line, then the other (Shift+click adds corners)' }
];
const ACTION_OPS = [
{ op: 'bevel', label: 'Bevel', hint: '', oneShot: true, param: true, icon: 'bevel', desc: "chamfer the selected face's border" },
Expand Down Expand Up @@ -1447,6 +1448,28 @@
})}>Symmetrize</button
>
</div>
<!-- 37 R11: LIVE symmetry — every edit mirrored, vertex drags move their twin -->
<label
class="tbx-row flex items-center gap-1 text-xs text-gray-300"
title="While on, every edit is mirrored across the axis above, from the side you edited — and dragging a vertex moves its mirror twin with it. Symmetrize first if the mesh is not symmetric yet."
>
<input
id="mesh-sym-live"
class="tbx-check"
type="checkbox"
checked={$liveSymmetry}
onchange={(e) => {
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
</label>
</ToolboxSection>

<ToolboxSection key="display" label="Display" open={true} id="mesh-sec-display">
Expand Down
27 changes: 25 additions & 2 deletions src/components/menu/MeshToolOptions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -693,6 +696,26 @@
{:else if focus === 'knife'}
<span class="tbx-label">Knife options</span>
<div class="tbx-row text-xs text-gray-400">
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.
</div>
<!-- 37 R11: touch has no Enter/Backspace — the same two actions as buttons -->
{#if $knifePreview}
<div id="knife-pending" class="tbx-row text-xs text-gray-300">
<span>{$knifePreview.points.length} {$knifePreview.points.length === 1 ? 'point' : 'points'}</span>
<button
id="knife-undo-corner"
class="tbx-cmd"
title="Take the last point back (Backspace)"
onclick={() => knifeDropCorner()}>Back</button
>
<button
id="knife-finish"
class="tbx-primary"
disabled={$knifePreview.points.length < 2}
title="Cut through the points placed so far (Enter)"
onclick={() => knifeFinish()}>Cut</button
>
</div>
{/if}
{/if}
Loading
Loading