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
+11
View File
@@ -986,6 +986,17 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/ */
snapProfile?: SnapProfile snapProfile?: SnapProfile
/**
* For `structural` kinds: does drafting this kind set a DIRECTION (so the
* angle-lock snapping mode is meaningful)? Wall/fence/slab/ceiling drafting
* draws directed edges → `true` (the default). Roof/stair/elevator are placed
* as axis-aligned footprints, not directional draws → `false`, so their
* drafting uses the no-angle `polygon` snap context (grid / lines / off)
* instead of the angle-bearing `wall` context. Ignored for `item` kinds
* (their context never carries an angle lock).
*/
snapDraftDirectional?: boolean
/** /**
* Optional translucent preview of the node — used by the move tool to * Optional translucent preview of the node — used by the move tool to
* show where the node will land, and by the placement tool's cursor. * show where the node will land, and by the placement tool's cursor.
@@ -23,7 +23,7 @@ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides' 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 { useMovingNode } from '../../store/use-interaction-scope'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
@@ -509,10 +509,12 @@ export function FloorplanRegistryMoveOverlay() {
if (!m) return if (!m) return
// 1) Grid snap baseline. Fresh catalog placement is absolute under // 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 gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) => const snap = (value: number) =>
event.shiftKey ? value : Math.round(value / gridStep) * gridStep isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
const resolved = resolvePlanarCursorPosition({ const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]], cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
@@ -525,12 +527,12 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point // 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable // as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alt bypasses alignment // base rather than the raw cursor jitter. Alignment ("lines") follows
// entirely; Shift bypasses both grid and alignment // the magnetic snapping mode — independent of grid; Alt is force-place,
// hint chip. // not a snap bypass.
let finalX = gridX let finalX = gridX
let finalZ = gridZ 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 // Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed // moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a // 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 * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview' import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { import {
@@ -163,7 +164,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// point: resolving against the grid point would only ever catch anchors // 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 // 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 // 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 = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -195,13 +197,19 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
} }
const onGridMove = (event: GridEvent) => { 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( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? 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[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const supportY = resolveElevatorSupportY({ const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId, buildingId: currentBuildingId,
@@ -221,7 +229,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -239,13 +247,17 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if (!latestBuildingId) return if (!latestBuildingId) return
const bypassSnap = event.nativeEvent?.shiftKey === true const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? 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[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
commitElevatorPlacement( commitElevatorPlacement(
latestBuildingId, latestBuildingId,
@@ -775,9 +775,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// item's edge, snap and publish a guide. The guide connects to the // 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 // 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 // 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 // and cursor positions below. Alt is force-place only (it does NOT bypass
// active snapping mode governs whether alignment runs at all ('off' / // snapping — 'off' mode is the no-snap bypass); the active snapping mode
// 'angles' disable magnetic alignment, matching the wall/fence flow). // governs whether alignment runs at all ('off' / 'angles' disable
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
const draft = draftNode.current const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 0 let alignZ = 0
@@ -23,7 +23,7 @@ import {
resolveAlignmentForActiveBuilding, resolveAlignmentForActiveBuilding,
snapWorldXZForActiveBuilding, snapWorldXZForActiveBuilding,
} from '../../../lib/world-grid-snap' } 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' import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5 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 // 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 // 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 // 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 = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -241,21 +242,22 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid. Snapping follows
const bypassSnap = event.nativeEvent?.shiftKey === true // the global mode (grid quantize / lines alignment); Off keeps the raw
const snapped: [number, number] = bypassSnap // cursor. Shift cycles the mode centrally — this tool never reads it.
? [event.localPosition[0], event.localPosition[2]] const snapped: [number, number] = isGridSnapActive()
: snapWorldXZForActiveBuilding( ? snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
snapped[0], snapped[0],
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -265,7 +267,7 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(gridX, gridY, gridZ) cursorRef.current.position.set(gridX, gridY, gridZ)
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
corner1Ref.current && corner1Ref.current &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -290,21 +292,21 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return if (!currentLevelId) return
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid. Snapping follows
const bypassSnap = event.nativeEvent?.shiftKey === true // the global mode; Off keeps the raw cursor.
const snapped: [number, number] = bypassSnap const snapped: [number, number] = isGridSnapActive()
? [event.localPosition[0], event.localPosition[2]] ? snapWorldXZForActiveBuilding(
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
snapped[0], snapped[0],
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -23,6 +23,7 @@ import {
resolveStairDestinationLevel, resolveStairDestinationLevel,
resolveStairPlacementLevelId, resolveStairPlacementLevelId,
} from '../../../lib/stair-levels' } from '../../../lib/stair-levels'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -319,7 +320,8 @@ export const StairTool: React.FC = () => {
// The probe is the RAW cursor, not the grid-snapped point: resolving // 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 // 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 // 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 = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -348,20 +350,26 @@ export const StairTool: React.FC = () => {
} }
const onGridMove = (event: GridEvent) => { 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( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? 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[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const position: [number, number, number] = [gridX, 0, gridZ] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current) applyDraftPreview(position, rotationRef.current)
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (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 getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
const bypassSnap = event.nativeEvent?.shiftKey === true const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? 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[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
return [gridX, 0, gridZ] return [gridX, 0, gridZ]
} }
@@ -109,6 +109,7 @@ export function HelperManager() {
mode, mode,
tool, tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
}), }),
[scope, mode, tool], [scope, mode, tool],
) )
@@ -114,4 +114,25 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull() expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
expect(ctx({ kind: 'idle' }, 'build', 'shelf')).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 mode: string
tool: string | null tool: string | null
profileOf: (typeOrTool: string) => SnapProfile | undefined 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 { }): SnapContext | null {
const { scope, mode, tool, profileOf } = args const { scope, mode, tool, profileOf, draftDirectionalOf } = args
switch (scope.kind) { switch (scope.kind) {
case 'placing': case 'placing':
case 'moving': case 'moving':
@@ -150,8 +154,12 @@ export function snapContextOf(args: {
// — they use the no-angle 'polygon' set (grid / lines / off). // — they use the no-angle 'polygon' set (grid / lines / off).
return scope.reshape === 'endpoint' ? 'wall' : 'polygon' return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
case 'drafting': case 'drafting':
return scope.tool ? contextForProfile(profileOf(scope.tool), true) : null return scope.tool
? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
: null
default: 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, mode: editor.mode,
tool: editor.tool, tool: editor.tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
}) })
} }
+10
View File
@@ -176,6 +176,16 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: ElevatorNode, schema: ElevatorNode,
category: 'structure', category: 'structure',
snapProfile: 'structural',
// Placed as a footprint (R/T rotates), not a directional draw → no angle-lock
// mode. The toolHints presence routes it through the contextual HUD so the
// snapping chip shows during placement.
snapDraftDirectional: false,
toolHints: [
{ key: 'Left click', label: 'Place elevator' },
{ key: 'R / T', label: 'Rotate' },
{ key: 'Esc', label: 'Cancel' },
],
surfaceRole: 'joinery', surfaceRole: 'joinery',
defaults: () => { defaults: () => {
+3
View File
@@ -94,6 +94,9 @@ const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
export const roofDefinition: NodeDefinition<typeof RoofNode> = { export const roofDefinition: NodeDefinition<typeof RoofNode> = {
kind: 'roof', kind: 'roof',
snapProfile: 'structural', snapProfile: 'structural',
// Drafted as a 2-corner footprint (axis-aligned bbox), not a directional
// edge → no angle-lock mode (grid / lines / off only).
snapDraftDirectional: false,
schemaVersion: 1, schemaVersion: 1,
schema: RoofNode, schema: RoofNode,
category: 'structure', category: 'structure',
+10
View File
@@ -421,6 +421,16 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: StairNode, schema: StairNode,
category: 'structure', category: 'structure',
snapProfile: 'structural',
// Placed as a footprint (R/T rotates), not a directional draw → no angle-lock
// mode. The toolHints presence routes it through the contextual HUD so the
// snapping chip shows during placement.
snapDraftDirectional: false,
toolHints: [
{ key: 'Left click', label: 'Place stairs' },
{ key: 'R / T', label: 'Rotate' },
{ key: 'Esc', label: 'Cancel' },
],
surfaceRole: 'joinery', surfaceRole: 'joinery',
defaults: () => { defaults: () => {