From f3c1407cae30eec8ca0303a3a979128d77849481 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 9 Jun 2026 15:58:43 -0400 Subject: [PATCH] fix: sync ceiling handles during polygon edits --- .../ceiling-selection-affordance-system.tsx | 66 ++++++++----------- .../nodes/src/ceiling/boundary-editor.tsx | 37 +++++++++-- 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index c5435948..218beccd 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -42,7 +42,6 @@ type CornerBracketData = { outgoingDirection: [number, number] incomingLength: number outgoingLength: number - cornerStrength: number } type CornerDragState = { @@ -135,6 +134,13 @@ const CeilingSelectionAffordance = ({ levelId: string }) => { const { camera, gl } = useThree() + const liveOverride = useLiveNodeOverrides( + (state) => state.overrides.get(ceiling.id) as Partial | undefined, + ) + const effectiveCeiling = useMemo( + () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), + [ceiling, liveOverride], + ) const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) @@ -151,7 +157,7 @@ const CeilingSelectionAffordance = ({ const intersectionRef = useRef(new Vector3()) const localIntersectionRef = useRef(new Vector3()) - const displayPolygon = previewPolygon ?? ceiling.polygon + const displayPolygon = previewPolygon ?? effectiveCeiling.polygon const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon]) const highlightedEdgeIndices = useMemo(() => { @@ -173,13 +179,13 @@ const CeilingSelectionAffordance = ({ useEffect(() => { if (activeCornerIndex === null) return - useViewer.getState().setHoveredId(ceiling.id) + useViewer.getState().setHoveredId(effectiveCeiling.id) return () => { - if (useViewer.getState().hoveredId === ceiling.id) { + if (useViewer.getState().hoveredId === effectiveCeiling.id) { useViewer.getState().setHoveredId(null) } } - }, [activeCornerIndex, ceiling.id]) + }, [activeCornerIndex, effectiveCeiling.id]) const selectCeilingForEdit = useCallback(() => { const editor = useEditor.getState() @@ -188,8 +194,8 @@ const CeilingSelectionAffordance = ({ editor.setCurvingWall(null) editor.setEditingHole(null) editor.setMode('select') - useViewer.getState().setSelection({ selectedIds: [ceiling.id] }) - }, [ceiling.id]) + useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] }) + }, [effectiveCeiling.id]) const getHandlePlanePoint = useCallback( (event: MouseEvent | PointerEvent): [number, number] | null => { @@ -202,7 +208,7 @@ const CeilingSelectionAffordance = ({ ) 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) planeOriginRef.current.set(0, 0, 0) @@ -219,7 +225,7 @@ const CeilingSelectionAffordance = ({ levelObject.worldToLocal(localIntersectionRef.current) return [localIntersectionRef.current.x, localIntersectionRef.current.z] }, - [camera, ceiling.height, gl.domElement, levelObject], + [camera, effectiveCeiling.height, gl.domElement, levelObject], ) const handleCornerPointerDown = useCallback( @@ -229,14 +235,14 @@ const CeilingSelectionAffordance = ({ const startPlanePosition = getHandlePlanePoint(event.nativeEvent) if (!startPlanePosition) return - const initialCorner = ceiling.polygon[corner.index] + const initialCorner = effectiveCeiling.polygon[corner.index] if (!initialCorner) return dragRef.current = { - ceilingId: ceiling.id, + ceilingId: effectiveCeiling.id, cornerIndex: corner.index, 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, pointerId: event.pointerId, previewPolygon: null, @@ -247,13 +253,13 @@ const CeilingSelectionAffordance = ({ startPlanePosition, } }, - [ceiling.id, ceiling.polygon, getHandlePlanePoint], + [effectiveCeiling.id, effectiveCeiling.polygon, getHandlePlanePoint], ) useEffect(() => { const handlePointerMove = (event: PointerEvent) => { const drag = dragRef.current - if (!drag || drag.ceilingId !== ceiling.id) return + if (!drag || drag.ceilingId !== effectiveCeiling.id) return if (event.pointerId !== drag.pointerId) return const dragDistance = Math.hypot( @@ -344,11 +350,11 @@ const CeilingSelectionAffordance = ({ window.removeEventListener('pointercancel', cancelDrag, true) const drag = dragRef.current - if (!drag || drag.ceilingId !== ceiling.id) return + if (!drag || drag.ceilingId !== effectiveCeiling.id) return dragRef.current = null clearCornerDragPreview(drag) } - }, [ceiling.id, getHandlePlanePoint, selectCeilingForEdit]) + }, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit]) useEffect(() => { let frameId = 0 @@ -379,10 +385,10 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( void onPointerDown: (event: ThreeEvent) => void }) => { - const angle = Math.atan2(direction[1], direction[0]) + const angle = -Math.atan2(direction[1], direction[0]) const position: [number, number, number] = [ direction[0] * (length / 2), 0, @@ -553,7 +559,7 @@ const BracketLeg = ({ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] { 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 next = polygon[(index + 1) % polygon.length]! 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 outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) - const cornerStrength = - 1 - - Math.abs( - incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1], - ) return { corner, @@ -578,23 +579,8 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat outgoingDirection, incomingLength: getBracketLength(incomingLength), 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] { diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 0b06f5cf..0ae77a9d 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -3,7 +3,7 @@ import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { PolygonEditor, triggerSFX } from '@pascal-app/editor' 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). @@ -25,8 +25,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const setSelection = useViewer((s) => s.setSelection) const setHoveredId = useViewer((s) => s.setHoveredId) const ownsCeilingHoverRef = useRef(false) + const ownsPolygonPreviewRef = useRef(false) + const liveOverride = useLiveNodeOverrides((state) => { + if (ownsPolygonPreviewRef.current) return null + return state.overrides.get(ceilingId) as Partial | undefined + }) const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + const effectiveCeiling = useMemo( + () => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), + [ceiling, liveOverride], + ) const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { @@ -39,11 +48,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const handlePolygonPreview = useCallback( (preview: ReadonlyArray | null) => { if (preview) { + ownsPolygonPreviewRef.current = true useLiveNodeOverrides.getState().set(ceilingId, { polygon: preview.map(([x, z]) => [x, z] as [number, number]), }) } else { useLiveNodeOverrides.getState().clear(ceilingId) + ownsPolygonPreviewRef.current = false } markDirty(ceilingId) }, @@ -74,15 +85,28 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const handleDragStateChange = useCallback( (isDragging: boolean) => { + if (!isDragging) { + ownsPolygonPreviewRef.current = false + } setCeilingHandleHover(isDragging) }, [setCeilingHandleHover], ) + const handlePolygonEditorDragStart = useCallback(() => { + ownsPolygonPreviewRef.current = true + triggerSFX('sfx:item-pick') + }, []) + + const handlePolygonEditorBeforeVertexDrag = useCallback(() => { + ownsPolygonPreviewRef.current = true + }, []) + useEffect(() => { return () => { useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) + ownsPolygonPreviewRef.current = false if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { useViewer.getState().setHoveredId(null) } @@ -90,25 +114,26 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = } }, [ceilingId]) - if (!ceiling?.polygon || ceiling.polygon.length < 3) return null + if (!effectiveCeiling?.polygon || effectiveCeiling.polygon.length < 3) return null return ( triggerSFX('sfx:item-place')} - onDragStart={() => triggerSFX('sfx:item-pick')} + onDragStart={handlePolygonEditorDragStart} onEdgeHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} onVertexHoverChange={handleHandleHoverChange} - polygon={ceiling.polygon} - surfaceHeight={ceiling.height ?? 2.5} + polygon={effectiveCeiling.polygon} + surfaceHeight={effectiveCeiling.height ?? 2.5} /> ) }