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
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core'
import {
type FencePlanPoint,
isMagneticSnapActive,
isSegmentLongEnough,
snapFenceDraftPoint,
useAlignmentGuides,
@@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string],
bypassSnap: modifiers.shift,
magnetic: !modifiers.shift && isMagneticSnapActive(),
})
// Figma-style alignment: nudge the dragged endpoint onto another wall /
-1
View File
@@ -229,7 +229,6 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
toolHints: [
{ key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -15,6 +15,7 @@ import {
alignFloorplanDraftPoint,
type FencePlanPoint,
getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
@@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
fences: nextFences,
ignoreFenceIds: [node.id],
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
+2
View File
@@ -15,6 +15,7 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed,
snapFenceDraftPoint,
triggerSFX,
@@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
fences: levelFences,
ignoreFenceIds: [fenceId],
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
})
if (
+29 -37
View File
@@ -24,6 +24,8 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
snapFenceDraftPoint,
@@ -445,7 +447,6 @@ export const FenceTool: 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 measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
@@ -466,10 +467,13 @@ export const FenceTool: 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: FencePlanPoint, bypass: boolean): FencePlanPoint => {
if (bypass || alignmentCandidates.length === 0) {
// Figma alignment pulls the endpoint onto existing corners / edges, so it
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
// `'angles'`), matching the fence-geometry snap.
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
@@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
// While drafting, the segment locks to 15° rays from its start
// unless Shift is held. Shift also bypasses grid and magnetic snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
// While drafting, the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedLocal = alignPoint(
snapFenceDraftPoint({
point: localPoint,
@@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => {
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: isMagneticSnapActive(),
}),
bypassAlign || angleLocked,
)
@@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => {
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) {
@@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => {
)
} else {
const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
snapFenceDraftPoint({
point: localPoint,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
@@ -559,12 +567,16 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [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(
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
snapFenceDraftPoint({
point: localClick,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => {
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint(
snapFenceDraftPoint({
point: localClick,
@@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => {
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: isMagneticSnapActive(),
}),
bypassAlign || angleLocked,
)
@@ -614,20 +626,6 @@ export const FenceTool: 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()
@@ -638,17 +636,11 @@ export const FenceTool: 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)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear()
}