Merge pull request #259 from pascalorg/fix/move-tools

Fix/move tools
This commit is contained in:
Wassim SAMAD
2026-04-17 17:34:47 -04:00
committed by GitHub
6 changed files with 93 additions and 29 deletions
@@ -139,6 +139,14 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview) useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
@@ -29,9 +29,13 @@ export const MoveRoofTool: React.FC<{
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id) const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) { if (obj) {
const pos = new THREE.Vector3() const worldPos = obj.getWorldPosition(new THREE.Vector3())
obj.getWorldPosition(pos) // Cursor renders inside the building-local ToolManager group, so convert
return [pos.x, pos.y, pos.z] // world → building-local to honor any building rotation.
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) buildingObj.worldToLocal(worldPos)
return [worldPos.x, worldPos.y, worldPos.z]
} }
// Fallback if not registered (e.g. newly created duplicate without mesh yet) // Fallback if not registered (e.g. newly created duplicate without mesh yet)
if ( if (
@@ -114,10 +118,15 @@ export const MoveRoofTool: React.FC<{
} }
} }
const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => { const computeLocal = (
let localX = gridX gridX: number,
let localZ = gridZ gridZ: number,
y: number,
buildingLocalX: number,
buildingLocalZ: number,
): [number, number] => {
// Segments have a transformed parent (stair/roof). Convert world → parent-local
// via Three.js hierarchy so the segment's stored position stays parent-relative.
if ( if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') && (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId movingNode.parentId
@@ -128,19 +137,21 @@ export const MoveRoofTool: React.FC<{
if (parentObj) { if (parentObj) {
const worldVec = new THREE.Vector3(gridX, y, gridZ) const worldVec = new THREE.Vector3(gridX, y, gridZ)
parentObj.worldToLocal(worldVec) parentObj.worldToLocal(worldVec)
localX = worldVec.x return [worldVec.x, worldVec.z]
localZ = worldVec.z }
} else {
const dx = gridX - (parentNode.position[0] as number) const dx = gridX - (parentNode.position[0] as number)
const dz = gridZ - (parentNode.position[2] as number) const dz = gridZ - (parentNode.position[2] as number)
const angle = -(parentNode.rotation as number) const angle = -(parentNode.rotation as number)
localX = dx * Math.cos(angle) - dz * Math.sin(angle) return [
localZ = dx * Math.sin(angle) + dz * Math.cos(angle) dx * Math.cos(angle) - dz * Math.sin(angle),
} dx * Math.sin(angle) + dz * Math.cos(angle),
]
} }
} }
return [localX, localZ] // Stair/roof live directly in the level — their stored position is building-local.
// event.localPosition is already building-local, so using it handles building rotation.
return [buildingLocalX, buildingLocalZ]
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -161,7 +172,7 @@ export const MoveRoofTool: React.FC<{
const lz = Math.round(event.localPosition[2] * 2) / 2 const lz = Math.round(event.localPosition[2] * 2) / 2
setCursorWorldPos([lx, event.localPosition[1], lz]) setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
// Directly update the Three.js mesh — no store update during drag // Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id) const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -181,8 +192,10 @@ export const MoveRoofTool: React.FC<{
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
const [localX, localZ] = computeLocal(gridX, gridZ, y) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
wasCommitted = true wasCommitted = true
@@ -112,6 +112,14 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(node.id, {
polygon: originalPolygon,
holes: originalHoles,
})
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview) useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
@@ -110,12 +110,18 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const curveOffset = previewOffsetRef.current const curveOffset = previewOffsetRef.current
wasCommitted = true wasCommitted = true
if (curveOffset !== originalCurveOffset) {
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
if (curveOffset !== getClampedWallCurveOffset(node)) {
useScene.getState().updateNode(nodeId, { curveOffset }) useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId) useScene.getState().markDirty(nodeId as AnyNodeId)
}
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
}
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -201,6 +201,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (hasChanged && isWallLongEnough(preview.start, preview.end)) { if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
@@ -229,22 +229,43 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true wasCommitted = true
useScene.temporal.getState().resume()
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume()
const commitUpdates = [
{
id: nodeId as AnyNodeId,
data: isNew
? {
start: preview.start,
end: preview.end,
metadata: stripWallIsNewMetadata(node.metadata),
}
: { start: preview.start, end: preview.end },
},
...getLinkedWallUpdates( ...getLinkedWallUpdates(
linkedOriginalsRef.current, linkedOriginalsRef.current,
originalStart, originalStart,
originalEnd, originalEnd,
preview.start, preview.start,
preview.end, preview.end,
), ).map((entry) => ({
]) id: entry.id as AnyNodeId,
if (isNew) { data: { start: entry.start, end: entry.end },
useScene.getState().updateNode(nodeId, { })),
metadata: stripWallIsNewMetadata(node.metadata), ]
}) useScene.getState().updateNodes(commitUpdates)
for (const { id } of commitUpdates) {
useScene.getState().markDirty(id)
} }
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')