feat(editor): roof/stair/elevator snapping migration + no-angle footprint draft

Migrate roof/stair/elevator draft tools + the 2D floorplan move overlay off the
legacy Shift/Alt=bypass model onto mode-driven snapping (isGridSnapActive /
isMagneticSnapActive); Alt dropped (no validity gate). stair/elevator now use the
live grid step.

These three are placed as footprints, not directional draws, so the angle-lock
mode was meaningless: add NodeDefinition.snapDraftDirectional (default true; false
for roof/stair/elevator) so their draft resolves to the no-angle 'polygon' context
(grid / lines / off). snapContextOf takes an injected draftDirectionalOf, like
profileOf. Add toolHints to stair/elevator so they route through the contextual
HUD and show the snapping chip. Fix one stale Alt-bypass comment in the item
placement coordinator (#9: already force-only). +snapping-mode test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 12:23:00 -04:00
co-authored by Claude Opus 4.8
parent 8e9a45a9d5
commit bbf9291c2d
13 changed files with 142 additions and 48 deletions
@@ -23,7 +23,7 @@ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor from '../../store/use-editor'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
@@ -509,10 +509,12 @@ export function FloorplanRegistryMoveOverlay() {
if (!m) return
// 1) Grid snap baseline. Fresh catalog placement is absolute under
// the cursor; existing moves preserve the cursor's grab offset.
// the cursor; existing moves preserve the cursor's grab offset. Grid
// follows the active snapping mode (Shift cycles it); raw cursor in
// any non-grid mode.
const gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) =>
event.shiftKey ? value : Math.round(value / gridStep) * gridStep
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]],
@@ -525,12 +527,12 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alt bypasses alignment
// entirely; Shift bypasses both grid and alignment
// hint chip.
// base rather than the raw cursor jitter. Alignment ("lines") follows
// the magnetic snapping mode — independent of grid; Alt is force-place,
// not a snap bypass.
let finalX = gridX
let finalZ = gridZ
if (!(event.altKey || event.shiftKey) && candidateAnchors.length > 0) {
if (isMagneticSnapActive() && candidateAnchors.length > 0) {
// Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere'
import {
@@ -163,7 +164,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
const alignPoint = (
gridX: number,
gridZ: number,
@@ -195,13 +197,19 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
// Grid snap follows the global mode (live step so the HUD chip is
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId,
@@ -221,7 +229,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
})
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
@@ -239,13 +247,17 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
})
if (!latestBuildingId) return
const bypassSnap = event.nativeEvent?.shiftKey === true
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
commitElevatorPlacement(
latestBuildingId,
@@ -775,9 +775,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// item's edge, snap and publish a guide. The guide connects to the
// nearest real corner of the candidate (resolver tie-break), so the dot
// always sits on an actual point. The delta is applied to BOTH the grid
// and cursor positions below. Alt (free place) bypasses all snap; the
// active snapping mode governs whether alignment runs at all ('off' /
// 'angles' disable magnetic alignment, matching the wall/fence flow).
// and cursor positions below. Alt is force-place only (it does NOT bypass
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
// governs whether alignment runs at all ('off' / 'angles' disable
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
const draft = draftNode.current
let alignX = 0
let alignZ = 0
@@ -23,7 +23,7 @@ import {
resolveAlignmentForActiveBuilding,
snapWorldXZForActiveBuilding,
} from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5
@@ -187,7 +187,8 @@ export const RoofTool: React.FC = () => {
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
const alignPoint = (
gridX: number,
gridZ: number,
@@ -241,21 +242,22 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode (grid quantize / lines alignment); Off keeps the raw
// cursor. Shift cycles the mode centrally — this tool never reads it.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const y = event.localPosition[1]
@@ -265,7 +267,7 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(gridX, gridY, gridZ)
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
corner1Ref.current &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -290,21 +292,21 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode; Off keeps the raw cursor.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const y = event.localPosition[1]
@@ -23,6 +23,7 @@ import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
@@ -319,7 +320,8 @@ export const StairTool: React.FC = () => {
// The probe is the RAW cursor, not the grid-snapped point: resolving
// against the grid point would only catch anchors that happen to sit near
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes
// keep the normal grid snap. Alt bypasses.
// keep the normal grid snap. Alignment runs only when the magnetic (lines)
// snapping mode is active.
const alignPoint = (
gridX: number,
gridZ: number,
@@ -348,20 +350,26 @@ export const StairTool: React.FC = () => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
// Grid snap follows the global mode (live step so the HUD chip is
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current)
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
@@ -372,13 +380,17 @@ export const StairTool: React.FC = () => {
}
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
return [gridX, 0, gridZ]
}
@@ -109,6 +109,7 @@ export function HelperManager() {
mode,
tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
}),
[scope, mode, tool],
)
@@ -114,4 +114,25 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
})
it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
// Roof / stair / elevator are placed as footprints, not directional draws →
// declared `snapDraftDirectional: false`, so their draft context drops the
// angle-lock mode. Directional structural kinds (no flag) stay `wall`.
const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true)
const draftCtx = (tool: string) =>
snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
expect(draftCtx('roof')).toBe('polygon')
expect(draftCtx('wall')).toBe('wall')
// Also via the explicit `drafting` scope path.
expect(
snapContextOf({
scope: { kind: 'drafting', tool: 'roof' },
mode: 'build',
tool: 'roof',
profileOf,
draftDirectionalOf,
}),
).toBe('polygon')
})
})
+11 -3
View File
@@ -137,8 +137,12 @@ export function snapContextOf(args: {
mode: string
tool: string | null
profileOf: (typeOrTool: string) => SnapProfile | undefined
// Whether drafting a kind sets a direction (angle-lock meaningful). Injected
// like `profileOf` so `snapping-mode` need not import the registry; defaults
// to `true` (the structural draw default) when not supplied.
draftDirectionalOf?: (typeOrTool: string) => boolean
}): SnapContext | null {
const { scope, mode, tool, profileOf } = args
const { scope, mode, tool, profileOf, draftDirectionalOf } = args
switch (scope.kind) {
case 'placing':
case 'moving':
@@ -150,8 +154,12 @@ export function snapContextOf(args: {
// — they use the no-angle 'polygon' set (grid / lines / off).
return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
case 'drafting':
return scope.tool ? contextForProfile(profileOf(scope.tool), true) : null
return scope.tool
? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
: null
default:
return mode === 'build' && tool ? contextForProfile(profileOf(tool), true) : null
return mode === 'build' && tool
? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)
: null
}
}
+1
View File
@@ -1191,6 +1191,7 @@ export function getActiveSnapContext(): SnapContext | null {
mode: editor.mode,
tool: editor.tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
})
}