fix: sync ceiling handles during polygon edits

This commit is contained in:
Aymeric Rabot
2026-06-09 15:58:43 -04:00
parent 265acdb2be
commit f3c1407cae
2 changed files with 57 additions and 46 deletions
@@ -42,7 +42,6 @@ type CornerBracketData = {
outgoingDirection: [number, number] outgoingDirection: [number, number]
incomingLength: number incomingLength: number
outgoingLength: number outgoingLength: number
cornerStrength: number
} }
type CornerDragState = { type CornerDragState = {
@@ -135,6 +134,13 @@ const CeilingSelectionAffordance = ({
levelId: string levelId: string
}) => { }) => {
const { camera, gl } = useThree() const { camera, gl } = useThree()
const liveOverride = useLiveNodeOverrides(
(state) => state.overrides.get(ceiling.id) as Partial<CeilingNode> | undefined,
)
const effectiveCeiling = useMemo(
() => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
[ceiling, liveOverride],
)
const [levelObject, setLevelObject] = useState<Object3D | null>( const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null, () => sceneRegistry.nodes.get(levelId) ?? null,
) )
@@ -151,7 +157,7 @@ const CeilingSelectionAffordance = ({
const intersectionRef = useRef(new Vector3()) const intersectionRef = useRef(new Vector3())
const localIntersectionRef = useRef(new Vector3()) const localIntersectionRef = useRef(new Vector3())
const displayPolygon = previewPolygon ?? ceiling.polygon const displayPolygon = previewPolygon ?? effectiveCeiling.polygon
const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex
const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon]) const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon])
const highlightedEdgeIndices = useMemo(() => { const highlightedEdgeIndices = useMemo(() => {
@@ -173,13 +179,13 @@ const CeilingSelectionAffordance = ({
useEffect(() => { useEffect(() => {
if (activeCornerIndex === null) return if (activeCornerIndex === null) return
useViewer.getState().setHoveredId(ceiling.id) useViewer.getState().setHoveredId(effectiveCeiling.id)
return () => { return () => {
if (useViewer.getState().hoveredId === ceiling.id) { if (useViewer.getState().hoveredId === effectiveCeiling.id) {
useViewer.getState().setHoveredId(null) useViewer.getState().setHoveredId(null)
} }
} }
}, [activeCornerIndex, ceiling.id]) }, [activeCornerIndex, effectiveCeiling.id])
const selectCeilingForEdit = useCallback(() => { const selectCeilingForEdit = useCallback(() => {
const editor = useEditor.getState() const editor = useEditor.getState()
@@ -188,8 +194,8 @@ const CeilingSelectionAffordance = ({
editor.setCurvingWall(null) editor.setCurvingWall(null)
editor.setEditingHole(null) editor.setEditingHole(null)
editor.setMode('select') editor.setMode('select')
useViewer.getState().setSelection({ selectedIds: [ceiling.id] }) useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
}, [ceiling.id]) }, [effectiveCeiling.id])
const getHandlePlanePoint = useCallback( const getHandlePlanePoint = useCallback(
(event: MouseEvent | PointerEvent): [number, number] | null => { (event: MouseEvent | PointerEvent): [number, number] | null => {
@@ -202,7 +208,7 @@ const CeilingSelectionAffordance = ({
) )
raycasterRef.current.setFromCamera(ndcRef.current, camera) raycasterRef.current.setFromCamera(ndcRef.current, camera)
planePointRef.current.set(0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0)
levelObject.localToWorld(planePointRef.current) levelObject.localToWorld(planePointRef.current)
planeOriginRef.current.set(0, 0, 0) planeOriginRef.current.set(0, 0, 0)
@@ -219,7 +225,7 @@ const CeilingSelectionAffordance = ({
levelObject.worldToLocal(localIntersectionRef.current) levelObject.worldToLocal(localIntersectionRef.current)
return [localIntersectionRef.current.x, localIntersectionRef.current.z] return [localIntersectionRef.current.x, localIntersectionRef.current.z]
}, },
[camera, ceiling.height, gl.domElement, levelObject], [camera, effectiveCeiling.height, gl.domElement, levelObject],
) )
const handleCornerPointerDown = useCallback( const handleCornerPointerDown = useCallback(
@@ -229,14 +235,14 @@ const CeilingSelectionAffordance = ({
const startPlanePosition = getHandlePlanePoint(event.nativeEvent) const startPlanePosition = getHandlePlanePoint(event.nativeEvent)
if (!startPlanePosition) return if (!startPlanePosition) return
const initialCorner = ceiling.polygon[corner.index] const initialCorner = effectiveCeiling.polygon[corner.index]
if (!initialCorner) return if (!initialCorner) return
dragRef.current = { dragRef.current = {
ceilingId: ceiling.id, ceilingId: effectiveCeiling.id,
cornerIndex: corner.index, cornerIndex: corner.index,
didDrag: false, didDrag: false,
initialPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]), initialPolygon: effectiveCeiling.polygon.map(([x, z]) => [x, z] as [number, number]),
inputDraggingSet: false, inputDraggingSet: false,
pointerId: event.pointerId, pointerId: event.pointerId,
previewPolygon: null, previewPolygon: null,
@@ -247,13 +253,13 @@ const CeilingSelectionAffordance = ({
startPlanePosition, startPlanePosition,
} }
}, },
[ceiling.id, ceiling.polygon, getHandlePlanePoint], [effectiveCeiling.id, effectiveCeiling.polygon, getHandlePlanePoint],
) )
useEffect(() => { useEffect(() => {
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
const drag = dragRef.current const drag = dragRef.current
if (!drag || drag.ceilingId !== ceiling.id) return if (!drag || drag.ceilingId !== effectiveCeiling.id) return
if (event.pointerId !== drag.pointerId) return if (event.pointerId !== drag.pointerId) return
const dragDistance = Math.hypot( const dragDistance = Math.hypot(
@@ -344,11 +350,11 @@ const CeilingSelectionAffordance = ({
window.removeEventListener('pointercancel', cancelDrag, true) window.removeEventListener('pointercancel', cancelDrag, true)
const drag = dragRef.current const drag = dragRef.current
if (!drag || drag.ceilingId !== ceiling.id) return if (!drag || drag.ceilingId !== effectiveCeiling.id) return
dragRef.current = null dragRef.current = null
clearCornerDragPreview(drag) clearCornerDragPreview(drag)
} }
}, [ceiling.id, getHandlePlanePoint, selectCeilingForEdit]) }, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit])
useEffect(() => { useEffect(() => {
let frameId = 0 let frameId = 0
@@ -379,10 +385,10 @@ const CeilingSelectionAffordance = ({
if (!levelObject || corners.length === 0) return null if (!levelObject || corners.length === 0) return null
return createPortal( return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}> <group position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => ( {corners.map((corner, index) => (
<CornerBracket <CornerBracket
ceiling={ceiling} ceiling={effectiveCeiling}
corner={corner} corner={corner}
highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)} highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)} highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
@@ -514,7 +520,7 @@ const BracketLeg = ({
onHoverChange: (hovered: boolean) => void onHoverChange: (hovered: boolean) => void
onPointerDown: (event: ThreeEvent<PointerEvent>) => void onPointerDown: (event: ThreeEvent<PointerEvent>) => void
}) => { }) => {
const angle = Math.atan2(direction[1], direction[0]) const angle = -Math.atan2(direction[1], direction[0])
const position: [number, number, number] = [ const position: [number, number, number] = [
direction[0] * (length / 2), direction[0] * (length / 2),
0, 0,
@@ -553,7 +559,7 @@ const BracketLeg = ({
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] { function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
if (polygon.length < 3) return [] if (polygon.length < 3) return []
const allCorners = polygon.map((corner, index) => { return polygon.map((corner, index) => {
const previous = polygon[(index - 1 + polygon.length) % polygon.length]! const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
const next = polygon[(index + 1) % polygon.length]! const next = polygon[(index + 1) % polygon.length]!
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number] const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
@@ -563,11 +569,6 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return { return {
corner, corner,
@@ -578,23 +579,8 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
outgoingDirection, outgoingDirection,
incomingLength: getBracketLength(incomingLength), incomingLength: getBracketLength(incomingLength),
outgoingLength: getBracketLength(outgoingLength), outgoingLength: getBracketLength(outgoingLength),
cornerStrength,
} }
}) })
if (allCorners.length <= 4) {
return allCorners
}
const selectedIndices = new Set(
allCorners
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
.sort((a, b) => b.strength - a.strength)
.slice(0, 4)
.map(({ index }) => index),
)
return allCorners.filter((_, index) => selectedIndices.has(index))
} }
function normalize2D(vector: [number, number]): [number, number] { function normalize2D(vector: [number, number]): [number, number] {
+31 -6
View File
@@ -3,7 +3,7 @@
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor, triggerSFX } from '@pascal-app/editor' import { PolygonEditor, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
/** /**
* Phase 5 Stage D — ceiling boundary editor (registry-driven). * Phase 5 Stage D — ceiling boundary editor (registry-driven).
@@ -25,8 +25,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setHoveredId = useViewer((s) => s.setHoveredId) const setHoveredId = useViewer((s) => s.setHoveredId)
const ownsCeilingHoverRef = useRef(false) const ownsCeilingHoverRef = useRef(false)
const ownsPolygonPreviewRef = useRef(false)
const liveOverride = useLiveNodeOverrides((state) => {
if (ownsPolygonPreviewRef.current) return null
return state.overrides.get(ceilingId) as Partial<CeilingNode> | undefined
})
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const effectiveCeiling = useMemo(
() => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
[ceiling, liveOverride],
)
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
@@ -39,11 +48,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handlePolygonPreview = useCallback( const handlePolygonPreview = useCallback(
(preview: ReadonlyArray<readonly [number, number]> | null) => { (preview: ReadonlyArray<readonly [number, number]> | null) => {
if (preview) { if (preview) {
ownsPolygonPreviewRef.current = true
useLiveNodeOverrides.getState().set(ceilingId, { useLiveNodeOverrides.getState().set(ceilingId, {
polygon: preview.map(([x, z]) => [x, z] as [number, number]), polygon: preview.map(([x, z]) => [x, z] as [number, number]),
}) })
} else { } else {
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
ownsPolygonPreviewRef.current = false
} }
markDirty(ceilingId) markDirty(ceilingId)
}, },
@@ -74,15 +85,28 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handleDragStateChange = useCallback( const handleDragStateChange = useCallback(
(isDragging: boolean) => { (isDragging: boolean) => {
if (!isDragging) {
ownsPolygonPreviewRef.current = false
}
setCeilingHandleHover(isDragging) setCeilingHandleHover(isDragging)
}, },
[setCeilingHandleHover], [setCeilingHandleHover],
) )
const handlePolygonEditorDragStart = useCallback(() => {
ownsPolygonPreviewRef.current = true
triggerSFX('sfx:item-pick')
}, [])
const handlePolygonEditorBeforeVertexDrag = useCallback(() => {
ownsPolygonPreviewRef.current = true
}, [])
useEffect(() => { useEffect(() => {
return () => { return () => {
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId) useScene.getState().markDirty(ceilingId)
ownsPolygonPreviewRef.current = false
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null) useViewer.getState().setHoveredId(null)
} }
@@ -90,25 +114,26 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
} }
}, [ceilingId]) }, [ceilingId])
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null if (!effectiveCeiling?.polygon || effectiveCeiling.polygon.length < 3) return null
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove allowEdgeMove
color="#d4d4d4" color="#d4d4d4"
highlightConnectedHandles highlightConnectedHandles
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
onDragStateChange={handleDragStateChange} onDragStateChange={handleDragStateChange}
onDragCommit={() => triggerSFX('sfx:item-place')} onDragCommit={() => triggerSFX('sfx:item-place')}
onDragStart={() => triggerSFX('sfx:item-pick')} onDragStart={handlePolygonEditorDragStart}
onEdgeHoverChange={handleHandleHoverChange} onEdgeHoverChange={handleHandleHoverChange}
onMidpointHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview} onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={handleHandleHoverChange} onVertexHoverChange={handleHandleHoverChange}
polygon={ceiling.polygon} polygon={effectiveCeiling.polygon}
surfaceHeight={ceiling.height ?? 2.5} surfaceHeight={effectiveCeiling.height ?? 2.5}
/> />
) )
} }