Phase 5 Stage D moves: live-drag mesh.position for slab / ceiling / fence
User-reported regressions on the 1:1 legacy ports: slab/ceiling moves were slow (polygon CSG rebuilds per scene.update tick), fence moves teleported briefly on commit (residual mesh.position offset survived the geometry rebuild). All three now use the same live-drag pattern the legacy fence move was designed for: - During drag, write only to `sceneRegistry.nodes.get(id).position` + `useLiveTransforms`. No `scene.update`, no polygon rebuild, no React re-render of geometry. - History stays UNPAUSED — scene state isn't changing. - On commit, a single `scene.update` writes the translated polygon (or fence start/end + linked-fence cascade). Recorded as one natural undo step. - Tools leave `mesh.position` at the drag delta on commit; GeometrySystem / CeilingSystem reset it to (0,0,0) when they rebuild the geometry on the next frame. By the time position clears, the new geometry is in place — no teleport. Two framework changes enable this: - `GeometrySystem` (viewer/systems/geometry) now resets `group.position` + `group.rotation` after every rebuild, matching the legacy `FenceSystem.updateFenceGeometry` behavior. Tools that translate the group during live-drag can rely on the reset. - Legacy `CeilingSystem.updateCeilingGeometry` extends its existing `position.y` reset to cover X/Z too — previously it left X/Z at the drag delta after rebuild, double-translating the visual. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1e15e10185
commit
f4ea07e05b
@@ -5,19 +5,27 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
import type * as THREE from 'three'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 5 Stage D — ceiling whole-move tool (kind-owned).
|
* Phase 5 Stage D — ceiling whole-move tool.
|
||||||
*
|
*
|
||||||
* 1:1 port of the legacy `MoveCeilingTool`. 0.5m grid snap, scene.update
|
* Live-drag pattern: translate the ceiling MESH visually via
|
||||||
* per tick, history dance on commit, preview fill + outline overlay
|
* `sceneRegistry.nodes.get(id).position` + a mirror in
|
||||||
* matching the legacy.
|
* `useLiveTransforms`. No `scene.update` during the drag — polygon CSG
|
||||||
|
* with holes isn't rebuilt per tick. On commit we write the translated
|
||||||
|
* polygon to the scene once; the legacy `CeilingSystem` resets the
|
||||||
|
* mesh's X/Z position on rebuild (`mesh.position.x = 0`,
|
||||||
|
* `mesh.position.z = 0`) so the visual transitions smoothly.
|
||||||
|
*
|
||||||
|
* 0.5m grid snap (matches legacy).
|
||||||
*/
|
*/
|
||||||
function snap(value: number) {
|
function snap(value: number) {
|
||||||
return Math.round(value * 2) / 2
|
return Math.round(value * 2) / 2
|
||||||
@@ -42,27 +50,29 @@ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
|||||||
return [sumX / polygon.length, sumZ / polygon.length]
|
return [sumX / polygon.length, sumZ / polygon.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: number): void {
|
||||||
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||||
|
// CeilingSystem positions the mesh at height−0.01 on rebuild; we
|
||||||
|
// preserve the Y while offsetting X/Z during the drag.
|
||||||
|
if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ)
|
||||||
|
}
|
||||||
|
|
||||||
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||||
const activatedAtRef = useRef<number>(Date.now())
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||||
const originalHolesRef = useRef(
|
const originalHolesRef = useRef(
|
||||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||||
)
|
)
|
||||||
|
const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current))
|
||||||
|
const heightRef = useRef(node.height ?? 2.5)
|
||||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
|
const deltaRef = useRef<[number, number]>([0, 0])
|
||||||
const previousDeltaRef = useRef<[number, number] | null>(null)
|
|
||||||
const previewRef = useRef<{
|
|
||||||
polygon: Array<[number, number]>
|
|
||||||
holes: Array<Array<[number, number]>>
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
const center = getPolygonCenter(node.polygon)
|
const c = originalCenterRef.current
|
||||||
return [center[0], node.height ?? 2.5, center[1]]
|
return [c[0], heightRef.current, c[1]]
|
||||||
})
|
})
|
||||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
|
||||||
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
@@ -71,40 +81,30 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const originalPolygon = originalPolygonRef.current
|
const originalPolygon = originalPolygonRef.current
|
||||||
const originalHoles = originalHolesRef.current
|
const originalHoles = originalHolesRef.current
|
||||||
|
const originalCenter = originalCenterRef.current
|
||||||
|
const height = heightRef.current
|
||||||
|
const ceilingId = node.id
|
||||||
|
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
|
|
||||||
const applyPreview = (
|
const applyPreview = (deltaX: number, deltaZ: number) => {
|
||||||
polygon: Array<[number, number]>,
|
deltaRef.current = [deltaX, deltaZ]
|
||||||
holes: Array<Array<[number, number]>>,
|
setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height)
|
||||||
) => {
|
useLiveTransforms.getState().set(ceilingId, {
|
||||||
previewRef.current = { polygon, holes }
|
position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ],
|
||||||
setPreviewPolygon(polygon)
|
rotation: 0,
|
||||||
setPreviewHoles(holes)
|
})
|
||||||
const center = getPolygonCenter(polygon)
|
setCursorLocalPos([originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ])
|
||||||
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
|
|
||||||
if (
|
|
||||||
!previousCursorPosRef.current ||
|
|
||||||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
|
|
||||||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
|
|
||||||
previousCursorPosRef.current[2] !== nextCursorPos[2]
|
|
||||||
) {
|
|
||||||
previousCursorPosRef.current = nextCursorPos
|
|
||||||
setCursorLocalPos(nextCursorPos)
|
|
||||||
}
|
|
||||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = () => {
|
const clearPreview = () => {
|
||||||
setPreviewPolygon(originalPolygon)
|
const mesh = sceneRegistry.nodes.get(ceilingId as AnyNodeId) as THREE.Object3D | undefined
|
||||||
setPreviewHoles(originalHoles)
|
if (mesh) {
|
||||||
useScene.getState().updateNode(node.id, {
|
mesh.position.x = 0
|
||||||
holes: originalHoles,
|
mesh.position.z = 0
|
||||||
polygon: originalPolygon,
|
// Leave Y at whatever the CeilingSystem set it to.
|
||||||
})
|
}
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
useLiveTransforms.getState().clear(ceilingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -122,22 +122,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||||
dragAnchorRef.current = anchor
|
dragAnchorRef.current = anchor
|
||||||
|
|
||||||
const deltaX = localX - anchor[0]
|
applyPreview(localX - anchor[0], localZ - anchor[1])
|
||||||
const deltaZ = localZ - anchor[1]
|
|
||||||
|
|
||||||
if (
|
|
||||||
previousDeltaRef.current &&
|
|
||||||
previousDeltaRef.current[0] === deltaX &&
|
|
||||||
previousDeltaRef.current[1] === deltaZ
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
previousDeltaRef.current = [deltaX, deltaZ]
|
|
||||||
|
|
||||||
applyPreview(
|
|
||||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
|
||||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
@@ -146,32 +131,27 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
const [deltaX, deltaZ] = deltaRef.current
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
|
|
||||||
// Restore original baseline while paused so the next resume+update
|
if (deltaX !== 0 || deltaZ !== 0) {
|
||||||
// registers as a single tracked change (undo reverts to original).
|
useScene.getState().updateNode(ceilingId, {
|
||||||
useScene.getState().updateNode(node.id, {
|
polygon: translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||||
polygon: originalPolygon,
|
holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
|
||||||
holes: originalHoles,
|
})
|
||||||
})
|
useScene.getState().markDirty(ceilingId as AnyNodeId)
|
||||||
|
}
|
||||||
useScene.temporal.getState().resume()
|
useLiveTransforms.getState().clear(ceilingId)
|
||||||
useScene.getState().updateNode(node.id, preview)
|
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
restoreOriginal()
|
clearPreview()
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
@@ -182,89 +162,21 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
restoreOriginal()
|
clearPreview()
|
||||||
|
} else {
|
||||||
|
useLiveTransforms.getState().clear(ceilingId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node.height, node.id])
|
}, [exitMoveMode, node.id])
|
||||||
|
|
||||||
const previewFillGeometry = useMemo(
|
|
||||||
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
|
||||||
[previewHoles, previewPolygon],
|
|
||||||
)
|
|
||||||
|
|
||||||
const previewOutlineGeometry = useMemo(
|
|
||||||
() => createCeilingOutlineGeometry(previewPolygon),
|
|
||||||
[previewPolygon],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#f5f5f4"
|
|
||||||
depthWrite={false}
|
|
||||||
opacity={0.3}
|
|
||||||
side={DoubleSide}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
|
||||||
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
|
||||||
</line>
|
|
||||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCeilingPreviewGeometry(
|
|
||||||
polygon: Array<[number, number]>,
|
|
||||||
holes: Array<Array<[number, number]>>,
|
|
||||||
): BufferGeometry {
|
|
||||||
if (polygon.length < 3) return new BufferGeometry()
|
|
||||||
|
|
||||||
const shape = new Shape()
|
|
||||||
const [firstX, firstZ] = polygon[0]!
|
|
||||||
shape.moveTo(firstX, -firstZ)
|
|
||||||
|
|
||||||
for (let i = 1; i < polygon.length; i++) {
|
|
||||||
const [x, z] = polygon[i]!
|
|
||||||
shape.lineTo(x, -z)
|
|
||||||
}
|
|
||||||
shape.closePath()
|
|
||||||
|
|
||||||
for (const holePolygon of holes) {
|
|
||||||
if (holePolygon.length < 3) continue
|
|
||||||
const hole = new Path()
|
|
||||||
const [hx, hz] = holePolygon[0]!
|
|
||||||
hole.moveTo(hx, -hz)
|
|
||||||
for (let i = 1; i < holePolygon.length; i++) {
|
|
||||||
const [x, z] = holePolygon[i]!
|
|
||||||
hole.lineTo(x, -z)
|
|
||||||
}
|
|
||||||
hole.closePath()
|
|
||||||
shape.holes.push(hole)
|
|
||||||
}
|
|
||||||
|
|
||||||
const geometry = new ShapeGeometry(shape)
|
|
||||||
geometry.rotateX(-Math.PI / 2)
|
|
||||||
geometry.computeVertexNormals()
|
|
||||||
return geometry
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
|
|
||||||
const geometry = new BufferGeometry()
|
|
||||||
if (polygon.length < 2) return geometry
|
|
||||||
|
|
||||||
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
|
|
||||||
const [firstX, firstZ] = polygon[0]!
|
|
||||||
points.push(new Vector3(firstX, 0, firstZ))
|
|
||||||
geometry.setFromPoints(points)
|
|
||||||
return geometry
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MoveCeilingTool
|
export default MoveCeilingTool
|
||||||
|
|||||||
@@ -23,13 +23,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import type * as THREE from 'three'
|
import type * as THREE from 'three'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 5 Stage D — fence whole-move tool (kind-owned).
|
* Phase 5 Stage D — fence whole-move tool.
|
||||||
*
|
*
|
||||||
* 1:1 port of the legacy `MoveFenceTool`. Same anchor-on-first-move
|
* Live-drag pattern: translate the fence MESH (and any linked fences
|
||||||
* delta drag, same linked-fence cascade, same live mesh.position +
|
* sharing an endpoint) via `sceneRegistry.nodes.get(id).position` +
|
||||||
* useLiveTransforms exception, same history dance on commit, same
|
* `useLiveTransforms`. No `scene.update` during the drag — fence
|
||||||
* cursor render at the polygon center (anchor + delta), same
|
* geometry isn't rebuilt per tick. On commit we write the translated
|
||||||
* activation grace.
|
* start/end (plus the linked-fence cascade) to the scene once.
|
||||||
|
* `GeometrySystem` resets `mesh.position` on rebuild, so the visual
|
||||||
|
* transitions smoothly with no teleport.
|
||||||
|
*
|
||||||
|
* History stays UNPAUSED during the drag (we're only mutating Three.js
|
||||||
|
* mesh transforms). The single `scene.update` on commit is the single
|
||||||
|
* undo step.
|
||||||
*/
|
*/
|
||||||
function samePoint(a: [number, number], b: [number, number]) {
|
function samePoint(a: [number, number], b: [number, number]) {
|
||||||
return a[0] === b[0] && a[1] === b[1]
|
return a[0] === b[0] && a[1] === b[1]
|
||||||
@@ -46,20 +52,13 @@ function getLinkedFenceSnapshots(args: {
|
|||||||
fenceParentId: string | null
|
fenceParentId: string | null
|
||||||
originalStart: [number, number]
|
originalStart: [number, number]
|
||||||
originalEnd: [number, number]
|
originalEnd: [number, number]
|
||||||
}) {
|
}): LinkedFenceSnapshot[] {
|
||||||
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||||
const { nodes } = useScene.getState()
|
const { nodes } = useScene.getState()
|
||||||
const snapshots: LinkedFenceSnapshot[] = []
|
const snapshots: LinkedFenceSnapshot[] = []
|
||||||
|
|
||||||
for (const node of Object.values(nodes)) {
|
for (const node of Object.values(nodes)) {
|
||||||
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
if (!(node?.type === 'fence' && node.id !== fenceId)) continue
|
||||||
continue
|
if ((node.parentId ?? null) !== fenceParentId) continue
|
||||||
}
|
|
||||||
|
|
||||||
if ((node.parentId ?? null) !== fenceParentId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
samePoint(node.start, originalStart) ||
|
samePoint(node.start, originalStart) ||
|
||||||
@@ -67,17 +66,14 @@ function getLinkedFenceSnapshots(args: {
|
|||||||
samePoint(node.end, originalStart) ||
|
samePoint(node.end, originalStart) ||
|
||||||
samePoint(node.end, originalEnd)
|
samePoint(node.end, originalEnd)
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
start: [...node.start] as [number, number],
|
start: [...node.start] as [number, number],
|
||||||
end: [...node.end] as [number, number],
|
end: [...node.end] as [number, number],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshots
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +99,26 @@ function getLinkedFenceUpdates(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMeshOffset(fenceId: FenceNode['id'], deltaX: number, deltaZ: number): void {
|
||||||
|
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
||||||
|
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFenceLiveTransform(
|
||||||
|
fenceId: FenceNode['id'],
|
||||||
|
start: [number, number],
|
||||||
|
end: [number, number],
|
||||||
|
deltaX: number,
|
||||||
|
deltaZ: number,
|
||||||
|
): void {
|
||||||
|
const cx = (start[0] + end[0]) / 2
|
||||||
|
const cz = (start[1] + end[1]) / 2
|
||||||
|
useLiveTransforms.getState().set(fenceId, {
|
||||||
|
position: [cx + deltaX, 0, cz + deltaZ],
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||||
const activatedAtRef = useRef<number>(Date.now())
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
@@ -117,8 +133,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const nodeIdRef = useRef(node.id)
|
const deltaRef = useRef<[number, number]>([0, 0])
|
||||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
|
||||||
|
|
||||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
const centerX = (node.start[0] + node.end[0]) / 2
|
const centerX = (node.start[0] + node.end[0]) / 2
|
||||||
@@ -131,9 +146,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nodeId = nodeIdRef.current
|
const fenceId = node.id
|
||||||
const originalStart = originalStartRef.current
|
const originalStart = originalStartRef.current
|
||||||
const originalEnd = originalEndRef.current
|
const originalEnd = originalEndRef.current
|
||||||
|
const linkedOriginals = linkedOriginalsRef.current
|
||||||
|
|
||||||
const levelNode =
|
const levelNode =
|
||||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||||
@@ -146,73 +163,28 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||||
|
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
|
|
||||||
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
const applyPreview = (deltaX: number, deltaZ: number) => {
|
||||||
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
deltaRef.current = [deltaX, deltaZ]
|
||||||
if (!mesh) {
|
setMeshOffset(fenceId, deltaX, deltaZ)
|
||||||
return
|
setFenceLiveTransform(fenceId, originalStart, originalEnd, deltaX, deltaZ)
|
||||||
|
for (const linked of linkedOriginals) {
|
||||||
|
setMeshOffset(linked.id, deltaX, deltaZ)
|
||||||
|
setFenceLiveTransform(linked.id, linked.start, linked.end, deltaX, deltaZ)
|
||||||
}
|
}
|
||||||
|
// Cursor at translated polygon center.
|
||||||
mesh.position.set(deltaX, 0, deltaZ)
|
const centerX = (originalStart[0] + originalEnd[0]) / 2
|
||||||
|
const centerZ = (originalStart[1] + originalEnd[1]) / 2
|
||||||
|
setCursorLocalPos([centerX + deltaX, 0, centerZ + deltaZ])
|
||||||
}
|
}
|
||||||
|
|
||||||
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
const clearPreview = () => {
|
||||||
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
setMeshOffset(fenceId, 0, 0)
|
||||||
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
useLiveTransforms.getState().clear(fenceId)
|
||||||
useLiveTransforms.getState().set(fence.id, {
|
for (const linked of linkedOriginals) {
|
||||||
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
setMeshOffset(linked.id, 0, 0)
|
||||||
rotation: 0,
|
useLiveTransforms.getState().clear(linked.id)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPreviewState = () => {
|
|
||||||
setMeshOffset(nodeId, 0, 0)
|
|
||||||
useLiveTransforms.getState().clear(nodeId)
|
|
||||||
|
|
||||||
for (const linkedFence of linkedOriginalsRef.current) {
|
|
||||||
setMeshOffset(linkedFence.id, 0, 0)
|
|
||||||
useLiveTransforms.getState().clear(linkedFence.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyNodePreview = (
|
|
||||||
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
|
|
||||||
) => {
|
|
||||||
useScene.getState().updateNodes(
|
|
||||||
updates.map((entry) => ({
|
|
||||||
id: entry.id as AnyNodeId,
|
|
||||||
data: { start: entry.start, end: entry.end },
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
for (const entry of updates) {
|
|
||||||
useScene.getState().markDirty(entry.id as AnyNodeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
|
||||||
previewRef.current = { start: nextStart, end: nextEnd }
|
|
||||||
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
||||||
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
||||||
setCursorLocalPos([centerX, 0, centerZ])
|
|
||||||
const deltaX = nextStart[0] - originalStart[0]
|
|
||||||
const deltaZ = nextStart[1] - originalStart[1]
|
|
||||||
setMeshOffset(nodeId, deltaX, deltaZ)
|
|
||||||
setFenceLiveTransform(node, deltaX, deltaZ)
|
|
||||||
|
|
||||||
for (const linkedFence of linkedOriginalsRef.current) {
|
|
||||||
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
|
||||||
setFenceLiveTransform(
|
|
||||||
{
|
|
||||||
...node,
|
|
||||||
id: linkedFence.id,
|
|
||||||
start: linkedFence.start,
|
|
||||||
end: linkedFence.end,
|
|
||||||
},
|
|
||||||
deltaX,
|
|
||||||
deltaZ,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +193,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
point: [event.localPosition[0], event.localPosition[2]],
|
point: [event.localPosition[0], event.localPosition[2]],
|
||||||
walls: levelWalls,
|
walls: levelWalls,
|
||||||
fences: levelFences,
|
fences: levelFences,
|
||||||
ignoreFenceIds: [nodeId],
|
ignoreFenceIds: [fenceId],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -235,13 +207,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||||
dragAnchorRef.current = anchor
|
dragAnchorRef.current = anchor
|
||||||
|
|
||||||
const deltaX = localX - anchor[0]
|
applyPreview(localX - anchor[0], localZ - anchor[1])
|
||||||
const deltaZ = localZ - anchor[1]
|
|
||||||
|
|
||||||
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
|
||||||
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
|
||||||
|
|
||||||
applyPreview(nextStart, nextEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
@@ -250,37 +216,47 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
const [deltaX, deltaZ] = deltaRef.current
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
|
|
||||||
useScene.temporal.getState().resume()
|
if (deltaX !== 0 || deltaZ !== 0) {
|
||||||
applyNodePreview([
|
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
||||||
{ id: nodeId, start: preview.start, end: preview.end },
|
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
||||||
...getLinkedFenceUpdates(
|
const linkedUpdates = getLinkedFenceUpdates(
|
||||||
linkedOriginalsRef.current,
|
linkedOriginals,
|
||||||
originalStart,
|
originalStart,
|
||||||
originalEnd,
|
originalEnd,
|
||||||
preview.start,
|
nextStart,
|
||||||
preview.end,
|
nextEnd,
|
||||||
),
|
)
|
||||||
])
|
useScene.getState().updateNodes([
|
||||||
useLiveTransforms.getState().clear(nodeId)
|
{ id: fenceId as AnyNodeId, data: { start: nextStart, end: nextEnd } },
|
||||||
for (const linkedFence of linkedOriginalsRef.current) {
|
...linkedUpdates.map((u) => ({
|
||||||
useLiveTransforms.getState().clear(linkedFence.id)
|
id: u.id as AnyNodeId,
|
||||||
|
data: { start: u.start, end: u.end },
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
useScene.getState().markDirty(fenceId as AnyNodeId)
|
||||||
|
for (const linked of linkedOriginals) {
|
||||||
|
useScene.getState().markDirty(linked.id as AnyNodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear useLiveTransforms but leave mesh.position — GeometrySystem
|
||||||
|
// resets it on the rebuild next frame.
|
||||||
|
useLiveTransforms.getState().clear(fenceId)
|
||||||
|
for (const linked of linkedOriginals) {
|
||||||
|
useLiveTransforms.getState().clear(linked.id)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
clearPreviewState()
|
clearPreview()
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
@@ -290,15 +266,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (wasCommitted) {
|
if (!wasCommitted) {
|
||||||
useLiveTransforms.getState().clear(nodeId)
|
clearPreview()
|
||||||
for (const linkedFence of linkedOriginalsRef.current) {
|
|
||||||
useLiveTransforms.getState().clear(linkedFence.id)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
clearPreviewState()
|
useLiveTransforms.getState().clear(fenceId)
|
||||||
|
for (const linked of linkedOriginals) {
|
||||||
|
useLiveTransforms.getState().clear(linked.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type GridEvent,
|
type GridEvent,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -19,13 +21,24 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type * as THREE from 'three'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 5 Stage D — slab whole-move tool (kind-owned).
|
* Phase 5 Stage D — slab whole-move tool.
|
||||||
*
|
*
|
||||||
* 1:1 port of the legacy `MoveSlabTool`. scene.update writes polygon
|
* Live-drag pattern: translate the slab MESH visually via
|
||||||
* + holes per tick (renderer keeps up via RAF-batched markDirty),
|
* `sceneRegistry.nodes.get(id).position` + a mirror entry in
|
||||||
* single-undo dance on commit, cursor at polygon center + delta.
|
* `useLiveTransforms`. No `scene.update` during the drag — the slab's
|
||||||
|
* polygon CSG isn't rebuilt per tick. On commit we write the
|
||||||
|
* translated polygon to the scene exactly once and clear the
|
||||||
|
* useLiveTransforms entry. `GeometrySystem` resets `mesh.position` to
|
||||||
|
* (0,0,0) when it rebuilds, so the visual transitions smoothly with
|
||||||
|
* no teleport.
|
||||||
|
*
|
||||||
|
* History stays UNPAUSED during the drag: the scene state isn't
|
||||||
|
* changing (we're only mutating Three.js mesh transforms), so there's
|
||||||
|
* nothing for zundo to record. The single `scene.update` on commit
|
||||||
|
* becomes the single undo step naturally.
|
||||||
*/
|
*/
|
||||||
function translatePolygon(
|
function translatePolygon(
|
||||||
polygon: Array<[number, number]>,
|
polygon: Array<[number, number]>,
|
||||||
@@ -46,22 +59,25 @@ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
|||||||
return [sumX / polygon.length, sumZ / polygon.length]
|
return [sumX / polygon.length, sumZ / polygon.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void {
|
||||||
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||||
|
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
|
||||||
|
}
|
||||||
|
|
||||||
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||||
const activatedAtRef = useRef<number>(Date.now())
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||||
const originalHolesRef = useRef(
|
const originalHolesRef = useRef(
|
||||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||||
)
|
)
|
||||||
|
const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current))
|
||||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const previewRef = useRef<{
|
const deltaRef = useRef<[number, number]>([0, 0])
|
||||||
polygon: Array<[number, number]>
|
|
||||||
holes: Array<Array<[number, number]>>
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
const center = getPolygonCenter(node.polygon)
|
const c = originalCenterRef.current
|
||||||
return [center[0], 0, center[1]]
|
return [c[0], 0, c[1]]
|
||||||
})
|
})
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
@@ -71,6 +87,9 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const originalPolygon = originalPolygonRef.current
|
const originalPolygon = originalPolygonRef.current
|
||||||
const originalHoles = originalHolesRef.current
|
const originalHoles = originalHolesRef.current
|
||||||
|
const originalCenter = originalCenterRef.current
|
||||||
|
const slabId = node.id
|
||||||
|
|
||||||
const levelNode =
|
const levelNode =
|
||||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||||
@@ -83,26 +102,24 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||||
|
|
||||||
useScene.temporal.getState().pause()
|
|
||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
|
|
||||||
const applyPreview = (
|
const applyPreview = (deltaX: number, deltaZ: number) => {
|
||||||
polygon: Array<[number, number]>,
|
deltaRef.current = [deltaX, deltaZ]
|
||||||
holes: Array<Array<[number, number]>>,
|
// Visual: translate the slab MESH only. No scene mutation, no
|
||||||
) => {
|
// polygon rebuild, no React re-render of geometry.
|
||||||
previewRef.current = { polygon, holes }
|
setMeshOffset(slabId as AnyNodeId, deltaX, deltaZ)
|
||||||
const center = getPolygonCenter(polygon)
|
useLiveTransforms.getState().set(slabId, {
|
||||||
setCursorLocalPos([center[0], 0, center[1]])
|
position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ],
|
||||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
rotation: 0,
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
})
|
||||||
|
// Cursor sphere follows the new polygon center.
|
||||||
|
setCursorLocalPos([originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ])
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = () => {
|
const clearPreview = () => {
|
||||||
useScene.getState().updateNode(node.id, {
|
setMeshOffset(slabId as AnyNodeId, 0, 0)
|
||||||
holes: originalHoles,
|
useLiveTransforms.getState().clear(slabId)
|
||||||
polygon: originalPolygon,
|
|
||||||
})
|
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -123,13 +140,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||||
dragAnchorRef.current = anchor
|
dragAnchorRef.current = anchor
|
||||||
|
|
||||||
const deltaX = localX - anchor[0]
|
applyPreview(localX - anchor[0], localZ - anchor[1])
|
||||||
const deltaZ = localZ - anchor[1]
|
|
||||||
|
|
||||||
applyPreview(
|
|
||||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
|
||||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
@@ -138,32 +149,35 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
const [deltaX, deltaZ] = deltaRef.current
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
|
|
||||||
// Restore original baseline while paused so the next resume+update
|
if (deltaX !== 0 || deltaZ !== 0) {
|
||||||
// registers as a single tracked change (undo reverts to original).
|
// Single scene.update — recorded as one undo step (history was
|
||||||
useScene.getState().updateNode(node.id, {
|
// never paused). GeometrySystem rebuilds polygon-driven geometry
|
||||||
polygon: originalPolygon,
|
// and resets the group's transform on the next frame.
|
||||||
holes: originalHoles,
|
useScene.getState().updateNode(slabId, {
|
||||||
})
|
polygon: translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||||
|
holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
|
||||||
useScene.temporal.getState().resume()
|
})
|
||||||
useScene.getState().updateNode(node.id, preview)
|
useScene.getState().markDirty(slabId as AnyNodeId)
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
}
|
||||||
useScene.temporal.getState().pause()
|
// Clear useLiveTransforms but leave mesh.position as-is. The
|
||||||
|
// GeometrySystem rebuild zeros it on the next frame, by which
|
||||||
|
// point the new geometry is in place — visual stays smooth.
|
||||||
|
useLiveTransforms.getState().clear(slabId)
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [slabId] })
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
restoreOriginal()
|
// No scene state to roll back — we never wrote anything. Just
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
// restore the mesh visual.
|
||||||
useScene.temporal.getState().resume()
|
clearPreview()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [slabId] })
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
@@ -174,14 +188,15 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
restoreOriginal()
|
clearPreview()
|
||||||
|
} else {
|
||||||
|
useLiveTransforms.getState().clear(slabId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node.id, node.parentId, node.polygon])
|
}, [exitMoveMode, node.id, node.parentId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
|||||||
gridMesh.geometry = newGeo.clone()
|
gridMesh.geometry = newGeo.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position at the ceiling height
|
// Position at the ceiling height and reset X/Z so live-drag mesh
|
||||||
|
// offsets (set by move tools during the drag) don't leak into the
|
||||||
|
// canonical position after the rebuild. Matches the pattern used by
|
||||||
|
// FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset
|
||||||
|
// position+rotation after rebuild).
|
||||||
|
mesh.position.x = 0
|
||||||
|
mesh.position.z = 0
|
||||||
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ export const GeometrySystem = () => {
|
|||||||
for (const child of [...built.children]) {
|
for (const child of [...built.children]) {
|
||||||
group.add(child)
|
group.add(child)
|
||||||
}
|
}
|
||||||
|
// Reset transform — matches the legacy per-kind systems
|
||||||
|
// (e.g. FenceSystem.updateFenceGeometry) which clear
|
||||||
|
// mesh.position/rotation when rebuilding. Tools that translate
|
||||||
|
// the group via `mesh.position` for live-drag visuals rely on
|
||||||
|
// this reset to restore the canonical position after scene
|
||||||
|
// state catches up (otherwise the geometry rebuild renders on
|
||||||
|
// top of the residual offset → double-translation teleport).
|
||||||
|
group.position.set(0, 0, 0)
|
||||||
|
group.rotation.set(0, 0, 0)
|
||||||
|
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user