Wassim SAMAD 04f1b0d59e 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>
2026-06-24 14:23:15 -04:00

177 lines
7.2 KiB
TypeScript

// The authoritative description of "what the user is currently doing".
//
// Before this, that question was answered by re-deriving from 7+ independent
// `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
// `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
// `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
// re-derived its behaviour from a different subset, so the flags could drift
// into illegal combinations (moving + curving at once; a stale `movingNode`
// after a drag ended). Collapsing them into one discriminated union makes those
// combinations unrepresentable: a scope is exactly one interaction at a time,
// and `idle` carries no interaction payload at all.
import type { AnyNode } from '@pascal-app/core'
export type InteractionView = '2d' | '3d'
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
// (rather than four sibling scopes) keeps the union small while still making
// "curving and hole-editing at once" unrepresentable.
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
export type InteractionScope =
| { kind: 'idle' }
// Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
// gizmo press-drag flavour (commit on release) vs click-to-place.
| {
kind: 'placing'
// The node being placed, carried inline: a fresh-placement / duplicate
// draft is not in the scene yet, so it cannot be recovered by id. Set once
// at `begin` and never mutated, so it is a stable reference for the gesture.
node: AnyNode
nodeId: string
nodeType: string
view: InteractionView
pressDrag: boolean
}
// Moving an existing node.
| { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
// Dragging a resize/translate/rotate handle of a selected node.
| { kind: 'handle-drag'; nodeId: string; handle: string }
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
| { kind: 'drafting'; tool: string }
// Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
// only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
| {
kind: 'reshaping'
nodeId: string
reshape: ReshapeKind
holeIndex?: number
endpoint?: 'start' | 'end'
}
// Marquee selection drag.
| { kind: 'box-select' }
// Material paint application.
| { kind: 'painting' }
export type InteractionKind = InteractionScope['kind']
export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
return scope.kind === 'idle'
}
export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
return scope.kind !== 'idle'
}
// The node a scope is acting on, if any. Drafting/box-select/painting/idle
// target no single existing node.
export function scopeNodeId(scope: InteractionScope): string | null {
switch (scope.kind) {
case 'placing':
case 'moving':
case 'handle-drag':
case 'reshaping':
return scope.nodeId
default:
return null
}
}
// The node a placing/moving scope is acting on, carried inline (see the
// `placing` variant comment). Null for every other scope. Replaces the legacy
// `useEditor.movingNode` flag: the node lives inside the discriminated union, so
// it cannot survive past the interaction's `end()`.
export function movingNodeOf(scope: InteractionScope): AnyNode | null {
return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : null
}
// Selection/hover picking is only meaningful while idle. During any active
// interaction the pointer belongs to that interaction's body, not to selecting
// a different object — the picking choke point should not route a hover/click
// to selection while this is false.
export function selectionEnabled(scope: InteractionScope): boolean {
return scope.kind === 'idle'
}
// Derived views of the scope that mirror the legacy `useEditor` flags they
// replaced. Each returns null unless that exact interaction is active, so a
// stale payload is unrepresentable: the value is a pure function of the single
// authoritative scope, not an independent flag that can drift out of sync.
// The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
// downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
}
// The legacy `editingHole` flag (`SurfaceHoleTarget`).
export function editingHoleInfo(
scope: InteractionScope,
): { nodeId: string; holeIndex: number } | null {
return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
: null
}
// Build the scope payload for a hole reshape, so producers don't re-spell the
// discriminator at every call site.
export function holeEditScope(target: {
nodeId: string
holeIndex: number
}): ActiveInteractionScope {
return {
kind: 'reshaping',
nodeId: target.nodeId,
reshape: 'hole',
holeIndex: target.holeIndex,
}
}
// True while the selected node's geometry is being curved (legacy
// `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
// recovered from the reshaped node's type, looked up from the scene by nodeId).
export function isCurveReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.reshape === 'curve'
}
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
// the duration of the drag).
export function endpointReshapeInfo(
scope: InteractionScope,
): { nodeId: string; endpoint: 'start' | 'end' } | null {
return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
? { nodeId: scope.nodeId, endpoint: scope.endpoint }
: null
}
// The id of the node being reshaped (any reshape kind), for the scene lookup
// that recovers the full node payload a few consumers still need.
export function reshapingNodeId(scope: InteractionScope): string | null {
return scope.kind === 'reshaping' ? scope.nodeId : null
}
// Builders so producers don't re-spell the discriminator at every call site.
export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'curve' }
}
export function endpointReshapeScope(
nodeId: string,
endpoint: 'start' | 'end',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
}
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'boundary' }
}