@@ -720,15 +715,12 @@ function PaintCursorLayer({
}) {
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
- const activePaintTarget = useEditor((s) => s.activePaintTarget)
- const badgeRef = useRef
(null)
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode
useEffect(() => {
if (!active) {
- if (badgeRef.current) {
- badgeRef.current.style.display = 'none'
- }
+ setPosition(null)
return
}
const el = containerRef.current
@@ -736,20 +728,16 @@ function PaintCursorLayer({
let frame = 0
let nextX = 0
let nextY = 0
- const badge = badgeRef.current
const flushPosition = () => {
frame = 0
- if (!badge) return
- badge.style.display = 'block'
- badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
+ setPosition({ x: nextX, y: nextY })
}
- const onMove = (e: PointerEvent) => {
+ const updateFromEvent = (e: PointerEvent) => {
const rect = el.getBoundingClientRect()
nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
-
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
}
@@ -759,17 +747,19 @@ function PaintCursorLayer({
window.cancelAnimationFrame(frame)
frame = 0
}
- if (badge) {
- badge.style.display = 'none'
- }
+ setPosition(null)
}
- el.addEventListener('pointermove', onMove)
+ el.addEventListener('pointermove', updateFromEvent)
+ el.addEventListener('pointerenter', updateFromEvent)
+ el.addEventListener('pointerdown', updateFromEvent)
el.addEventListener('pointerleave', onLeave)
return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
- el.removeEventListener('pointermove', onMove)
+ el.removeEventListener('pointermove', updateFromEvent)
+ el.removeEventListener('pointerenter', updateFromEvent)
+ el.removeEventListener('pointerdown', updateFromEvent)
el.removeEventListener('pointerleave', onLeave)
}
}, [active, containerRef])
@@ -779,29 +769,15 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
- const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
- const icon = 'mdi:format-color-fill'
- useLayoutEffect(() => {
- if (!active && badgeRef.current) {
- badgeRef.current.style.display = 'none'
- }
- }, [active])
-
- if (!active) return null
+ if (!active || !position) return null
return (
)
}
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx
index 04c7dab7..0fd56a5b 100644
--- a/packages/editor/src/components/editor/node-arrow-handles.tsx
+++ b/packages/editor/src/components/editor/node-arrow-handles.tsx
@@ -41,8 +41,8 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api'
+import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
-import { snapToGrid } from '../tools/item/placement-math'
import { formatAngleRadians } from '../tools/shared/segment-angle'
import {
ARROW_COLOR,
@@ -1143,9 +1143,6 @@ function ArcArrow({
function TranslateArrow({
descriptor,
node,
- handleIndex,
- dragControls,
- rideObject,
}: {
descriptor: TranslateHandle
node: AnyNode
@@ -1154,7 +1151,6 @@ function TranslateArrow({
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
- const [isDragging, setIsDragging] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
@@ -1166,67 +1162,17 @@ function TranslateArrow({
// local +Z). Its cross icon stands up into that plane (tilt about X).
const isWallPlane = descriptor.plane === 'node-normal'
- const activate = useHandleDrag({
- kind: 'drag',
- cursor,
- dragControls,
- handleIndex,
- node,
- rideObject,
- setIsDragging,
- onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => {
- const worldOrigin = new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld)
- const planeNormal = isWallPlane
- ? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize()
- : new Vector3(0, 1, 0)
- const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
- const parent = dragRideObject.parent
- const parentInverse = new Matrix4()
- if (parent) {
- parent.updateMatrixWorld()
- parentInverse.copy(parent.matrixWorld).invert()
- }
-
- const hitWorld = new Vector3()
- if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
- return null
- }
- const startLocal = hitWorld.clone().applyMatrix4(parentInverse)
- const initialPos = (initialNode as { position?: readonly [number, number, number] })
- .position ?? [0, 0, 0]
-
- return {
- markDirty: false,
- move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
- const hit = new Vector3()
- if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
- const curLocal = hit.applyMatrix4(parentInverse)
- const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]]
- newPos[0] += curLocal.x - startLocal.x
- if (isWallPlane) {
- newPos[1] += curLocal.y - startLocal.y
- } else {
- newPos[2] += curLocal.z - startLocal.z
- }
-
- const extents = descriptor.snapExtents?.(initialNode as never, sceneApi)
- if (extents) {
- newPos[0] = snapToGrid(newPos[0], extents[0])
- if (isWallPlane) {
- newPos[1] = snapToGrid(newPos[1], extents[1])
- } else {
- newPos[2] = snapToGrid(newPos[2], extents[1])
- }
- }
- return descriptor.apply(initialNode as never, newPos, sceneApi) as Partial
- },
- }
- },
- })
-
- // Suppress the unused `isDragging` lint — it only drives the React re-render
- // that keeps hover/drag cursor state in sync.
- void isDragging
+ // Same function as the floating action menu's Move button
+ // (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
+ // which owns the cursor follow, grid + alignment snap, green guide overlay,
+ // and click-to-commit. Routes both entry points through one path so the
+ // 3D translate gizmo and the floating Move button behave identically.
+ const activate = (event: ThreeEvent) => {
+ event.stopPropagation()
+ sfxEmitter.emit('sfx:item-pick')
+ useEditor.getState().setMovingNode(node as never)
+ useViewer.getState().setSelection({ selectedIds: [] })
+ }
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
// it lies in the item-local XY plane (= the wall face).
diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts
index 0f34e70e..31c013b6 100644
--- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts
+++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts
@@ -5,7 +5,6 @@ import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import {
- snapPointToGrid as snapWallPointToGrid,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
@@ -41,7 +40,7 @@ type UseFloorplanBackgroundPlacementArgs = {
) => boolean
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
handleSlabPlacementPoint: (point: WallPlanPoint) => void
- handleWallPlacementPoint: (point: WallPlanPoint) => void
+ handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
handleZonePlacementPoint: (point: WallPlanPoint) => void
isCeilingBuildActive: boolean
isCeilingItemPlacementActive: boolean
@@ -65,6 +64,7 @@ type UseFloorplanBackgroundPlacementArgs = {
start?: WallPlanPoint
angleSnap?: boolean
step?: number
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint
}) => WallPlanPoint
snapPolygonDraftPoint: (args: {
point: WallPlanPoint
@@ -73,6 +73,13 @@ type UseFloorplanBackgroundPlacementArgs = {
}) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[]
+ /**
+ * Snap a building-local plan point to the world XZ grid at `step`.
+ * Injected so the hook doesn't have to know the building's rotation
+ * or position — used by wall / fence branches that snap at variable
+ * step (Shift = fine).
+ */
+ worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
}
export function useFloorplanBackgroundPlacement({
@@ -111,6 +118,7 @@ export function useFloorplanBackgroundPlacement({
snapPolygonDraftPoint,
toPoint2D,
walls,
+ worldGridSnap,
}: UseFloorplanBackgroundPlacementArgs) {
const handleBackgroundPlacementClick = useCallback(
(
@@ -177,16 +185,17 @@ export function useFloorplanBackgroundPlacement({
if (isFenceBuildActive) {
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — endpoint snap wins (same precedence as move).
+ // `gridSnap` keeps the snap on the world XZ grid even when the
+ // building is rotated.
+ const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
+ gridSnap: (p) => worldGridSnap(p, fenceStep),
})
- const fenceGridBase = snapWallPointToGrid(
- planPoint,
- shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
- )
+ const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
const snappedPoint = fenceLocked
@@ -249,22 +258,23 @@ export function useFloorplanBackgroundPlacement({
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
// Figma alignment — endpoint/join snap wins (same precedence as the
// move-preview branch), so committing onto a corner still works.
+ // `gridSnap` keeps the snap on the world XZ grid even when the
+ // building is rotated.
+ const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
+ gridSnap: (p) => worldGridSnap(p, wallStep),
})
- const wallGridBase = snapWallPointToGrid(
- planPoint,
- shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
- )
+ const wallGridBase = worldGridSnap(planPoint, wallStep)
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
const snappedPoint = wallLocked
? wallSnapped
- : alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
+ : alignFloorplanDraftPoint(wallSnapped, { bypass: false })
emitFloorplanGridEvent('click', snappedPoint, event)
- handleWallPlacementPoint(snappedPoint)
+ handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
return true
}
@@ -324,6 +334,7 @@ export function useFloorplanBackgroundPlacement({
toPoint2D,
walls,
handleWallPlacementPoint,
+ worldGridSnap,
],
)
diff --git a/packages/editor/src/components/editor/use-floorplan-scene-data.ts b/packages/editor/src/components/editor/use-floorplan-scene-data.ts
index b3d7350e..6f8bd7e1 100644
--- a/packages/editor/src/components/editor/use-floorplan-scene-data.ts
+++ b/packages/editor/src/components/editor/use-floorplan-scene-data.ts
@@ -181,6 +181,7 @@ export function useFloorplanSceneData({
return {
buildingPosition,
+ committedBuildingPosition,
buildingRotationY,
currentBuildingId,
ceilings,
diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts
index ff09a30c..5ea60acb 100644
--- a/packages/editor/src/components/tools/fence/fence-drafting.ts
+++ b/packages/editor/src/components/tools/fence/fence-drafting.ts
@@ -134,14 +134,23 @@ export function snapFenceDraftPoint(args: {
ignoreFenceIds?: string[]
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
step?: number
+ /**
+ * Optional grid-snap function. When provided, replaces the default
+ * local-axis snap — lets the 2D floor-plan keep snapping to the
+ * world XZ grid even when the building is rotated. Wall / fence
+ * endpoint snap precedence is preserved.
+ */
+ gridSnap?: (point: FencePlanPoint) => FencePlanPoint
}): FencePlanPoint {
- const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step } = args
+ const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
const gridStep = step ?? getSegmentGridStep()
const angleStep = getWallAngleSnapStep(gridStep)
const basePoint =
start && angleSnap
- ? snapPointTo45Degrees(start, point, gridStep, angleStep)
- : snapPointToGrid(point, gridStep)
+ ? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
+ : gridSnap
+ ? gridSnap(point)
+ : snapPointToGrid(point, gridStep)
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts
index 6ab28510..047a8b02 100644
--- a/packages/editor/src/components/tools/item/placement-strategies.ts
+++ b/packages/editor/src/components/tools/item/placement-strategies.ts
@@ -19,6 +19,7 @@ import {
useScene,
} from '@pascal-app/core'
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
+import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import {
calculateCursorRotation,
calculateItemRotation,
@@ -99,10 +100,15 @@ export const floorStrategy = {
const [dimX, , dimZ] = dims
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
- // event.localPosition is building-local; the coordinator cursor group is inside the
- // building-local ToolManager group, so local coords are correct for both data and visuals.
- const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
- const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
+ // Snap on the world XZ grid (the grid the editor renders) so the
+ // item edges land on the visible grid even when the active building
+ // is rotated; then project the world point back into building-local
+ // for storage. Without this, a rotated building drags placement off
+ // the world grid.
+ const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
+ const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
+ const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
+ const [x, z] = local
const y = ctx.gridPosition.y
return {
diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
index 2e6ca3d3..84bdfd2d 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -10,7 +10,6 @@ import {
getScaledDimensions,
type ItemEvent,
movingFootprintAnchors,
- resolveAlignment,
resolveLevelId,
type ShelfEvent,
sceneRegistry,
@@ -42,6 +41,7 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
+import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
@@ -700,7 +700,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draft.id,
useViewer.getState().selection.levelId,
)
- const ar = resolveAlignment({
+ const ar = resolveAlignmentForActiveBuilding({
moving: movingFootprintAnchors(
draft as unknown as AnyNode,
result.gridPosition[0],
diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
index e13410a4..a6262973 100644
--- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
+++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
@@ -28,6 +28,7 @@ import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placemen
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
+import { DragBoundingBox } from '../shared/drag-bounding-box'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
import { PlacementBox } from '../shared/placement-box'
@@ -561,6 +562,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
useAbsoluteCursorPlacement,
])
+ // Snapshot the scene once at drag-start — bounds depend on `node` (locked
+ // for the lifetime of this tool) and any sibling state the kind reads. If a
+ // future kind needs live sibling state mid-drag, switch to a subscribed
+ // selector; for v1 (elevator shaft height from level set) start-time is
+ // correct and avoids subscribing the whole `nodes` map.
+ const dragBounds = useMemo(
+ () =>
+ nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
+ null,
+ [node],
+ )
+
if (!previewVisible) return null
if (boxDimensions) {
@@ -574,5 +587,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
)
}
- return
+ return (
+ <>
+
+
+ >
+ )
}
diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx
index a26af9f9..1bd9d8af 100644
--- a/packages/editor/src/components/tools/roof/roof-tool.tsx
+++ b/packages/editor/src/components/tools/roof/roof-tool.tsx
@@ -7,7 +7,6 @@ import {
type LevelNode,
RoofNode,
RoofSegmentNode,
- resolveAlignment,
sceneRegistry,
snapScalar,
useScene,
@@ -20,6 +19,10 @@ import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'thre
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
+import {
+ resolveAlignmentForActiveBuilding,
+ snapWorldXZForActiveBuilding,
+} from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -196,7 +199,7 @@ export const RoofTool: React.FC = () => {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
- const ar = resolveAlignment({
+ const ar = resolveAlignmentForActiveBuilding({
moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
@@ -237,9 +240,16 @@ export const RoofTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
+ // World-grid snap projected into building-local; rotated buildings
+ // used to drag every roof corner off the visible grid.
+ const snapped = snapWorldXZForActiveBuilding(
+ event.position[0],
+ event.position[2],
+ useEditor.getState().gridSnapStep,
+ ).local
const [gridX, gridZ] = alignPoint(
- snapToActiveGrid(event.localPosition[0]),
- snapToActiveGrid(event.localPosition[2]),
+ snapped[0],
+ snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
@@ -275,9 +285,16 @@ export const RoofTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
+ // World-grid snap projected into building-local; rotated buildings
+ // used to drag every roof corner off the visible grid.
+ const snapped = snapWorldXZForActiveBuilding(
+ event.position[0],
+ event.position[2],
+ useEditor.getState().gridSnapStep,
+ ).local
const [gridX, gridZ] = alignPoint(
- snapToActiveGrid(event.localPosition[0]),
- snapToActiveGrid(event.localPosition[2]),
+ snapped[0],
+ snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx
index 16e4480f..0d5e3fc7 100644
--- a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx
+++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx
@@ -64,6 +64,15 @@ interface DragBoundingBoxProps {
rotationY?: number
/** Declared `[width, height, depth]`, used until/if the mesh can't be measured. */
fallbackSize?: [number, number, number]
+ /**
+ * Hard override for the box extents — wins over both mesh measurement and
+ * `fallbackSize`. Use when the rendered mesh contains extras the user
+ * wouldn't read as "the thing being dragged" (e.g. an elevator whose mesh
+ * includes per-level landings outside the shaft footprint).
+ */
+ size?: [number, number, number]
+ /** Y center of the box in the node's local frame. Defaults to `size[1] / 2`. */
+ centerY?: number
color?: number
}
@@ -80,15 +89,20 @@ export function DragBoundingBox({
position,
rotationY = 0,
fallbackSize = [0, 0, 0],
+ size,
+ centerY,
color = DEFAULT_COLOR,
}: DragBoundingBoxProps) {
const measured = useMemo(() => {
+ if (size) return null
const obj = sceneRegistry.nodes.get(nodeId)
return obj ? measureLocalBounds(obj) : null
- }, [nodeId])
+ }, [nodeId, size])
- const [w, h, d] = measured?.size ?? fallbackSize
- const [cx, cy, cz] = measured?.center ?? [0, fallbackSize[1] / 2, 0]
+ const [w, h, d] = size ?? measured?.size ?? fallbackSize
+ const [cx, cy, cz] = size
+ ? [0, centerY ?? size[1] / 2, 0]
+ : (measured?.center ?? [0, fallbackSize[1] / 2, 0])
const minY = cy - h / 2
const edgeGeometry = useMemo(() => {
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts
index 84e4084f..fe1982fa 100644
--- a/packages/editor/src/components/tools/wall/wall-drafting.ts
+++ b/packages/editor/src/components/tools/wall/wall-drafting.ts
@@ -68,17 +68,24 @@ export function snapPointTo45Degrees(
cursor: WallPlanPoint,
step = WALL_GRID_STEP,
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
+ /**
+ * Optional grid-snap callback. Lets the caller route the final
+ * snap through a world-XZ grid (or any other axis system) instead
+ * of the local-axis grid `snapPointToGrid` uses. When omitted,
+ * falls back to the local-axis snap at `step`.
+ */
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint,
): WallPlanPoint {
const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / angleStep) * angleStep
const distance = Math.sqrt(dx * dx + dz * dz)
-
- return snapPointToGrid(
- [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
- step,
- )
+ const point: WallPlanPoint = [
+ start[0] + Math.cos(snappedAngle) * distance,
+ start[1] + Math.sin(snappedAngle) * distance,
+ ]
+ return gridSnap ? gridSnap(point) : snapPointToGrid(point, step)
}
export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
@@ -331,6 +338,13 @@ type SnapWallDraftArgs = {
* keep the prior behaviour.
*/
magnetic?: boolean
+ /**
+ * Optional grid-snap override. Lets the caller route grid snapping
+ * through a world-XZ aligned snap (so a rotated building's draft
+ * lands on the visible grid). When omitted, falls back to the
+ * local-axis grid at `step`.
+ */
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint
}
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -342,6 +356,7 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
ignoreWallIds,
step: overrideStep,
magnetic = true,
+ gridSnap,
} = args
// Discrete special points (corner / midpoint / crossing) are taken from the
@@ -356,8 +371,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
const angleStep = getWallAngleSnapStep(step)
const basePoint =
start && angleSnap
- ? snapPointTo45Degrees(start, point, step, angleStep)
- : snapPointToGrid(point, step)
+ ? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
+ : gridSnap
+ ? gridSnap(point)
+ : snapPointToGrid(point, step)
if (magnetic) {
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx
index 73006499..cf43f26c 100644
--- a/packages/editor/src/components/tools/zone/zone-tool.tsx
+++ b/packages/editor/src/components/tools/zone/zone-tool.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
+import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -178,9 +179,13 @@ export const ZoneTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
- // Snap to 0.5 grid
- const gridX = Math.round(event.localPosition[0] * 2) / 2
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
+ // World-grid snap projected into building-local; rotated buildings
+ // used to pull the snap off the visible grid lines.
+ const [gridX, gridZ] = snapWorldXZForActiveBuilding(
+ event.position[0],
+ event.position[2],
+ 0.5,
+ ).local
cursorPosition = [gridX, gridZ]
levelYRef.current = event.localPosition[1]
@@ -209,8 +214,11 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
- const gridX = Math.round(event.localPosition[0] * 2) / 2
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
+ const [gridX, gridZ] = snapWorldXZForActiveBuilding(
+ event.position[0],
+ event.position[2],
+ 0.5,
+ ).local
let clickPoint: [number, number] = [gridX, gridZ]
// Snap to axis from last point
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 0acf48be..5bc28f58 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -104,6 +104,7 @@ export {
snapWallDraftPoint,
snapWallDraftPointDetailed,
WALL_FINE_GRID_STEP,
+ WALL_GRID_STEP,
type WallDraftSnapKind,
type WallDraftSnapResult,
type WallPlanPoint,
@@ -237,6 +238,12 @@ export {
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
export { cn } from './lib/utils'
+export {
+ getActiveBuildingPose,
+ resolveAlignmentForActiveBuilding,
+ snapBuildingLocalToWorldGrid,
+ snapWorldXZForActiveBuilding,
+} from './lib/world-grid-snap'
export { default as useAlignmentGuides } from './store/use-alignment-guides'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
diff --git a/packages/editor/src/lib/world-grid-snap.ts b/packages/editor/src/lib/world-grid-snap.ts
new file mode 100644
index 00000000..fc6984a3
--- /dev/null
+++ b/packages/editor/src/lib/world-grid-snap.ts
@@ -0,0 +1,199 @@
+/**
+ * World-grid snap for tools that consume `grid:move` / `grid:click`.
+ *
+ * Tools historically snapped on `event.localPosition` (the cursor in the
+ * active building's local frame). After the floor-plan grid was pulled
+ * out of the rotated scene group, snapping has to follow the WORLD XZ
+ * grid — otherwise a rotated building drags every placement off the
+ * visible grid lines. This helper resolves the active building's pose
+ * and projects the world snap back into local coords for storage.
+ */
+import {
+ type AlignmentAnchor,
+ type AnyNodeId,
+ type BuildingPose,
+ type ResolveAlignmentInBuildingResult,
+ resolveAlignmentInBuildingWorld,
+ snapWorldXZToBuildingLocal,
+ useLiveTransforms,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+
+/**
+ * Look up the active building's pose, or null when we're at the site root.
+ * Used by tools that need to resolve alignment in world coords.
+ *
+ * Falls back through the active level's `parentId` because the 2D floor-plan
+ * often runs with `selection.buildingId === null` while still operating
+ * inside a specific building (the user is editing a level, not the building
+ * shell itself). Without the fallback, alignment in the floor plan saw a
+ * `buildingRotY === 0` pose and emitted world-axis guides — which appeared
+ * diagonal once the building was rotated.
+ *
+ * Honours `useLiveTransforms` overrides: while the building is being moved
+ * or rotated, the floor-plan scene is driven by the live transform
+ * (see `use-floorplan-scene-data.ts`), so alignment has to read the same
+ * pose or the rotated anchors fall out of sync with the SVG transform and
+ * guides drift off the visible grid mid-drag (and through any post-drag
+ * frame where the live override is still set).
+ */
+export function getActiveBuildingPose(): BuildingPose | null {
+ const sel = useViewer.getState().selection
+ const nodes = useScene.getState().nodes
+ // Match `use-floorplan-scene-data.ts`: prefer the active level's
+ // owning building, fall back to selection.buildingId. If the user
+ // has a stale building selected from another scope while editing a
+ // different level, the level path is the authoritative one.
+ let buildingId: AnyNodeId | null = null
+ if (sel.levelId) {
+ const level = nodes[sel.levelId]
+ if (level && level.type === 'level' && level.parentId) {
+ buildingId = level.parentId as AnyNodeId
+ }
+ }
+ if (!buildingId) buildingId = sel.buildingId ?? null
+ const building = buildingId ? nodes[buildingId] : null
+ if (!building || building.type !== 'building') return null
+ const live = useLiveTransforms.getState().transforms.get(buildingId as string)
+ return {
+ position: live?.position ?? building.position,
+ rotationY: live?.rotation ?? building.rotation[1] ?? 0,
+ }
+}
+
+/**
+ * Resolve Figma-style alignment for tools whose anchors are in the active
+ * building's local frame, but where alignment must run on the WORLD axes
+ * (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
+ * with the active building lookup so callers don't repeat the boilerplate.
+ *
+ * Returns:
+ * - `guides` in WORLD coords (renderer must live in a world-space group),
+ * - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
+ */
+export function resolveAlignmentForActiveBuilding(args: {
+ moving: readonly AlignmentAnchor[]
+ candidates: readonly AlignmentAnchor[]
+ threshold: number
+}): ResolveAlignmentInBuildingResult {
+ return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
+}
+
+/**
+ * Baseline rotation the floor-plan view applies on top of the building
+ * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
+ * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
+ */
+const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2
+
+function rotateAnchorsBy(
+ anchors: readonly AlignmentAnchor[],
+ cos: number,
+ sin: number,
+): AlignmentAnchor[] {
+ return anchors.map((a) => ({
+ nodeId: a.nodeId,
+ kind: a.kind,
+ x: a.x * cos - a.z * sin,
+ z: a.x * sin + a.z * cos,
+ }))
+}
+
+/**
+ * Resolve alignment in the 2D floor-plan view frame — the frame the user
+ * sees the (always axis-aligned) grid lines in, regardless of how the
+ * building has been rotated. Use this from EVERY 2D floor-plan path so
+ * alignment guides stay parallel to the visible grid.
+ *
+ * Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
+ * shows the world XZ grid, so world-frame alignment matches the visible
+ * grid there. The 2D floor plan, however, rotates the scene `` by
+ * `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
+ * OUTSIDE that rotated group — so the visible axes are
+ * `R(FVR − buildingRot) · local`. World-frame alignment would land on
+ * world axes, which appear diagonal in this view; view-frame alignment
+ * lands on the SVG axes the user actually reads.
+ *
+ * Returns guides in view-frame coords (correct input for the floor-plan
+ * alignment-guide layer, which is mounted outside the rotated scene
+ * group) and a snap delta projected back into building-local (so callers
+ * can add it to a local position as-is).
+ */
+export function resolveAlignmentForFloorplanView(args: {
+ moving: readonly AlignmentAnchor[]
+ candidates: readonly AlignmentAnchor[]
+ threshold: number
+}): ResolveAlignmentInBuildingResult {
+ const pose = getActiveBuildingPose()
+ const buildingRotY = pose?.rotationY ?? 0
+ const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
+ const cos = Math.cos(rot)
+ const sin = Math.sin(rot)
+ const result = resolveAlignmentInBuildingWorld({
+ moving: rotateAnchorsBy(args.moving, cos, sin),
+ candidates: rotateAnchorsBy(args.candidates, cos, sin),
+ threshold: args.threshold,
+ // Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
+ // path: it just runs `resolveAlignment` on the already-rotated
+ // anchors and returns the snap delta in the same view frame.
+ pose: null,
+ })
+ if (!result.snap) return result
+ // View-frame delta → local-frame delta (transpose of R(rot)).
+ const { dx, dz } = result.snap
+ return {
+ guides: result.guides,
+ snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
+ }
+}
+
+/**
+ * Snap a world XZ position to the grid, then express it in the active
+ * building's local frame. When no building is active, world == local.
+ */
+export function snapWorldXZForActiveBuilding(
+ worldX: number,
+ worldZ: number,
+ step: number,
+): { world: [number, number]; local: [number, number] } {
+ const buildingId = useViewer.getState().selection.buildingId
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
+ if (!building || building.type !== 'building') {
+ if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
+ const sx = Math.round(worldX / step) * step
+ const sz = Math.round(worldZ / step) * step
+ return { world: [sx, sz], local: [sx, sz] }
+ }
+ return snapWorldXZToBuildingLocal(
+ worldX,
+ worldZ,
+ building.position,
+ building.rotation[1] ?? 0,
+ step,
+ )
+}
+
+/**
+ * Snap a building-local plan point so the resulting position sits on the
+ * world XZ grid. The returned point is still in building-local coords —
+ * useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
+ * etc., which operate entirely in the local frame.
+ */
+export function snapBuildingLocalToWorldGrid(
+ local: readonly [number, number],
+ step: number,
+): [number, number] {
+ const buildingId = useViewer.getState().selection.buildingId
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
+ if (!building || building.type !== 'building') {
+ if (step <= 0) return [local[0], local[1]]
+ return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
+ }
+ const rotY = building.rotation[1] ?? 0
+ const cos = Math.cos(rotY)
+ const sin = Math.sin(rotY)
+ const worldX = building.position[0] + local[0] * cos + local[1] * sin
+ const worldZ = building.position[2] - local[0] * sin + local[1] * cos
+ return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
+}
diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts
index 623cef13..f72bd8a3 100644
--- a/packages/nodes/src/elevator/definition.ts
+++ b/packages/nodes/src/elevator/definition.ts
@@ -137,21 +137,20 @@ function elevatorRotateHandle(): HandleDescriptor {
function elevatorMoveHandle(): HandleDescriptor {
return {
- kind: 'translate',
+ // Tap-to-engage: hand the elevator to its move tool (same path the
+ // floating action menu's Move button takes via `setMovingNode`) so the
+ // 3D grip and the floating-UI button share one move flow — green
+ // bounding box, alignment guides, R/T rotation, click-to-commit.
+ kind: 'tap-action',
+ shape: 'move-cross',
+ cursor: 'move',
+ onActivate: (node, _scene, editor) => editor.engageMove(node),
placement: {
position: (n) => {
const { halfZ } = elevatorOuterHalfExtents(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
- apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
- snapExtents: (n) => {
- const { halfX, halfZ } = elevatorOuterHalfExtents(n)
- const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM)
- const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM)
- const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
- return [swap ? dimZ : dimX, swap ? dimX : dimZ]
- },
}
}
@@ -205,6 +204,23 @@ export const elevatorDefinition: NodeDefinition = {
rotation: [0, e.rotation ?? 0, 0],
}
},
+ // Drag box wraps just the OUTER SHAFT × full shaft height — same footprint
+ // alignment uses, same height the rendered shell occupies. Without this
+ // override, `DragBoundingBox` would measure the whole mesh tree (per-level
+ // landing assemblies, cab interior, buttons) and the box would feel
+ // vertically off-centre when the elevator's lowest served level isn't the
+ // building origin.
+ dragBounds: (node, nodes) => {
+ const e = node as ElevatorNodeType
+ const { halfX, halfZ } = elevatorOuterHalfExtents(e)
+ const { shaftBaseY, totalHeight } = resolveElevatorLevels(e, nodes ?? {})
+ const cabHeight = Math.max(e.cabHeight, 1.4)
+ const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
+ return {
+ size: [halfX * 2, shaftHeight, halfZ * 2],
+ centerY: shaftBaseY + shaftHeight / 2,
+ }
+ },
duplicable: true,
deletable: true,
},
diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts
index 0fca3ca7..2843e17a 100644
--- a/packages/nodes/src/fence/floorplan-affordances.ts
+++ b/packages/nodes/src/fence/floorplan-affordances.ts
@@ -16,10 +16,12 @@ import {
type FencePlanPoint,
getSegmentGridStep,
isSegmentLongEnough,
+ snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
snapScalarToGrid,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
+ WALL_GRID_STEP,
} from '@pascal-app/editor'
/**
@@ -159,12 +161,14 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = {
// Endpoint move = grid snap only; the 45°-from-start angle
// snap is draft-only. Shift switches to the fine grid step for
// precision, matching the 3D fence endpoint action.
+ const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
+ gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching
diff --git a/packages/nodes/src/ridge-vent/panel.tsx b/packages/nodes/src/ridge-vent/panel.tsx
index 566c0ed8..dd0f2e7c 100644
--- a/packages/nodes/src/ridge-vent/panel.tsx
+++ b/packages/nodes/src/ridge-vent/panel.tsx
@@ -3,7 +3,6 @@
import {
type AnyNode,
type AnyNodeId,
- getActiveRoofHeight,
RidgeVentNode as RidgeVentSchema,
type RoofSegmentNode,
useScene,
@@ -217,11 +216,8 @@ export default function RidgeVentPanel() {
/>
handleUpdate({
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx
index aa993516..73886595 100644
--- a/packages/nodes/src/ridge-vent/renderer.tsx
+++ b/packages/nodes/src/ridge-vent/renderer.tsx
@@ -18,6 +18,7 @@ import {
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
+import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
import { getSurfaceY } from '../shared/roof-surface'
import { buildRidgeVentGeometry } from './geometry'
@@ -66,11 +67,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
? ({ ...storeNode, ...overrides } as RidgeVentNode)
: storeNode
- const segment = useScene((state) =>
+ const segmentStore = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
+ // Subscribe to the segment's live overrides too — when the user drags a
+ // segment handle (width / depth / wallHeight / pitch / rotation), the
+ // dimensions stream through `useLiveNodeOverrides` and don't hit the
+ // store until release. Merging them lets the ridge ride the segment in
+ // real time instead of snapping into place on commit.
+ const segmentOverrides = useLiveNodeOverrides((s) =>
+ node.roofSegmentId
+ ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined)
+ : undefined,
+ )
+ const segment: RoofSegmentNode | undefined = segmentStore
+ ? segmentOverrides
+ ? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
+ : segmentStore
+ : undefined
const geometry = useMemo(
() => buildRidgeVentGeometry(node),
@@ -109,18 +125,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
- // Seat the vent on the ridge by DERIVING its Y from the segment's current
- // surface rather than the stored `position[1]`. The ridge height comes from
- // the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered
- // the segment updates, this renderer re-runs, and the vent rides the ridge
- // down automatically — no stale floating cap. X/Z stay as authored (the vent
- // straddles the ridge line at localZ≈0).
- const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
+ // Lock the BASE position to the ridge so the vent always starts on the
+ // slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
+ // off that base (Y above ridge lift, Z away from ridge centerline). So
+ // after placement the inspector's Y / Z sliders nudge the vent off the
+ // locked ridge without losing the slope-tracking base. X is the position
+ // along the ridge — the snap re-clamps it to the segment's ridge span.
+ const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
+ const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
+ const baseZ = snap ? snap.localZ : 0
+ const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
+ // Clamp legacy stored Y (absolute peak height from earlier versions) so the
+ // vent doesn't fly off when the field was an absolute Y instead of offset.
+ const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
+ const ridgeY = baseY + yOffset
+ const ridgeZ = baseZ + (node.position[2] ?? 0)
return (
{
)
if (!hit) return
- // Snap the cursor to the ridge by zeroing localZ via the
- // segment's local frame, then convert back through the building.
+ // Project the cursor onto the segment's ridge line (clamped to the
+ // segment's ridge span). The preview then moves ALONG the ridge as the
+ // cursor moves — never off it. Flat segments have no ridge: hide.
+ const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
+ if (!snap) {
+ setPreviewPos(null)
+ return
+ }
const segObj = sceneRegistry.nodes.get(hit.segment.id)
let ridgeWorld: [number, number, number]
if (segObj) {
- const ridgeLocal = new THREE.Vector3(hit.localX, hit.localY, 0)
+ const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
segObj.updateWorldMatrix(true, false)
ridgeLocal.applyMatrix4(segObj.matrixWorld)
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
@@ -99,14 +106,15 @@ const RidgeVentTool = () => {
event.position[2],
)
if (!hit) return
+ const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
+ if (!snap) return
const state = useScene.getState()
const vent = RidgeVentNode.parse({
...ridgeVentDefinition.defaults(),
name: 'Ridge Vent',
roofSegmentId: hit.segment.id,
- // Snap Z to 0 — ridge vents straddle the ridge line.
- position: [hit.localX, hit.localY, 0],
+ position: [snap.localX, 0, snap.localZ],
rotation: 0,
})
state.createNode(vent, hit.segment.id as AnyNodeId)
diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts
index bd86a393..fcc70b6e 100644
--- a/packages/nodes/src/roof/definition.ts
+++ b/packages/nodes/src/roof/definition.ts
@@ -108,6 +108,51 @@ export const roofDefinition: NodeDefinition = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
+ // Contribute a plan AABB to the alignment-guide candidate pool so a roof
+ // (and any moving sibling) snaps against the roof's outer silhouette.
+ // Roof has no centred-box footprint — it's the union of its
+ // `roof-segment` children — so we hand the bridge a resolved `aabb`
+ // directly. The roof moves by its origin via `move-roof-tool`, so it
+ // only ever contributes static candidates; the relocatable-box path
+ // never needs to apply to roofs.
+ alignmentFootprint: (node, nodes) => {
+ const roof = node as RoofNodeType
+ if (!nodes) return null
+ const cos = Math.cos(roof.rotation ?? 0)
+ const sin = Math.sin(roof.rotation ?? 0)
+ let minX = Number.POSITIVE_INFINITY
+ let minZ = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let maxZ = Number.NEGATIVE_INFINITY
+ let any = false
+ for (const childId of roof.children ?? []) {
+ const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
+ if (segment?.type !== 'roof-segment') continue
+ const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
+ const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
+ const sCos = Math.cos(segment.rotation ?? 0)
+ const sSin = Math.sin(segment.rotation ?? 0)
+ for (const [cx, cz] of [
+ [-halfWidth, -halfDepth],
+ [halfWidth, -halfDepth],
+ [halfWidth, halfDepth],
+ [-halfWidth, halfDepth],
+ ] as const) {
+ // Segment corner → roof-local.
+ const rx = segment.position[0] + cx * sCos + cz * sSin
+ const rz = segment.position[2] - cx * sSin + cz * sCos
+ // Roof-local → world (apply roof rotation, then position).
+ const wx = roof.position[0] + rx * cos + rz * sin
+ const wz = roof.position[2] - rx * sin + rz * cos
+ if (wx < minX) minX = wx
+ if (wx > maxX) maxX = wx
+ if (wz < minZ) minZ = wz
+ if (wz > maxZ) maxZ = wz
+ any = true
+ }
+ }
+ return any ? { shape: 'aabb', minX, minZ, maxX, maxZ } : null
+ },
},
// Bespoke free-floating move (drag-to-place with R/T rotation and
diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx
index ac31d918..3c95163f 100644
--- a/packages/nodes/src/shared/move-roof-tool.tsx
+++ b/packages/nodes/src/shared/move-roof-tool.tsx
@@ -20,6 +20,7 @@ import {
import {
CursorSphere,
commitFreshPlacementSubtree,
+ DragBoundingBox,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
snapFenceDraftPoint,
@@ -56,6 +57,7 @@ export const MoveRoofTool: React.FC<{
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
+ const [previewRotation, setPreviewRotation] = useState(movingNode.rotation as number)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
@@ -187,8 +189,8 @@ export const MoveRoofTool: React.FC<{
// Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide
- // layer), so we leave them on the plain grid+corner snap. Stairs align by
- // their footprint edges; roofs keep the origin-point behavior.
+ // layer), so we leave them on the plain grid+corner snap. Both stair and
+ // roof align by their footprint bounding-box corners.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(
@@ -203,7 +205,7 @@ export const MoveRoofTool: React.FC<{
return [lx, lz]
}
const moving =
- movingNode.type === 'stair'
+ movingNode.type === 'stair' || movingNode.type === 'roof'
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
: []
const ar = resolveAlignment({
@@ -418,6 +420,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
+ setPreviewRotation(pendingRotation)
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -479,9 +482,21 @@ export const MoveRoofTool: React.FC<{
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
+ // Green footprint box during whole-stair / whole-roof moves. Skipped for
+ // segments — their cursor lives in parent-local space and the bounding box
+ // would render in the wrong frame.
+ const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
+
return (
+ {showBoundingBox && (
+
+ )}
)
}
diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts
index 470a0280..9684ea05 100644
--- a/packages/nodes/src/shared/polygon-centroid-move.ts
+++ b/packages/nodes/src/shared/polygon-centroid-move.ts
@@ -67,8 +67,15 @@ export function createPolygonCentroidMoveTarget(args: {
nodes: Record
/** 3D mesh Y the kind's system parks the group at on rebuild. */
meshY: number
+ /**
+ * Extra fields merged into the commit payload. Use for kind-specific flags
+ * that a manual drag should clear — e.g. slab `autoFromWalls: false`, so the
+ * space-detection sync stops re-deriving the polygon from walls and
+ * snapping the slab back to its original position.
+ */
+ extraCommitData?: Record
}): FloorplanMoveTargetSession {
- const { node, nodes, meshY } = args
+ const { node, nodes, meshY, extraCommitData } = args
const id = node.id as AnyNodeId
const typeGuard = node.type
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
@@ -129,12 +136,19 @@ export function createPolygonCentroidMoveTarget(args: {
// so the React render and the kind's geometry rebuild land in the same
// paint (no original-position blink). Only write `holes` for kinds that
// have them (zone has none).
- const data: { polygon: Array<[number, number]>; holes?: Array> } = {
+ const data: {
+ polygon: Array<[number, number]>
+ holes?: Array>
+ [key: string]: unknown
+ } = {
polygon: translatePolygon(originalPolygon, dx, dz),
}
if (hasHoles) {
data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz))
}
+ if (extraCommitData) {
+ Object.assign(data, extraCommitData)
+ }
useScene.getState().updateNodes([{ id, data }])
useScene.getState().markDirty(id)
useLiveTransforms.getState().clear(id)
diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts
new file mode 100644
index 00000000..8a2af476
--- /dev/null
+++ b/packages/nodes/src/shared/ridge-snap.ts
@@ -0,0 +1,48 @@
+import type { RoofSegmentNode } from '@pascal-app/core'
+
+/**
+ * Shared ridge-line snap math for ridge-vent placement + move tools.
+ *
+ * Ridge vents must sit centered on the segment's ridge — off-ridge the
+ * cap's far half dips into the higher part of the slope ("goes inside"
+ * the roof). So the placement tools clamp the cursor onto the ridge:
+ * closest-point projection along the segment's local X axis, with the X
+ * span clipped to where a real ridge actually exists for that roof type.
+ *
+ * Per roof type (the segment's ridge runs along the segment's local X):
+ * - gable / gambrel / dutch / mansard: ridge spans the full width.
+ * - hip: ridge is shortened by the hipped ends — spans width − depth.
+ * A square hip (width ≤ depth) collapses to a single apex point.
+ * - shed: no true ridge — snap to the high eave (z = -depth/2).
+ * - flat: no ridge at all → return null.
+ */
+
+// Standard lift above the analytical slope surface so the cap reads as
+// sitting on the shingle course rather than clipping into it. Shared
+// with the renderer so live ridge-Y derivation matches placement.
+export const RIDGE_LIFT = 0.12
+
+export type RidgeSnap = {
+ /** Segment-local X of the snapped ridge position. */
+ localX: number
+ /** Segment-local Z of the snapped ridge position (0 for peaked roofs). */
+ localZ: number
+}
+
+export function resolveRidgeSnap(
+ segment: RoofSegmentNode,
+ cursorLocalX: number,
+ _cursorLocalZ: number,
+): RidgeSnap | null {
+ const roofType = segment.roofType ?? 'gable'
+ if (roofType === 'flat') return null
+
+ const halfW = (segment.width ?? 0) / 2
+ const halfD = (segment.depth ?? 0) / 2
+
+ const ridgeZ = roofType === 'shed' ? -halfD : 0
+ const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW
+ const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX))
+
+ return { localX, localZ: ridgeZ }
+}
diff --git a/packages/nodes/src/slab/floorplan-move.ts b/packages/nodes/src/slab/floorplan-move.ts
index f0725a04..c4083fe7 100644
--- a/packages/nodes/src/slab/floorplan-move.ts
+++ b/packages/nodes/src/slab/floorplan-move.ts
@@ -11,4 +11,12 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
* `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild.
*/
export const slabFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) =>
- createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
+ createPolygonCentroidMoveTarget({
+ node,
+ nodes,
+ meshY: 0,
+ // A user-dragged slab is a manual edit. Clear `autoFromWalls` so the
+ // space-detection sync doesn't recompute the polygon from the wall loop
+ // and snap the slab back to its original position.
+ extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
+ })
diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx
index 54e29d22..19b36c24 100644
--- a/packages/nodes/src/slab/move-tool.tsx
+++ b/packages/nodes/src/slab/move-tool.tsx
@@ -8,7 +8,6 @@ import {
type GridEvent,
type LevelNode,
polygonAnchors,
- resolveAlignment,
type SlabNode,
sceneRegistry,
useLiveTransforms,
@@ -17,7 +16,10 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
+ getSegmentGridStep,
markToolCancelConsumed,
+ resolveAlignmentForActiveBuilding,
+ snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
triggerSFX,
useAlignmentGuides,
@@ -164,10 +166,12 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
+ const gridStep = getSegmentGridStep()
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
+ gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
})
if (
@@ -189,7 +193,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// publish a guide. Alt bypasses.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
- const result = resolveAlignment({
+ const result = resolveAlignmentForActiveBuilding({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index 151c9141..4cb01637 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -352,7 +352,14 @@ function stairRotateHandle(): HandleDescriptor {
function stairMoveHandle(): HandleDescriptor {
return {
- kind: 'translate',
+ // Tap-to-engage: hand the stair to its `MoveRoofTool` (same path the
+ // floating action menu's Move button takes via `setMovingNode`) so the
+ // 3D grip and the floating-UI button share one move flow — green
+ // bounding box, alignment guides, R/T rotation, click-to-commit.
+ kind: 'tap-action',
+ shape: 'move-cross',
+ cursor: 'move',
+ onActivate: (node, _scene, editor) => editor.engageMove(node),
placement: {
// Low to the floor at the front edge (matches the item move grip) so it
// reads as a floor-move grip and stays clear of the body resize / rotate
@@ -362,14 +369,6 @@ function stairMoveHandle(): HandleDescriptor {
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET]
},
},
- apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
- snapExtents: (n, sceneApi) => {
- const bounds = readStairMoveBounds(n, sceneApi)
- const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH)
- const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH)
- const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
- return [swap ? dimZ : dimX, swap ? dimX : dimZ]
- },
}
}
diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx
index 6771b6d2..08bce22a 100644
--- a/packages/nodes/src/wall/curve-tool.tsx
+++ b/packages/nodes/src/wall/curve-tool.tsx
@@ -16,6 +16,7 @@ import {
CursorSphere,
getSegmentGridStep,
markToolCancelConsumed,
+ snapBuildingLocalToWorldGrid,
snapScalarToGrid,
triggerSFX,
useEditor,
@@ -85,12 +86,14 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => {
const snapStep = getSegmentGridStep()
- const localX = shiftPressedRef.current
- ? event.localPosition[0]
- : snapScalarToGrid(event.localPosition[0], snapStep)
- const localZ = shiftPressedRef.current
- ? event.localPosition[2]
- : snapScalarToGrid(event.localPosition[2], snapStep)
+ // Snap the cursor on the WORLD XZ grid (still in building-local
+ // coords for the rest of the math) so a rotated building doesn't
+ // pull the curve handle off the visible grid lines.
+ const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current
+ ? [event.localPosition[0], event.localPosition[2]]
+ : snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
+ const localX = snappedLocalX
+ const localZ = snappedLocalZ
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts
index 7616a3bc..85bad124 100644
--- a/packages/nodes/src/wall/floorplan-affordances.ts
+++ b/packages/nodes/src/wall/floorplan-affordances.ts
@@ -14,10 +14,12 @@ import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isSegmentLongEnough,
+ snapBuildingLocalToWorldGrid,
snapScalarToGrid,
snapWallDraftPoint,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
+ WALL_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -111,8 +113,11 @@ export const wallCurveAffordance: FloorplanAffordance = {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapStep = getSegmentGridStep()
- const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
- const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
+ // World-grid snap so a rotated building doesn't drag the curve
+ // handle off the visible grid.
+ const [x, y] = modifiers.shiftKey
+ ? [planPoint[0], planPoint[1]]
+ : snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep)
// Signed projection of (snappedPoint - chord midpoint) onto the
// chord normal. Legacy negates because the SVG y-axis flips
@@ -186,11 +191,13 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = {
// the angle snap is for initial draft only. Shift switches to
// the fine grid step for precision, matching the 3D
// `MoveWallEndpointTool`.
+ const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
+ gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep),
})
// Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall
diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx
index 72110c61..9e4b700f 100644
--- a/packages/nodes/src/wall/move-tool.tsx
+++ b/packages/nodes/src/wall/move-tool.tsx
@@ -29,6 +29,7 @@ import {
getSegmentGridStep,
isSegmentLongEnough,
markToolCancelConsumed,
+ snapBuildingLocalToWorldGrid,
snapScalarToGrid,
triggerSFX,
useEditor,
@@ -470,9 +471,21 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta
+ } else if (shiftPressedRef.current) {
+ deltaX = rawDeltaX
+ deltaZ = rawDeltaZ
} else {
- deltaX = shiftPressedRef.current ? rawDeltaX : snapScalarToGrid(rawDeltaX, snapStep)
- deltaZ = shiftPressedRef.current ? rawDeltaZ : snapScalarToGrid(rawDeltaZ, snapStep)
+ // Snap the resulting wall center to the WORLD XZ grid (projected
+ // back into building-local), then express the result as a delta
+ // from the original centre. Without this, a rotated building
+ // dragged the wall along its local axes instead of world ones.
+ const targetLocal: [number, number] = [
+ originalCenter[0] + rawDeltaX,
+ originalCenter[1] + rawDeltaZ,
+ ]
+ const snappedLocal = snapBuildingLocalToWorldGrid(targetLocal, snapStep)
+ deltaX = snappedLocal[0] - originalCenter[0]
+ deltaZ = snappedLocal[1] - originalCenter[1]
}
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx
index cc8d7167..d0fdbc33 100644
--- a/packages/nodes/src/wall/tool.tsx
+++ b/packages/nodes/src/wall/tool.tsx
@@ -686,6 +686,13 @@ export const WallTool: React.FC = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
+ // Alt commits a single wall — stop drafting instead of chaining
+ // so the next click starts a fresh start point.
+ if (event.nativeEvent?.altKey === true) {
+ stopDrafting()
+ return
+ }
+
const nextStart = createdWall.end
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current)
diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts
index a488f924..fc4f39a1 100644
--- a/packages/nodes/src/zone/floorplan.ts
+++ b/packages/nodes/src/zone/floorplan.ts
@@ -105,6 +105,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
textAnchor: 'middle',
dominantBaseline: 'central',
opacity: showSelectedChrome ? 1 : 0.92,
+ upright: true,
})
}