registry: post-migration polish — ceiling 3D selection, ceiling item commit, floorplan move drift
Three bugs surfaced after the Stage E node-registry migration:
1. Ceiling intercepts 3D hover/click selection
Selecting via the floor-plan helper or the boundary-editor handles
is the intended flow; a direct 3D click on the ceiling should fall
through to whatever's underneath. `SelectionManager` now early-returns
on `ceiling` in onEnter/onLeave/onClick, so `event.stopPropagation`
is skipped and the ray reaches the item/wall/floor below.
2. Ceiling item placement: final click does nothing
When a ceiling-attached draft hangs in front of the ceiling-grid
mesh, the click ray hits the draft first and fires `item:click`,
not `ceiling:click`. `onItemClick` already forwards self-clicks to
shelf-surface / item-surface hosts; this PR adds the matching
ceiling branch so the commit lands on the ceiling under the cursor.
3. Floor-plan item move drift after the commit click
Two contributing causes, both fixed:
- `usePlacementCoordinator`'s `useFrame` lerped the draft mesh
toward `gridPosition.current` (the item's pre-move spot) every
frame, fighting React's render from `scene.position` while the
2D `FloorplanRegistryMoveOverlay` drove the move. Gated the lerp
on a `has3DPointerDrivenMoveRef` flag set on first 3D pointer
event — pure 3D drags are unchanged.
- The overlay's pointer-up handler skipped a final `session.apply`
and committed at the last pointermove position. Browsers don't
guarantee a pointermove right before pointerup, so a quick click
after a drag could land a few pixels off. Re-apply at pointer-up
coords so commit matches where the user actually released.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d747d2f0ea
commit
1ec65acc5f
@@ -209,6 +209,27 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// inside the SVG viewport, including empty grid background.
|
||||
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
||||
|
||||
// Apply once more at the pointer-up coords before committing.
|
||||
// Browsers don't guarantee a pointermove fires right before
|
||||
// pointerup — a quick click after a drag can land pointerup a
|
||||
// few pixels past the last pointermove. Without this re-apply,
|
||||
// the commit would freeze the item at the stale pointermove
|
||||
// position, leaving a visible drift between where the user
|
||||
// released the click and where the item lands.
|
||||
const finalPlanPoint = toMeters(event.clientX, event.clientY)
|
||||
if (finalPlanPoint) {
|
||||
hasMovedSinceStart = true
|
||||
session.apply({
|
||||
planPoint: finalPlanPoint,
|
||||
modifiers: {
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
commitFinalStateOrRevert()
|
||||
setMovingNode(null)
|
||||
|
||||
|
||||
@@ -298,6 +298,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
)
|
||||
const shiftFreeRef = useRef(false)
|
||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||
// Goes true the first time a 3D pointer event drives this coordinator.
|
||||
// The per-frame mesh-position lerp below is only useful for that path;
|
||||
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
|
||||
// writing scene.position directly), the lerp fights React's render and
|
||||
// pulls the rendered item back toward its pre-move position. Gating
|
||||
// the lerp on this flag keeps 3D placement smooth without hijacking
|
||||
// 2D drags that share the same draft.
|
||||
const has3DPointerDrivenMoveRef = useRef(false)
|
||||
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
||||
|
||||
// Store config callbacks in refs to avoid re-running effect when they change
|
||||
@@ -561,6 +569,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
configRef.current.initDraft(gridPosition.current)
|
||||
}
|
||||
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
const result = floorStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
@@ -618,6 +627,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// ---- Wall Handlers ----
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = wallStrategy.enter(
|
||||
getContext(),
|
||||
@@ -643,6 +653,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const ctx = getContext()
|
||||
|
||||
if (ctx.state.surface !== 'wall') {
|
||||
@@ -833,6 +844,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const onItemEnter = (event: ItemEvent) => {
|
||||
if (event.node.id === draftNode.current?.id) return
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const result = itemSurfaceStrategy.enter(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -849,6 +861,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const onItemMove = (event: ItemEvent) => {
|
||||
if (event.node.id === draftNode.current?.id) return
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const ctx = getContext()
|
||||
|
||||
if (ctx.state.surface !== 'item-surface') {
|
||||
@@ -990,6 +1003,41 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ceiling-hosted draft: when placing a ceiling-attached item the
|
||||
// draft hangs below the ceiling and intercepts the click ray
|
||||
// before the ceiling-grid mesh does — so `ceiling:click` never
|
||||
// fires and the user's commit click is dropped. Forward the
|
||||
// self-click to `ceilingStrategy.click` so placement commits the
|
||||
// same way it would from a click on the ceiling itself.
|
||||
if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
|
||||
const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
|
||||
if (ceilingNode && ceilingNode.type === 'ceiling') {
|
||||
const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
|
||||
const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
|
||||
if (result) {
|
||||
event.stopPropagation()
|
||||
if (draftNode.current) {
|
||||
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||
}
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
if (configRef.current.onCommitted()) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = ceilingStrategy.enter(
|
||||
getContext(),
|
||||
synthetic,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1017,6 +1065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// ---- Ceiling Handlers ----
|
||||
|
||||
const onCeilingEnter = (event: CeilingEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
if (!result) return
|
||||
@@ -1036,6 +1085,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onCeilingMove = (event: CeilingEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
||||
const nodes = useScene.getState().nodes
|
||||
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
@@ -1142,6 +1192,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// to the cursor's local-Y so the user can target a specific row.
|
||||
|
||||
const onShelfEnter = (event: ShelfEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -1156,6 +1207,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onShelfMove = (event: ShelfEvent) => {
|
||||
has3DPointerDrivenMoveRef.current = true
|
||||
const ctx = getContext()
|
||||
if (ctx.state.surface !== 'shelf-surface') {
|
||||
// Cursor entered via a move event without an enter — try
|
||||
@@ -1466,6 +1518,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
useFrame((_, delta) => {
|
||||
if (!asset) return
|
||||
if (!draftNode.current) return
|
||||
// The mesh-position lerp below only makes sense once this coordinator
|
||||
// owns the move via a 3D pointer event. Skip until then so that
|
||||
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
|
||||
// writing scene.position directly) aren't fought by useFrame pulling
|
||||
// the mesh back to its pre-move location.
|
||||
if (!has3DPointerDrivenMoveRef.current) return
|
||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||
if (!mesh) return
|
||||
|
||||
|
||||
@@ -304,6 +304,11 @@ export const SelectionManager = () => {
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const strategy = getStrategy()
|
||||
if (!strategy) return
|
||||
// Ceilings are selected via their floor-plan helper and the
|
||||
// boundary-editor vertex handles, never via a direct 3D click on
|
||||
// the polygon. Skipping selection routing here means a click on a
|
||||
// ceiling falls through to the item / wall / floor below it.
|
||||
if (event.node.type === 'ceiling') return
|
||||
if (strategy.isValid(event.node)) {
|
||||
event.stopPropagation()
|
||||
if (event.node.type === 'slab') {
|
||||
@@ -317,6 +322,7 @@ export const SelectionManager = () => {
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
const strategy = getStrategy()
|
||||
if (!strategy) return
|
||||
if (event.node.type === 'ceiling') return
|
||||
if (strategy.isValid(event.node)) {
|
||||
event.stopPropagation()
|
||||
useViewer.setState({ hoveredId: null })
|
||||
@@ -326,6 +332,7 @@ export const SelectionManager = () => {
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const strategy = getStrategy()
|
||||
if (!strategy) return
|
||||
if (event.node.type === 'ceiling') return
|
||||
if (!strategy.isValid(event.node)) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
Reference in New Issue
Block a user