fix: improve ceiling handle feedback and WebGPU placeholders
This commit is contained in:
+344
-23
@@ -5,19 +5,29 @@ import {
|
||||
emitter,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { Object3D } from 'three'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { snapToHalf } from '../../tools/item/placement-math'
|
||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||
|
||||
const BRACKET_THICKNESS = 0.04
|
||||
const BRACKET_HEIGHT = 0.04
|
||||
const BRACKET_Y_OFFSET = 0.035
|
||||
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
||||
const HANDLE_COLOR = '#d4d4d4'
|
||||
const HANDLE_HOVER_COLOR = '#818cf8'
|
||||
const HANDLE_OPACITY = 0.72
|
||||
const HANDLE_HOVER_OPACITY = 0.92
|
||||
const HANDLE_DRAG_THRESHOLD_PX = 4
|
||||
const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1)
|
||||
// Draw the corner handles after the ceiling surface so they read cleanly
|
||||
// when unobstructed, while material depth testing still lets other scene
|
||||
// geometry hide them.
|
||||
@@ -25,13 +35,58 @@ const CORNER_RENDER_ORDER = 1000
|
||||
|
||||
type CornerBracketData = {
|
||||
corner: [number, number]
|
||||
index: number
|
||||
incomingEdgeIndex: number
|
||||
incomingDirection: [number, number]
|
||||
outgoingEdgeIndex: number
|
||||
outgoingDirection: [number, number]
|
||||
incomingLength: number
|
||||
outgoingLength: number
|
||||
cornerStrength: number
|
||||
}
|
||||
|
||||
type CornerDragState = {
|
||||
ceilingId: CeilingNode['id']
|
||||
cornerIndex: number
|
||||
didDrag: boolean
|
||||
initialPolygon: Array<[number, number]>
|
||||
inputDraggingSet: boolean
|
||||
pointerId: number
|
||||
previewPolygon: Array<[number, number]> | null
|
||||
previousSnappedPosition: [number, number] | null
|
||||
previousInputDragging: boolean
|
||||
startClientX: number
|
||||
startClientY: number
|
||||
startPlanePosition: [number, number]
|
||||
}
|
||||
|
||||
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation()
|
||||
suppressBoxSelectForPointer(event, { markHandled: false })
|
||||
}
|
||||
|
||||
function suppressNextClick() {
|
||||
const suppressClick = (clickEvent: MouseEvent) => {
|
||||
clickEvent.stopImmediatePropagation()
|
||||
clickEvent.preventDefault()
|
||||
window.removeEventListener('click', suppressClick, true)
|
||||
}
|
||||
window.addEventListener('click', suppressClick, true)
|
||||
requestAnimationFrame(() => {
|
||||
window.removeEventListener('click', suppressClick, true)
|
||||
})
|
||||
}
|
||||
|
||||
function clearCornerDragPreview(drag: CornerDragState) {
|
||||
if (drag.didDrag) {
|
||||
useLiveNodeOverrides.getState().clear(drag.ceilingId)
|
||||
useScene.getState().markDirty(drag.ceilingId)
|
||||
}
|
||||
if (drag.inputDraggingSet) {
|
||||
useViewer.getState().setInputDragging(drag.previousInputDragging)
|
||||
}
|
||||
}
|
||||
|
||||
export const CeilingSelectionAffordanceSystem = () => {
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
@@ -79,11 +134,221 @@ const CeilingSelectionAffordance = ({
|
||||
ceiling: CeilingNode
|
||||
levelId: string
|
||||
}) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(
|
||||
() => sceneRegistry.nodes.get(levelId) ?? null,
|
||||
)
|
||||
const [hoveredCornerIndex, setHoveredCornerIndex] = useState<number | null>(null)
|
||||
const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||
const dragRef = useRef<CornerDragState | null>(null)
|
||||
const raycasterRef = useRef(new Raycaster())
|
||||
const ndcRef = useRef(new Vector2())
|
||||
const planeRef = useRef(new Plane())
|
||||
const planePointRef = useRef(new Vector3())
|
||||
const planeNormalRef = useRef(new Vector3())
|
||||
const planeOriginRef = useRef(new Vector3())
|
||||
const intersectionRef = useRef(new Vector3())
|
||||
const localIntersectionRef = useRef(new Vector3())
|
||||
|
||||
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
||||
const displayPolygon = previewPolygon ?? ceiling.polygon
|
||||
const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex
|
||||
const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon])
|
||||
const highlightedEdgeIndices = useMemo(() => {
|
||||
const next = new Set<number>()
|
||||
if (activeCornerIndex === null || displayPolygon.length < 2) return next
|
||||
next.add(activeCornerIndex)
|
||||
next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
|
||||
return next
|
||||
}, [activeCornerIndex, displayPolygon.length])
|
||||
const highlightedCornerIndices = useMemo(() => {
|
||||
const next = new Set<number>()
|
||||
if (activeCornerIndex === null || displayPolygon.length < 2) return next
|
||||
next.add(activeCornerIndex)
|
||||
next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
|
||||
next.add((activeCornerIndex + 1) % displayPolygon.length)
|
||||
return next
|
||||
}, [activeCornerIndex, displayPolygon.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCornerIndex === null) return
|
||||
|
||||
useViewer.getState().setHoveredId(ceiling.id)
|
||||
return () => {
|
||||
if (useViewer.getState().hoveredId === ceiling.id) {
|
||||
useViewer.getState().setHoveredId(null)
|
||||
}
|
||||
}
|
||||
}, [activeCornerIndex, ceiling.id])
|
||||
|
||||
const selectCeilingForEdit = useCallback(() => {
|
||||
const editor = useEditor.getState()
|
||||
editor.setMovingNode(null)
|
||||
editor.setMovingWallEndpoint(null)
|
||||
editor.setCurvingWall(null)
|
||||
editor.setEditingHole(null)
|
||||
editor.setMode('select')
|
||||
useViewer.getState().setSelection({ selectedIds: [ceiling.id] })
|
||||
}, [ceiling.id])
|
||||
|
||||
const getHandlePlanePoint = useCallback(
|
||||
(event: MouseEvent | PointerEvent): [number, number] | null => {
|
||||
if (!levelObject) return null
|
||||
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
ndcRef.current.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
raycasterRef.current.setFromCamera(ndcRef.current, camera)
|
||||
|
||||
planePointRef.current.set(0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0)
|
||||
levelObject.localToWorld(planePointRef.current)
|
||||
|
||||
planeOriginRef.current.set(0, 0, 0)
|
||||
levelObject.localToWorld(planeOriginRef.current)
|
||||
planeNormalRef.current.set(0, 1, 0)
|
||||
levelObject.localToWorld(planeNormalRef.current)
|
||||
planeNormalRef.current.sub(planeOriginRef.current).normalize()
|
||||
planeRef.current.setFromNormalAndCoplanarPoint(planeNormalRef.current, planePointRef.current)
|
||||
|
||||
const hit = raycasterRef.current.ray.intersectPlane(planeRef.current, intersectionRef.current)
|
||||
if (!hit) return null
|
||||
|
||||
localIntersectionRef.current.copy(intersectionRef.current)
|
||||
levelObject.worldToLocal(localIntersectionRef.current)
|
||||
return [localIntersectionRef.current.x, localIntersectionRef.current.z]
|
||||
},
|
||||
[camera, ceiling.height, gl.domElement, levelObject],
|
||||
)
|
||||
|
||||
const handleCornerPointerDown = useCallback(
|
||||
(corner: CornerBracketData, event: ThreeEvent<PointerEvent>) => {
|
||||
if (event.button !== 0) return
|
||||
stopHandlePointerDown(event)
|
||||
|
||||
const startPlanePosition = getHandlePlanePoint(event.nativeEvent)
|
||||
if (!startPlanePosition) return
|
||||
const initialCorner = ceiling.polygon[corner.index]
|
||||
if (!initialCorner) return
|
||||
|
||||
dragRef.current = {
|
||||
ceilingId: ceiling.id,
|
||||
cornerIndex: corner.index,
|
||||
didDrag: false,
|
||||
initialPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]),
|
||||
inputDraggingSet: false,
|
||||
pointerId: event.pointerId,
|
||||
previewPolygon: null,
|
||||
previousSnappedPosition: [initialCorner[0], initialCorner[1]],
|
||||
previousInputDragging: useViewer.getState().inputDragging,
|
||||
startClientX: event.nativeEvent.clientX,
|
||||
startClientY: event.nativeEvent.clientY,
|
||||
startPlanePosition,
|
||||
}
|
||||
},
|
||||
[ceiling.id, ceiling.polygon, getHandlePlanePoint],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.ceilingId !== ceiling.id) return
|
||||
if (event.pointerId !== drag.pointerId) return
|
||||
|
||||
const dragDistance = Math.hypot(
|
||||
event.clientX - drag.startClientX,
|
||||
event.clientY - drag.startClientY,
|
||||
)
|
||||
|
||||
const planePosition = getHandlePlanePoint(event)
|
||||
if (!planePosition) return
|
||||
|
||||
if (!drag.didDrag) {
|
||||
if (dragDistance < HANDLE_DRAG_THRESHOLD_PX) return
|
||||
|
||||
drag.didDrag = true
|
||||
drag.inputDraggingSet = true
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDraggedCornerIndex(drag.cornerIndex)
|
||||
selectCeilingForEdit()
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
}
|
||||
|
||||
const initialCorner = drag.initialPolygon[drag.cornerIndex]
|
||||
if (!initialCorner) return
|
||||
|
||||
const nextPosition: [number, number] = [
|
||||
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
|
||||
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
||||
]
|
||||
|
||||
if (
|
||||
drag.previousSnappedPosition &&
|
||||
(nextPosition[0] !== drag.previousSnappedPosition[0] ||
|
||||
nextPosition[1] !== drag.previousSnappedPosition[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
drag.previousSnappedPosition = nextPosition
|
||||
|
||||
const nextPolygon = drag.initialPolygon.map((polygonPoint, index) =>
|
||||
index === drag.cornerIndex ? nextPosition : polygonPoint,
|
||||
)
|
||||
|
||||
drag.previewPolygon = nextPolygon
|
||||
setPreviewPolygon(nextPolygon)
|
||||
useLiveNodeOverrides.getState().set(drag.ceilingId, { polygon: nextPolygon })
|
||||
useScene.getState().markDirty(drag.ceilingId)
|
||||
}
|
||||
|
||||
const finishDrag = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || event.pointerId !== drag.pointerId) return
|
||||
|
||||
dragRef.current = null
|
||||
setDraggedCornerIndex(null)
|
||||
setPreviewPolygon(null)
|
||||
|
||||
if (drag.didDrag) {
|
||||
event.preventDefault()
|
||||
suppressNextClick()
|
||||
|
||||
if (drag.previewPolygon) {
|
||||
useScene.getState().updateNode(drag.ceilingId, { polygon: drag.previewPolygon })
|
||||
useViewer.getState().setSelection({ selectedIds: [drag.ceilingId] })
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
|
||||
clearCornerDragPreview(drag)
|
||||
}
|
||||
|
||||
const cancelDrag = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || event.pointerId !== drag.pointerId) return
|
||||
|
||||
dragRef.current = null
|
||||
setDraggedCornerIndex(null)
|
||||
setPreviewPolygon(null)
|
||||
clearCornerDragPreview(drag)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', finishDrag, true)
|
||||
window.addEventListener('pointercancel', cancelDrag, true)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', finishDrag, true)
|
||||
window.removeEventListener('pointercancel', cancelDrag, true)
|
||||
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.ceilingId !== ceiling.id) return
|
||||
dragRef.current = null
|
||||
clearCornerDragPreview(drag)
|
||||
}
|
||||
}, [ceiling.id, getHandlePlanePoint, selectCeilingForEdit])
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
@@ -116,7 +381,26 @@ const CeilingSelectionAffordance = ({
|
||||
return createPortal(
|
||||
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||
{corners.map((corner, index) => (
|
||||
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
|
||||
<CornerBracket
|
||||
ceiling={ceiling}
|
||||
corner={corner}
|
||||
highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
|
||||
highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
|
||||
isHovered={activeCornerIndex === corner.index}
|
||||
isLinkedHovered={
|
||||
activeCornerIndex !== null &&
|
||||
activeCornerIndex !== corner.index &&
|
||||
highlightedCornerIndices.has(corner.index)
|
||||
}
|
||||
key={`${ceiling.id}-corner-${index}`}
|
||||
onHoverChange={(hovered) => {
|
||||
setHoveredCornerIndex((current) => {
|
||||
if (hovered) return corner.index
|
||||
return current === corner.index ? null : current
|
||||
})
|
||||
}}
|
||||
onPointerDown={(event) => handleCornerPointerDown(corner, event)}
|
||||
/>
|
||||
))}
|
||||
</group>,
|
||||
levelObject,
|
||||
@@ -126,21 +410,29 @@ const CeilingSelectionAffordance = ({
|
||||
const CornerBracket = ({
|
||||
ceiling,
|
||||
corner,
|
||||
highlightIncoming,
|
||||
highlightOutgoing,
|
||||
isHovered,
|
||||
isLinkedHovered,
|
||||
onHoverChange,
|
||||
onPointerDown,
|
||||
}: {
|
||||
ceiling: CeilingNode
|
||||
corner: CornerBracketData
|
||||
highlightIncoming: boolean
|
||||
highlightOutgoing: boolean
|
||||
isHovered: boolean
|
||||
isLinkedHovered: boolean
|
||||
onHoverChange: (hovered: boolean) => void
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const color = '#d4d4d4'
|
||||
const opacity = 0.72
|
||||
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
|
||||
const cubeOpacity = isHovered ? 0.92 : 0.72
|
||||
const cubeHighlighted = isHovered || isLinkedHovered
|
||||
const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR
|
||||
const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY
|
||||
|
||||
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
useEditor.getState().setMovingNode(null)
|
||||
useEditor.getState().setMovingWallEndpoint(null)
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
@@ -160,33 +452,39 @@ const CornerBracket = ({
|
||||
return (
|
||||
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
||||
<BracketLeg
|
||||
color={color}
|
||||
color={highlightIncoming ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
|
||||
direction={corner.incomingDirection}
|
||||
highlighted={highlightIncoming}
|
||||
length={corner.incomingLength}
|
||||
onClick={handleClick}
|
||||
opacity={opacity}
|
||||
onHoverChange={onHoverChange}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
<BracketLeg
|
||||
color={color}
|
||||
color={highlightOutgoing ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
|
||||
direction={corner.outgoingDirection}
|
||||
highlighted={highlightOutgoing}
|
||||
length={corner.outgoingLength}
|
||||
onClick={handleClick}
|
||||
opacity={opacity}
|
||||
onHoverChange={onHoverChange}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
|
||||
<mesh
|
||||
geometry={SHARED_HANDLE_BOX_GEOMETRY}
|
||||
onClick={handleClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsHovered(true)
|
||||
onHoverChange(true)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsHovered(false)
|
||||
onHoverChange(false)
|
||||
}}
|
||||
renderOrder={CORNER_RENDER_ORDER}
|
||||
scale={HIT_BOX_SIZE}
|
||||
>
|
||||
<boxGeometry args={HIT_BOX_SIZE} />
|
||||
<meshBasicMaterial
|
||||
color={cubeColor}
|
||||
depthTest
|
||||
@@ -203,14 +501,18 @@ const BracketLeg = ({
|
||||
direction,
|
||||
length,
|
||||
color,
|
||||
highlighted,
|
||||
onClick,
|
||||
opacity,
|
||||
onHoverChange,
|
||||
onPointerDown,
|
||||
}: {
|
||||
direction: [number, number]
|
||||
length: number
|
||||
color: string
|
||||
highlighted: boolean
|
||||
onClick: (e: ThreeEvent<MouseEvent>) => void
|
||||
opacity: number
|
||||
onHoverChange: (hovered: boolean) => void
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
}) => {
|
||||
const angle = Math.atan2(direction[1], direction[0])
|
||||
const position: [number, number, number] = [
|
||||
@@ -221,13 +523,29 @@ const BracketLeg = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
geometry={SHARED_HANDLE_BOX_GEOMETRY}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
onHoverChange(true)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
onHoverChange(false)
|
||||
}}
|
||||
position={position}
|
||||
renderOrder={CORNER_RENDER_ORDER}
|
||||
rotation={[0, angle, 0]}
|
||||
scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]}
|
||||
>
|
||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||
<meshBasicMaterial color={color} depthTest depthWrite={false} opacity={opacity} transparent />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest
|
||||
depthWrite={false}
|
||||
opacity={highlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
@@ -253,7 +571,10 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
|
||||
|
||||
return {
|
||||
corner,
|
||||
index,
|
||||
incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
|
||||
incomingDirection,
|
||||
outgoingEdgeIndex: index,
|
||||
outgoingDirection,
|
||||
incomingLength: getBracketLength(incomingLength),
|
||||
outgoingLength: getBracketLength(outgoingLength),
|
||||
|
||||
@@ -1,17 +1,89 @@
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { Color, type Material, type Mesh } from 'three'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
|
||||
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
|
||||
const CEILING_GRID_HIGHLIGHT_MATERIAL_KEY = '__pascalCeilingGridHighlightMaterial'
|
||||
|
||||
type CeilingGridUserData = {
|
||||
[CEILING_GRID_BASE_MATERIAL_KEY]?: Material | Material[]
|
||||
[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]?: Material | Material[]
|
||||
}
|
||||
|
||||
type HighlightableMaterial = Material & {
|
||||
color?: Color
|
||||
depthWrite?: boolean
|
||||
needsUpdate?: boolean
|
||||
opacity?: number
|
||||
transparent?: boolean
|
||||
}
|
||||
|
||||
function cloneCeilingGridHighlightMaterial(material: Material | Material[]): Material | Material[] {
|
||||
const cloneOne = (entry: Material): Material => {
|
||||
const clone = entry.clone() as HighlightableMaterial
|
||||
if (clone.color instanceof Color) {
|
||||
clone.color.set(CEILING_GRID_HIGHLIGHT_COLOR)
|
||||
}
|
||||
clone.depthWrite = false
|
||||
clone.opacity = 1
|
||||
clone.transparent = true
|
||||
clone.needsUpdate = true
|
||||
return clone
|
||||
}
|
||||
|
||||
return Array.isArray(material) ? material.map(cloneOne) : cloneOne(material)
|
||||
}
|
||||
|
||||
function disposeMaterial(material: Material | Material[] | undefined) {
|
||||
if (!material) return
|
||||
const materials = Array.isArray(material) ? material : [material]
|
||||
for (const entry of materials) {
|
||||
entry.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
|
||||
const userData = ceilingGrid.userData as CeilingGridUserData
|
||||
|
||||
if (highlighted) {
|
||||
if (!userData[CEILING_GRID_BASE_MATERIAL_KEY]) {
|
||||
userData[CEILING_GRID_BASE_MATERIAL_KEY] = ceilingGrid.material
|
||||
userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] = cloneCeilingGridHighlightMaterial(
|
||||
ceilingGrid.material,
|
||||
)
|
||||
}
|
||||
|
||||
const highlightMaterial = userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
|
||||
if (highlightMaterial) {
|
||||
ceilingGrid.material = highlightMaterial
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const baseMaterial = userData[CEILING_GRID_BASE_MATERIAL_KEY]
|
||||
if (baseMaterial) {
|
||||
ceilingGrid.material = baseMaterial
|
||||
}
|
||||
disposeMaterial(userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY])
|
||||
delete userData[CEILING_GRID_BASE_MATERIAL_KEY]
|
||||
delete userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
|
||||
}
|
||||
|
||||
export const CeilingSystem = () => {
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const selectedItem = useEditor((state) => state.selectedItem)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const hoveredId = useViewer((state) => state.hoveredId)
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null
|
||||
const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null
|
||||
|
||||
const levelsToShowCeilings = new Set<string>()
|
||||
|
||||
@@ -54,7 +126,7 @@ export const CeilingSystem = () => {
|
||||
ceilings.forEach((ceiling) => {
|
||||
const mesh = sceneRegistry.nodes.get(ceiling)
|
||||
if (mesh) {
|
||||
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
|
||||
const ceilingGrid = mesh.getObjectByName('ceiling-grid') as Mesh | undefined
|
||||
if (ceilingGrid) {
|
||||
let belongsToVisibleLevel = false
|
||||
let currentId: string | null = ceiling
|
||||
@@ -68,14 +140,18 @@ export const CeilingSystem = () => {
|
||||
currentId = node?.parentId as string | null
|
||||
}
|
||||
|
||||
const shouldHighlightGrid = ceiling === hoveredCeilingId
|
||||
const shouldShowGrid =
|
||||
belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
shouldHighlightGrid ||
|
||||
belongsToVisibleLevel ||
|
||||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
|
||||
setCeilingGridHighlighted(ceilingGrid, shouldHighlightGrid)
|
||||
ceilingGrid.visible = shouldShowGrid
|
||||
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
|
||||
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
|
||||
// meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
|
||||
// slot 0 unbound and the draw is rejected, poisoning the command encoder.
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
// Match the four material slots the roof-segment renderer's material
|
||||
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
|
||||
// groups, mesh.material is a single-material lookup that mismatches
|
||||
|
||||
@@ -9,6 +9,10 @@ type PointerEventLike = {
|
||||
nativeEvent?: PointerEvent | PointerEventLike
|
||||
}
|
||||
|
||||
type SuppressBoxSelectOptions = {
|
||||
markHandled?: boolean
|
||||
}
|
||||
|
||||
function pointerIdFor(event: PointerEvent | PointerEventLike): number | null {
|
||||
if ('pointerId' in event && typeof event.pointerId === 'number') {
|
||||
return event.pointerId
|
||||
@@ -28,8 +32,12 @@ export function markBoxSelectHandled() {
|
||||
}, 50)
|
||||
}
|
||||
|
||||
export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) {
|
||||
markBoxSelectHandled()
|
||||
export function suppressBoxSelectForPointer(
|
||||
event: PointerEvent | PointerEventLike,
|
||||
options: SuppressBoxSelectOptions = {},
|
||||
) {
|
||||
const markHandled = options.markHandled ?? true
|
||||
if (markHandled) markBoxSelectHandled()
|
||||
|
||||
const pointerId = pointerIdFor(event)
|
||||
if (pointerId === null || suppressedPointerIds.has(pointerId)) return
|
||||
@@ -38,7 +46,7 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
|
||||
|
||||
const clear = (releaseEvent?: PointerEvent) => {
|
||||
if (releaseEvent && releaseEvent.pointerId !== pointerId) return
|
||||
markBoxSelectHandled()
|
||||
if (markHandled) markBoxSelectHandled()
|
||||
suppressedPointerIds.delete(pointerId)
|
||||
const cleanup = suppressionCleanups.get(pointerId)
|
||||
suppressionCleanups.delete(pointerId)
|
||||
@@ -48,15 +56,18 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
|
||||
const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent)
|
||||
const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent)
|
||||
const onBlur = () => clear()
|
||||
// Click-preserving handle interactions need suppression cleared before
|
||||
// canvas-level pointerup handlers decide whether to block the follow-up click.
|
||||
const releaseListenerOptions = markHandled ? undefined : { capture: true }
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
window.removeEventListener('pointerup', onPointerUp, releaseListenerOptions)
|
||||
window.removeEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
|
||||
suppressionCleanups.set(pointerId, cleanup)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerCancel)
|
||||
window.addEventListener('pointerup', onPointerUp, releaseListenerOptions)
|
||||
window.addEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
|
||||
window.addEventListener('blur', onBlur)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferGeometry,
|
||||
Color,
|
||||
CylinderGeometry,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from '../../editor/node-arrow-handles'
|
||||
import { snapToHalf } from '../item/placement-math'
|
||||
import { suppressBoxSelectForPointer } from '../select/box-select-state'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
// Per-side resize arrows: indigo chevrons that match the registry arrow
|
||||
@@ -104,6 +106,8 @@ export interface PolygonEditorProps {
|
||||
onVertexHoverChange?: (vertexIndex: number | null) => void
|
||||
/** Called when a midpoint add-vertex handle enters or leaves hover. */
|
||||
onMidpointHoverChange?: (edgeIndex: number | null) => void
|
||||
/** Called when an edge move handle enters or leaves hover. */
|
||||
onEdgeHoverChange?: (edgeIndex: number | null) => void
|
||||
/** Called when any polygon drag starts or ends. */
|
||||
onDragStateChange?: (isDragging: boolean) => void
|
||||
/** Called once when a polygon drag starts. */
|
||||
@@ -114,6 +118,8 @@ export interface PolygonEditorProps {
|
||||
showBorderLine?: boolean
|
||||
/** Whether midpoint handles can add new vertices. */
|
||||
showMidpointHandles?: boolean
|
||||
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
|
||||
highlightConnectedHandles?: boolean
|
||||
/** Optional vertex handle renderer for host-specific affordances. */
|
||||
renderVertexHandle?: PolygonVertexHandleRenderer
|
||||
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
||||
@@ -127,6 +133,7 @@ export interface PolygonEditorProps {
|
||||
const MIN_HANDLE_HEIGHT = 0.15
|
||||
const EDGE_HANDLE_HEIGHT = 0.06
|
||||
const EDGE_HANDLE_THICKNESS = 0.12
|
||||
const EDGE_HANDLE_GEOMETRY = new BoxGeometry(1, 1, 1)
|
||||
|
||||
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
|
||||
const dx = end[0] - start[0]
|
||||
@@ -137,6 +144,11 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
|
||||
return [-dz / length, dx / length]
|
||||
}
|
||||
|
||||
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
|
||||
event.stopPropagation()
|
||||
suppressBoxSelectForPointer(event, { markHandled: false })
|
||||
}
|
||||
|
||||
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
|
||||
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
|
||||
|
||||
@@ -324,6 +336,47 @@ function OutlinedEdgeArrowHandle({
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedEdgeSegment({
|
||||
end,
|
||||
start,
|
||||
y,
|
||||
}: {
|
||||
end: [number, number]
|
||||
start: [number, number]
|
||||
y: number
|
||||
}) {
|
||||
const geometry = useMemo(() => {
|
||||
const nextGeometry = new BufferGeometry()
|
||||
nextGeometry.setAttribute(
|
||||
'position',
|
||||
new Float32BufferAttribute([start[0], y, start[1], end[0], y, end[1]], 3),
|
||||
)
|
||||
return nextGeometry
|
||||
}, [end, start, y])
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<line
|
||||
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={12}
|
||||
>
|
||||
<lineBasicNodeMaterial
|
||||
color={EDGE_ARROW_HOVER_COLOR}
|
||||
depthTest
|
||||
depthWrite={false}
|
||||
linewidth={4}
|
||||
opacity={0.95}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
)
|
||||
}
|
||||
|
||||
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
polygon,
|
||||
color = '#3b82f6',
|
||||
@@ -337,11 +390,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onBeforeVertexDrag,
|
||||
onVertexHoverChange,
|
||||
onMidpointHoverChange,
|
||||
onEdgeHoverChange,
|
||||
onDragStateChange,
|
||||
onDragStart,
|
||||
onDragCommit,
|
||||
showBorderLine = true,
|
||||
showMidpointHandles = true,
|
||||
highlightConnectedHandles = false,
|
||||
renderMidpointHandle,
|
||||
renderVertexHandle,
|
||||
}) => {
|
||||
@@ -442,6 +497,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
|
||||
|
||||
useEffect(() => {
|
||||
onEdgeHoverChange?.(hoveredEdge)
|
||||
}, [hoveredEdge, onEdgeHoverChange])
|
||||
|
||||
useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange])
|
||||
|
||||
const lineRef = useRef<Line>(null!)
|
||||
const previousPositionRef = useRef<[number, number] | null>(null)
|
||||
|
||||
@@ -569,6 +630,47 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})
|
||||
}, [displayPolygon])
|
||||
|
||||
const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex
|
||||
const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge
|
||||
|
||||
const highlightedEdgeIndices = useMemo(() => {
|
||||
const next = new Set<number>()
|
||||
const edgeCount = displayPolygon.length
|
||||
if (!highlightConnectedHandles || edgeCount < 2) return next
|
||||
|
||||
if (activeVertexIndex !== null && activeVertexIndex !== undefined) {
|
||||
next.add(activeVertexIndex)
|
||||
next.add((activeVertexIndex - 1 + edgeCount) % edgeCount)
|
||||
}
|
||||
if (hoveredMidpoint !== null) {
|
||||
next.add(hoveredMidpoint)
|
||||
}
|
||||
if (activeEdgeIndex !== null && activeEdgeIndex !== undefined) {
|
||||
next.add(activeEdgeIndex)
|
||||
}
|
||||
|
||||
return next
|
||||
}, [
|
||||
activeEdgeIndex,
|
||||
activeVertexIndex,
|
||||
displayPolygon.length,
|
||||
highlightConnectedHandles,
|
||||
hoveredMidpoint,
|
||||
])
|
||||
|
||||
const isVertexLinkedHighlighted = useCallback(
|
||||
(index: number) => {
|
||||
if (!highlightConnectedHandles || highlightedEdgeIndices.size === 0) return false
|
||||
const edgeCount = displayPolygon.length
|
||||
if (edgeCount < 2) return false
|
||||
return (
|
||||
highlightedEdgeIndices.has(index) ||
|
||||
highlightedEdgeIndices.has((index - 1 + edgeCount) % edgeCount)
|
||||
)
|
||||
},
|
||||
[displayPolygon.length, highlightConnectedHandles, highlightedEdgeIndices],
|
||||
)
|
||||
|
||||
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
|
||||
@@ -784,10 +886,28 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
</line>
|
||||
)}
|
||||
|
||||
{highlightConnectedHandles &&
|
||||
highlightedEdgeIndices.size > 0 &&
|
||||
Array.from(highlightedEdgeIndices).map((edgeIndex) => {
|
||||
const start = displayPolygon[edgeIndex]
|
||||
const end = displayPolygon[(edgeIndex + 1) % displayPolygon.length]
|
||||
if (!(start && end)) return null
|
||||
return (
|
||||
<HighlightedEdgeSegment
|
||||
end={end}
|
||||
key={`highlight-edge-${edgeIndex}`}
|
||||
start={start}
|
||||
y={edgeHandleY}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Vertex handles - blue cylinders that match surface height */}
|
||||
{displayPolygon.map(([x, z], index) => {
|
||||
const isHovered = hoveredVertex === index
|
||||
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
||||
const isLinkedHighlighted = isVertexLinkedHighlighted(index)
|
||||
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
||||
const radius = 0.1
|
||||
const height = handleHeight
|
||||
const point: [number, number] = [x!, z!]
|
||||
@@ -806,7 +926,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
},
|
||||
onPointerDown: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
stopHandlePointerDown(e)
|
||||
setHoveredEdge(null)
|
||||
onBeforeVertexDrag?.(index, point)
|
||||
startDrag({
|
||||
@@ -848,7 +968,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`vertex-${index}`}
|
||||
{...handleProps}
|
||||
@@ -867,7 +987,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
stopHandlePointerDown(e)
|
||||
setHoveredEdge(null)
|
||||
startDrag({
|
||||
isDragging: true,
|
||||
@@ -886,6 +1006,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
|
||||
const isHovered = hoveredEdge === index
|
||||
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
||||
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
||||
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
||||
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
|
||||
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
|
||||
|
||||
@@ -919,15 +1041,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
which sits outside the polygon and never overlaps a
|
||||
vertex/midpoint handle. */}
|
||||
<mesh
|
||||
geometry={EDGE_HANDLE_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[midpoint[0], edgeHandleY, midpoint[1]]}
|
||||
raycast={NO_RAYCAST}
|
||||
rotation={[0, rotationY, 0]}
|
||||
scale={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]}
|
||||
>
|
||||
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
||||
<meshBasicMaterial
|
||||
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
||||
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
opacity={isDragging ? 0.5 : isHighlighted ? 0.38 : 0.14}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
@@ -935,7 +1058,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
Points outward from the edge; dragging it translates only this
|
||||
edge's two vertices along the outward normal. */}
|
||||
<OutlinedEdgeArrowHandle
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
geometry={arrowGeometry}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
@@ -943,7 +1066,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
stopHandlePointerDown(e)
|
||||
beginEdgeDrag(e)
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
@@ -967,6 +1090,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
!dragState &&
|
||||
midpoints.map(([x, z], index) => {
|
||||
const isHovered = hoveredMidpoint === index
|
||||
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
||||
const isHighlighted = isHovered || isLinkedHighlighted
|
||||
const radius = 0.06
|
||||
const height = handleHeight
|
||||
const point: [number, number] = [x!, z!]
|
||||
@@ -978,7 +1103,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
},
|
||||
onPointerDown: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
stopHandlePointerDown(e)
|
||||
onBeforeVertexDrag?.(index + 1, point)
|
||||
const insertedVertex = handleAddVertex(index, point)
|
||||
if (insertedVertex.vertexIndex >= 0) {
|
||||
@@ -1021,11 +1146,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`midpoint-${index}`}
|
||||
{...handleProps}
|
||||
opacity={isHovered ? 1 : 0.7}
|
||||
opacity={isHighlighted ? 1 : 0.7}
|
||||
position={position}
|
||||
radius={radius}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { PolygonEditor, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — ceiling boundary editor (registry-driven).
|
||||
@@ -23,6 +23,8 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const markDirty = useScene((s) => s.markDirty)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||
const ownsCeilingHoverRef = useRef(false)
|
||||
|
||||
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
||||
|
||||
@@ -48,10 +50,43 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
[ceilingId, markDirty],
|
||||
)
|
||||
|
||||
const setCeilingHandleHover = useCallback(
|
||||
(active: boolean) => {
|
||||
if (active) {
|
||||
ownsCeilingHoverRef.current = true
|
||||
setHoveredId(ceilingId)
|
||||
return
|
||||
}
|
||||
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
|
||||
setHoveredId(null)
|
||||
}
|
||||
ownsCeilingHoverRef.current = false
|
||||
},
|
||||
[ceilingId, setHoveredId],
|
||||
)
|
||||
|
||||
const handleHandleHoverChange = useCallback(
|
||||
(index: number | null) => {
|
||||
setCeilingHandleHover(index !== null)
|
||||
},
|
||||
[setCeilingHandleHover],
|
||||
)
|
||||
|
||||
const handleDragStateChange = useCallback(
|
||||
(isDragging: boolean) => {
|
||||
setCeilingHandleHover(isDragging)
|
||||
},
|
||||
[setCeilingHandleHover],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
|
||||
useViewer.getState().setHoveredId(null)
|
||||
}
|
||||
ownsCeilingHoverRef.current = false
|
||||
}
|
||||
}, [ceilingId])
|
||||
|
||||
@@ -61,10 +96,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
color="#d4d4d4"
|
||||
highlightConnectedHandles
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onDragStateChange={handleDragStateChange}
|
||||
onDragCommit={() => triggerSFX('sfx:item-place')}
|
||||
onDragStart={() => triggerSFX('sfx:item-pick')}
|
||||
onEdgeHoverChange={handleHandleHoverChange}
|
||||
onMidpointHoverChange={handleHandleHoverChange}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
onVertexHoverChange={handleHandleHoverChange}
|
||||
polygon={ceiling.polygon}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
|
||||
@@ -13,13 +13,17 @@ import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
* (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0
|
||||
* is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was
|
||||
* not set", which poisons the whole command encoder (cascading into "Invalid
|
||||
* CommandBuffer" on every queue submit). Three real vertices give it a bound
|
||||
* buffer; the `groupCount` count-0 groups keep nothing drawn while matching the
|
||||
* mesh's material-array length so raycasts / BVH never index past the materials.
|
||||
* CommandBuffer" on every queue submit). The zero normals and UVs keep lit
|
||||
* node-material pipelines from compiling additional required-but-unbound
|
||||
* vertex buffers. Three real vertices give it bound buffers; the `groupCount`
|
||||
* count-0 groups keep nothing drawn while matching the mesh's material-array
|
||||
* length so raycasts / BVH never index past the materials.
|
||||
*/
|
||||
export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2))
|
||||
for (let group = 0; group < groupCount; group++) {
|
||||
geometry.addGroup(0, 0, group)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,15 @@ import {
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
Path,
|
||||
Shape,
|
||||
ShapeGeometry,
|
||||
} from 'three'
|
||||
import { MeshLambertNodeMaterial } from 'three/webgpu'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
@@ -134,6 +141,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
if (!polygonPoints || polygonPoints.length < 2) return null
|
||||
return createBoundaryLineGeometry(polygonPoints)
|
||||
}, [polygonPoints])
|
||||
useEffect(() => () => lineGeometry?.dispose(), [lineGeometry])
|
||||
|
||||
const groundGeometry = useMemo(() => {
|
||||
if (!groundShape) return null
|
||||
return new ShapeGeometry(groundShape)
|
||||
}, [groundShape])
|
||||
useEffect(() => () => groundGeometry?.dispose(), [groundGeometry])
|
||||
|
||||
const handlers = useNodeEvents(node, 'site')
|
||||
|
||||
@@ -149,15 +163,14 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
))}
|
||||
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
{groundGeometry && (
|
||||
<mesh
|
||||
geometry={groundGeometry}
|
||||
material={groundMaterial}
|
||||
position={[0, -0.05, 0]}
|
||||
receiveShadow
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
</mesh>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Simple boundary line */}
|
||||
|
||||
@@ -131,6 +131,8 @@ export function generateCeilingGeometry(
|
||||
// the whole command encoder.
|
||||
const degenerate = new THREE.BufferGeometry()
|
||||
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
return degenerate
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,14 @@ export const RoofSystem = () => {
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
|
||||
)
|
||||
placeholder.setAttribute(
|
||||
'normal',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
|
||||
)
|
||||
placeholder.setAttribute(
|
||||
'uv',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
|
||||
)
|
||||
computeGeometryBoundsTree(placeholder)
|
||||
mesh.geometry = placeholder
|
||||
}
|
||||
|
||||
@@ -531,6 +531,8 @@ function createEmptyGeometry(): THREE.BufferGeometry {
|
||||
// unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
|
||||
// poisoning the command encoder. The count-0 groups keep nothing drawn.
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
|
||||
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
|
||||
return geometry
|
||||
|
||||
Reference in New Issue
Block a user