fix: sync ceiling handles during polygon edits
This commit is contained in:
+26
-40
@@ -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<CeilingNode> | undefined,
|
||||
)
|
||||
const effectiveCeiling = useMemo(
|
||||
() => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
|
||||
[ceiling, liveOverride],
|
||||
)
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(
|
||||
() => 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(
|
||||
<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) => (
|
||||
<CornerBracket
|
||||
ceiling={ceiling}
|
||||
ceiling={effectiveCeiling}
|
||||
corner={corner}
|
||||
highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
|
||||
highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
|
||||
@@ -514,7 +520,7 @@ const BracketLeg = ({
|
||||
onHoverChange: (hovered: boolean) => 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] = [
|
||||
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] {
|
||||
|
||||
@@ -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<CeilingNode> | 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<readonly [number, number]> | 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 (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
color="#d4d4d4"
|
||||
highlightConnectedHandles
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
|
||||
onDragStateChange={handleDragStateChange}
|
||||
onDragCommit={() => 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user