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.
|
// inside the SVG viewport, including empty grid background.
|
||||||
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
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()
|
commitFinalStateOrRevert()
|
||||||
setMovingNode(null)
|
setMovingNode(null)
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
)
|
)
|
||||||
const shiftFreeRef = useRef(false)
|
const shiftFreeRef = useRef(false)
|
||||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
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)
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
||||||
|
|
||||||
// Store config callbacks in refs to avoid re-running effect when they change
|
// 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)
|
configRef.current.initDraft(gridPosition.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||||
const result = floorStrategy.move(getContext(), event)
|
const result = floorStrategy.move(getContext(), event)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
@@ -618,6 +627,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// ---- Wall Handlers ----
|
// ---- Wall Handlers ----
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const result = wallStrategy.enter(
|
const result = wallStrategy.enter(
|
||||||
getContext(),
|
getContext(),
|
||||||
@@ -643,6 +653,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
|
|
||||||
if (ctx.state.surface !== 'wall') {
|
if (ctx.state.surface !== 'wall') {
|
||||||
@@ -833,6 +844,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const onItemEnter = (event: ItemEvent) => {
|
const onItemEnter = (event: ItemEvent) => {
|
||||||
if (event.node.id === draftNode.current?.id) return
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const result = itemSurfaceStrategy.enter(getContext(), event)
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
@@ -849,6 +861,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const onItemMove = (event: ItemEvent) => {
|
const onItemMove = (event: ItemEvent) => {
|
||||||
if (event.node.id === draftNode.current?.id) return
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
|
|
||||||
if (ctx.state.surface !== 'item-surface') {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,6 +1065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// ---- Ceiling Handlers ----
|
// ---- Ceiling Handlers ----
|
||||||
|
|
||||||
const onCeilingEnter = (event: CeilingEvent) => {
|
const onCeilingEnter = (event: CeilingEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
@@ -1036,6 +1085,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCeilingMove = (event: CeilingEvent) => {
|
const onCeilingMove = (event: CeilingEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, 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.
|
// to the cursor's local-Y so the user can target a specific row.
|
||||||
|
|
||||||
const onShelfEnter = (event: ShelfEvent) => {
|
const onShelfEnter = (event: ShelfEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
@@ -1156,6 +1207,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onShelfMove = (event: ShelfEvent) => {
|
const onShelfMove = (event: ShelfEvent) => {
|
||||||
|
has3DPointerDrivenMoveRef.current = true
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
if (ctx.state.surface !== 'shelf-surface') {
|
if (ctx.state.surface !== 'shelf-surface') {
|
||||||
// Cursor entered via a move event without an enter — try
|
// Cursor entered via a move event without an enter — try
|
||||||
@@ -1466,6 +1518,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
if (!draftNode.current) 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)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (!mesh) return
|
if (!mesh) return
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ export const SelectionManager = () => {
|
|||||||
const onEnter = (event: NodeEvent) => {
|
const onEnter = (event: NodeEvent) => {
|
||||||
const strategy = getStrategy()
|
const strategy = getStrategy()
|
||||||
if (!strategy) return
|
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)) {
|
if (strategy.isValid(event.node)) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.node.type === 'slab') {
|
if (event.node.type === 'slab') {
|
||||||
@@ -317,6 +322,7 @@ export const SelectionManager = () => {
|
|||||||
const onLeave = (event: NodeEvent) => {
|
const onLeave = (event: NodeEvent) => {
|
||||||
const strategy = getStrategy()
|
const strategy = getStrategy()
|
||||||
if (!strategy) return
|
if (!strategy) return
|
||||||
|
if (event.node.type === 'ceiling') return
|
||||||
if (strategy.isValid(event.node)) {
|
if (strategy.isValid(event.node)) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
useViewer.setState({ hoveredId: null })
|
useViewer.setState({ hoveredId: null })
|
||||||
@@ -326,6 +332,7 @@ export const SelectionManager = () => {
|
|||||||
const onClick = (event: NodeEvent) => {
|
const onClick = (event: NodeEvent) => {
|
||||||
const strategy = getStrategy()
|
const strategy = getStrategy()
|
||||||
if (!strategy) return
|
if (!strategy) return
|
||||||
|
if (event.node.type === 'ceiling') return
|
||||||
if (!strategy.isValid(event.node)) return
|
if (!strategy.isValid(event.node)) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
Reference in New Issue
Block a user