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:
co-authored by
Claude Opus 4.8
parent
8e9a45a9d5
commit
bbf9291c2d
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user