feat(editor): node-declared per-context snapping + contextual HUD + painter scope

Generify the snapping/modifier HUD off the FSM scope and node declarations
instead of wall-creation-shaped, leaking pills.

- Per-context snapping (`snappingModeByContext`, persisted): wall / item /
  polygon mode-sets with exclusive modes (grid | lines | angles | off), each
  doing exactly what its chip says. Context is node-declared via the new
  `NodeDefinition.snapProfile` ('item' | 'structural'); the resolver maps
  (profile × action) → context with no per-kind switch.
- Scope-driven HUD: helper-manager reads the interaction scope; reshaping
  (endpoint/curve/boundary) and item move get their own chip, no select-hint
  leak. Rotate R/T rounds to 45°; Alt = force-place only (hidden for
  structural kinds); Shift = cycle everywhere.
- Slab/ceiling drafting: Shift=cycle, mode-aware grid/angle, Enter finishes
  (minDraftVertices); polygon boundary vertex/edge drag begins a reshaping
  scope. Fix grid/angle being ignored on boundary edit + slab creation:
  make resolveSurfacePlanPointSnap exclusive (alignment gated on magnetic) so
  grid/angles keep the snapped fallback instead of the raw cursor.
- Painter application scope: node-derived (single/object/matching/room) from
  the hovered node, cyclable via Shift, single-source HUD chip.
- Remove the redundant GridSnapControl from view-toggles (grid step lives in
  the contextual HUD now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 14:23:15 -04:00
co-authored by Claude Opus 4.8
parent b8b3d35f26
commit 04f1b0d59e
61 changed files with 1543 additions and 785 deletions
+2 -22
View File
@@ -66,7 +66,7 @@ import {
* operation.
* - **`isNew` metadata strip** — first commit after a fresh wall
* placement clears the placement marker.
* - **Activation grace** (150ms) + Shift to bypass grid snap.
* - **Activation grace** (150ms).
*
* Mounted via `def.affordanceTools.move` from `wall/definition.ts`.
*/
@@ -190,7 +190,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
@@ -462,7 +461,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const snapStep = getSegmentGridStep()
@@ -493,13 +491,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
if (axis) {
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
const snappedProj = bypassSnap ? rawProj : snapScalarToGrid(rawProj, snapStep)
const snappedProj = snapScalarToGrid(rawProj, snapStep)
const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta
} else if (bypassSnap) {
deltaX = rawDeltaX
deltaZ = rawDeltaZ
} else {
// Snap the resulting wall center to the WORLD XZ grid (projected
// back into building-local), then express the result as a delta
@@ -517,7 +512,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
!bypassSnap &&
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
@@ -633,11 +627,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
@@ -661,12 +650,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
applyPreview(nextWall.start, nextWall.end)
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal()
@@ -683,7 +666,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (shouldRestoreOnCleanup) {
@@ -698,13 +680,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal()
}
}
shiftPressedRef.current = false
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode, isNew, node.metadata, node.parentId])