feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf

Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:04:58 -04:00
co-authored by Claude Opus 4.8
parent b2f1a8432e
commit f773e6b8c5
71 changed files with 2362 additions and 598 deletions
+7 -5
View File
@@ -1,5 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildWallFloorplan } from './floorplan'
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move'
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
@@ -18,7 +18,8 @@ import { wallSlots } from './slots'
* `renderer` + `system` keep wrap-exporting legacy WallRenderer +
* WallSystem + WallCutout.
* Stage C: `def.floorplan` builder produces the mitered plan footprint
* polygon using `ctx.siblings` to assemble miter context.
* polygon from shared floor-plan level data, with `ctx.siblings` as the
* direct-caller fallback.
* floorplan-panel.tsx's `wallPolygons` short-circuits to [] when
* wall is registered.
*/
@@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
// Priority 4 mirrors the legacy WallSystem's useFrame priority.
priority: 4,
},
// Stage C: floor-plan rendering. ctx.siblings provides other walls in
// the level so `calculateLevelMiters` can compute correct corner joins.
// Stage C: floor-plan rendering. Precomputes the level miter graph once
// per render pass, then the builder reads its own junctions by wall id.
computeFloorplanLevelData: computeWallFloorplanLevelData,
floorplan: buildWallFloorplan,
floorplanDependsOnSiblings: true,
// 2D drag affordances triggered by `endpoint-handle` primitives in
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
// same legacy `MoveWallEndpointTool` flow, reachable from both the
@@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
toolHints: [
{ key: 'Left click', label: 'Set wall start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -13,6 +13,7 @@ import {
import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
@@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
walls,
ignoreWallIds: [node.id],
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
})
// Figma-style alignment on the dragged corner — snaps it onto another
+27 -13
View File
@@ -8,6 +8,7 @@ import {
getWallMidpointHandlePoint,
getWallPlanFootprint,
isCurvedWall,
type WallMiterData,
type WallNode,
} from '@pascal-app/core'
@@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string {
return `${Number.parseFloat(meters.toFixed(2))}m`
}
export function computeWallFloorplanLevelData({
siblings,
}: {
siblings: ReadonlyArray<WallNode>
nodes: Record<string, AnyNode>
}): WallMiterData {
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
}
/**
* Stage C floor-plan builder for wall — emits the full chrome stack the
* legacy `floorplan-panel.tsx` rendered inline:
@@ -47,21 +57,25 @@ function formatLengthMetric(meters: number): string {
* layer hosts the 5-circle stack + hover transitions + 2D drag.
* 5. A small dimension label at the midpoint when selected.
*
* `ctx.siblings` provides other walls in the level so
* `calculateLevelMiters` computes correct corner joins.
*
* Performance note: this recomputes level miter data per wall (O(N²)
* across N walls in the level). For < 100 walls per level this is
* sub-millisecond. If a real perf hotspot surfaces, the
* `ctx.levelData?.miters` extension flagged in the plan moves the batch
* computation to the dispatcher.
* `ctx.levelData` provides the shared level miter graph when the floor-plan
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
* direct builder callers.
*/
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
const all = [node, ...siblings].map(exaggerateWallThickness)
const miters = calculateLevelMiters(all)
const self = all.find((w) => w.id === node.id)
if (!self) return null
const self = exaggerateWallThickness(node)
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
// a direct builder caller with no shared data — pays the O(N) exaggerate +
// level-wide miter calc per wall; the dispatcher path is O(1) here, which is
// what keeps a wall drag from being O(N²) across the level.
const miters =
(ctx.levelData as WallMiterData | undefined) ??
calculateLevelMiters([
self,
...ctx.siblings
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
.map(exaggerateWallThickness),
])
const polygon = getWallPlanFootprint(self, miters)
if (!polygon || polygon.length < 3) return null
@@ -19,6 +19,7 @@ import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isMagneticSnapActive,
isSegmentLongEnough,
MeasurementPill,
type MovingWallEndpoint,
@@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
walls: levelWalls,
ignoreWallIds: [nodeId],
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
})
const snappedPoint = snapResult.point
+20 -42
View File
@@ -20,6 +20,8 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
snapWallDraftPointDetailed,
@@ -40,8 +42,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
*
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
* start, click 2 creates the wall. Between clicks a vertical preview
* rectangle + length/angle measurement HUD follow the pointer. Shift
* bypasses the angle snap; Esc cancels.
* rectangle + length/angle measurement HUD follow the pointer. Snapping is
* governed by the global snapping mode (`'off'` is the bypass); Esc cancels.
*
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
* stateful sequence of grid:click events, not a single drag-up.
@@ -486,7 +488,6 @@ export const WallTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111'
@@ -508,13 +509,16 @@ export const WallTool: React.FC = () => {
}
// Align the drafted point onto another object's nearest real anchor and
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
// snapping. Returns the possibly snapped point.
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
// point.
const alignPoint = (
point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean },
): WallPlanPoint => {
if (options.bypass || alignmentCandidates.length === 0) {
// Figma alignment pulls the endpoint onto existing wall corners / edges,
// so it is a line snap — suppress it whenever magnetic snap is off
// (`'off'` / `'angles'`), matching the wall-geometry snap above.
if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
@@ -546,19 +550,17 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default path: grid + magnetic snap, with 15° angle lock while
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
// or alignment snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const angleLocked = buildingState.current === 1 && !bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
// Snapping is governed entirely by the snapping mode (grid / lines /
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
// Alt still bypasses Figma-style alignment guides independently.
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
const bypassAlign = event.nativeEvent?.altKey === true
const snapResult = snapWallDraftPointDetailed({
point: localPoint,
walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
})
gridPosition = alignPoint(snapResult.point, {
applySnap: !angleLocked,
@@ -590,7 +592,6 @@ export const WallTool: React.FC = () => {
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) {
@@ -633,16 +634,14 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 0) {
const snappedStart = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
}).point,
{ bypass: bypassAlign },
)
@@ -665,15 +664,14 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
}).point,
{
applySnap: !angleLocked,
@@ -729,20 +727,6 @@ export const WallTool: React.FC = () => {
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
@@ -753,17 +737,11 @@ export const WallTool: React.FC = () => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall')