fix: sync ceiling handles during polygon edits
This commit is contained in:
+26
-40
@@ -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] {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user