diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 8100cb74..d3133c62 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -986,6 +986,17 @@ export type NodeDefinition> = { */ 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 * show where the node will land, and by the placement tool's cursor. diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index c0244416..74f599a0 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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 diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index d7cb941c..7410edb0 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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, 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 a2bba0b8..cdc502f2 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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 diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 9b30cf23..5cd769e3 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -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] diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 886abc40..9bb614a7 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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] } diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 6c5a5895..19e46161 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -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], ) diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 779b1d6d..7ca26fb3 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -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') + }) }) diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 2815fd6c..8226ca79 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -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 } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index e0e34cb9..f547f9da 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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, }) } diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts index 7f505bd3..bd181115 100644 --- a/packages/nodes/src/elevator/definition.ts +++ b/packages/nodes/src/elevator/definition.ts @@ -176,6 +176,16 @@ export const elevatorDefinition: NodeDefinition = { schemaVersion: 1, schema: ElevatorNode, 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', defaults: () => { diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index a90fd5e3..17e514d6 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -94,6 +94,9 @@ const roofHandles: HandleDescriptor[] = [roofMoveHandle()] export const roofDefinition: NodeDefinition = { kind: 'roof', 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, schema: RoofNode, category: 'structure', diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 200b8682..c12268ad 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -421,6 +421,16 @@ export const stairDefinition: NodeDefinition = { schemaVersion: 1, schema: StairNode, 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', defaults: () => {