fix: improve ceiling handle feedback and WebGPU placeholders

This commit is contained in:
Aymeric Rabot
2026-06-09 14:49:42 -04:00
parent 8ce26154d9
commit 265acdb2be
11 changed files with 661 additions and 55 deletions
@@ -5,19 +5,29 @@ import {
emitter, emitter,
resolveLevelId, resolveLevelId,
sceneRegistry, sceneRegistry,
useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Object3D } from 'three' import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' 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_THICKNESS = 0.04
const BRACKET_HEIGHT = 0.04 const BRACKET_HEIGHT = 0.04
const BRACKET_Y_OFFSET = 0.035 const BRACKET_Y_OFFSET = 0.035
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] 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 // Draw the corner handles after the ceiling surface so they read cleanly
// when unobstructed, while material depth testing still lets other scene // when unobstructed, while material depth testing still lets other scene
// geometry hide them. // geometry hide them.
@@ -25,13 +35,58 @@ const CORNER_RENDER_ORDER = 1000
type CornerBracketData = { type CornerBracketData = {
corner: [number, number] corner: [number, number]
index: number
incomingEdgeIndex: number
incomingDirection: [number, number] incomingDirection: [number, number]
outgoingEdgeIndex: number
outgoingDirection: [number, number] outgoingDirection: [number, number]
incomingLength: number incomingLength: number
outgoingLength: number outgoingLength: number
cornerStrength: 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 = () => { export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
@@ -79,11 +134,221 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode ceiling: CeilingNode
levelId: string levelId: string
}) => { }) => {
const { camera, gl } = useThree()
const [levelObject, setLevelObject] = useState<Object3D | null>( const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? 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(() => { useEffect(() => {
let frameId = 0 let frameId = 0
@@ -116,7 +381,26 @@ const CeilingSelectionAffordance = ({
return createPortal( return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}> <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => ( {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>, </group>,
levelObject, levelObject,
@@ -126,21 +410,29 @@ const CeilingSelectionAffordance = ({
const CornerBracket = ({ const CornerBracket = ({
ceiling, ceiling,
corner, corner,
highlightIncoming,
highlightOutgoing,
isHovered,
isLinkedHovered,
onHoverChange,
onPointerDown,
}: { }: {
ceiling: CeilingNode ceiling: CeilingNode
corner: CornerBracketData 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 cubeHighlighted = isHovered || isLinkedHovered
const color = '#d4d4d4' const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR
const opacity = 0.72 const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
const cubeOpacity = isHovered ? 0.92 : 0.72
const handleClick = (e: ThreeEvent<MouseEvent>) => { const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation() e.stopPropagation()
const nodes = useScene.getState().nodes
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null) useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null) useEditor.getState().setCurvingWall(null)
@@ -160,33 +452,39 @@ const CornerBracket = ({
return ( return (
<group position={[corner.corner[0], 0, corner.corner[1]]}> <group position={[corner.corner[0], 0, corner.corner[1]]}>
<BracketLeg <BracketLeg
color={color} color={highlightIncoming ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
direction={corner.incomingDirection} direction={corner.incomingDirection}
highlighted={highlightIncoming}
length={corner.incomingLength} length={corner.incomingLength}
onClick={handleClick} onClick={handleClick}
opacity={opacity} onHoverChange={onHoverChange}
onPointerDown={onPointerDown}
/> />
<BracketLeg <BracketLeg
color={color} color={highlightOutgoing ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
direction={corner.outgoingDirection} direction={corner.outgoingDirection}
highlighted={highlightOutgoing}
length={corner.outgoingLength} length={corner.outgoingLength}
onClick={handleClick} onClick={handleClick}
opacity={opacity} onHoverChange={onHoverChange}
onPointerDown={onPointerDown}
/> />
<mesh <mesh
geometry={SHARED_HANDLE_BOX_GEOMETRY}
onClick={handleClick} onClick={handleClick}
onPointerDown={onPointerDown}
onPointerEnter={(e) => { onPointerEnter={(e) => {
e.stopPropagation() e.stopPropagation()
setIsHovered(true) onHoverChange(true)
}} }}
onPointerLeave={(e) => { onPointerLeave={(e) => {
e.stopPropagation() e.stopPropagation()
setIsHovered(false) onHoverChange(false)
}} }}
renderOrder={CORNER_RENDER_ORDER} renderOrder={CORNER_RENDER_ORDER}
scale={HIT_BOX_SIZE}
> >
<boxGeometry args={HIT_BOX_SIZE} />
<meshBasicMaterial <meshBasicMaterial
color={cubeColor} color={cubeColor}
depthTest depthTest
@@ -203,14 +501,18 @@ const BracketLeg = ({
direction, direction,
length, length,
color, color,
highlighted,
onClick, onClick,
opacity, onHoverChange,
onPointerDown,
}: { }: {
direction: [number, number] direction: [number, number]
length: number length: number
color: string color: string
highlighted: boolean
onClick: (e: ThreeEvent<MouseEvent>) => void 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 angle = Math.atan2(direction[1], direction[0])
const position: [number, number, number] = [ const position: [number, number, number] = [
@@ -221,13 +523,29 @@ const BracketLeg = ({
return ( return (
<mesh <mesh
geometry={SHARED_HANDLE_BOX_GEOMETRY}
onClick={onClick} onClick={onClick}
onPointerDown={onPointerDown}
onPointerEnter={(e) => {
e.stopPropagation()
onHoverChange(true)
}}
onPointerLeave={(e) => {
e.stopPropagation()
onHoverChange(false)
}}
position={position} position={position}
renderOrder={CORNER_RENDER_ORDER} renderOrder={CORNER_RENDER_ORDER}
rotation={[0, angle, 0]} rotation={[0, angle, 0]}
scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]}
> >
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} /> <meshBasicMaterial
<meshBasicMaterial color={color} depthTest depthWrite={false} opacity={opacity} transparent /> color={color}
depthTest
depthWrite={false}
opacity={highlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY}
transparent
/>
</mesh> </mesh>
) )
} }
@@ -253,7 +571,10 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
return { return {
corner, corner,
index,
incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
incomingDirection, incomingDirection,
outgoingEdgeIndex: index,
outgoingDirection, outgoingDirection,
incomingLength: getBracketLength(incomingLength), incomingLength: getBracketLength(incomingLength),
outgoingLength: getBracketLength(outgoingLength), outgoingLength: getBracketLength(outgoingLength),
@@ -1,17 +1,89 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Color, type Material, type Mesh } from 'three'
import useEditor from '../../../store/use-editor' 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 = () => { export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem) const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const hoveredId = useViewer((state) => state.hoveredId)
useEffect(() => { useEffect(() => {
const nodes = useScene.getState().nodes 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>() const levelsToShowCeilings = new Set<string>()
@@ -54,7 +126,7 @@ export const CeilingSystem = () => {
ceilings.forEach((ceiling) => { ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling) const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) { if (mesh) {
const ceilingGrid = mesh.getObjectByName('ceiling-grid') const ceilingGrid = mesh.getObjectByName('ceiling-grid') as Mesh | undefined
if (ceilingGrid) { if (ceilingGrid) {
let belongsToVisibleLevel = false let belongsToVisibleLevel = false
let currentId: string | null = ceiling let currentId: string | null = ceiling
@@ -68,14 +140,18 @@ export const CeilingSystem = () => {
currentId = node?.parentId as string | null currentId = node?.parentId as string | null
} }
const shouldHighlightGrid = ceiling === hoveredCeilingId
const shouldShowGrid = const shouldShowGrid =
belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive) shouldHighlightGrid ||
belongsToVisibleLevel ||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
setCeilingGridHighlighted(ceilingGrid, shouldHighlightGrid)
ceilingGrid.visible = shouldShowGrid ceilingGrid.visible = shouldShowGrid
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden 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 return null
} }
@@ -18,6 +18,8 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
// meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer // meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
// slot 0 unbound and the draw is rejected, poisoning the command encoder. // slot 0 unbound and the draw is rejected, poisoning the command encoder.
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) 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 // Match the four material slots the roof-segment renderer's material
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
// groups, mesh.material is a single-material lookup that mismatches // groups, mesh.material is a single-material lookup that mismatches
@@ -9,6 +9,10 @@ type PointerEventLike = {
nativeEvent?: PointerEvent | PointerEventLike nativeEvent?: PointerEvent | PointerEventLike
} }
type SuppressBoxSelectOptions = {
markHandled?: boolean
}
function pointerIdFor(event: PointerEvent | PointerEventLike): number | null { function pointerIdFor(event: PointerEvent | PointerEventLike): number | null {
if ('pointerId' in event && typeof event.pointerId === 'number') { if ('pointerId' in event && typeof event.pointerId === 'number') {
return event.pointerId return event.pointerId
@@ -28,8 +32,12 @@ export function markBoxSelectHandled() {
}, 50) }, 50)
} }
export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) { export function suppressBoxSelectForPointer(
markBoxSelectHandled() event: PointerEvent | PointerEventLike,
options: SuppressBoxSelectOptions = {},
) {
const markHandled = options.markHandled ?? true
if (markHandled) markBoxSelectHandled()
const pointerId = pointerIdFor(event) const pointerId = pointerIdFor(event)
if (pointerId === null || suppressedPointerIds.has(pointerId)) return if (pointerId === null || suppressedPointerIds.has(pointerId)) return
@@ -38,7 +46,7 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
const clear = (releaseEvent?: PointerEvent) => { const clear = (releaseEvent?: PointerEvent) => {
if (releaseEvent && releaseEvent.pointerId !== pointerId) return if (releaseEvent && releaseEvent.pointerId !== pointerId) return
markBoxSelectHandled() if (markHandled) markBoxSelectHandled()
suppressedPointerIds.delete(pointerId) suppressedPointerIds.delete(pointerId)
const cleanup = suppressionCleanups.get(pointerId) const cleanup = suppressionCleanups.get(pointerId)
suppressionCleanups.delete(pointerId) suppressionCleanups.delete(pointerId)
@@ -48,15 +56,18 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent) const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent) const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onBlur = () => clear() 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 = () => { const cleanup = () => {
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp, releaseListenerOptions)
window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
window.removeEventListener('blur', onBlur) window.removeEventListener('blur', onBlur)
} }
suppressionCleanups.set(pointerId, cleanup) suppressionCleanups.set(pointerId, cleanup)
window.addEventListener('pointerup', onPointerUp) window.addEventListener('pointerup', onPointerUp, releaseListenerOptions)
window.addEventListener('pointercancel', onPointerCancel) window.addEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
window.addEventListener('blur', onBlur) 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 { createPortal, type ThreeEvent } from '@react-three/fiber'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
BoxGeometry,
BufferGeometry, BufferGeometry,
Color, Color,
CylinderGeometry, CylinderGeometry,
@@ -24,6 +25,7 @@ import {
useInvisibleHitAreaMaterial, useInvisibleHitAreaMaterial,
} from '../../editor/node-arrow-handles' } from '../../editor/node-arrow-handles'
import { snapToHalf } from '../item/placement-math' import { snapToHalf } from '../item/placement-math'
import { suppressBoxSelectForPointer } from '../select/box-select-state'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
// Per-side resize arrows: indigo chevrons that match the registry arrow // Per-side resize arrows: indigo chevrons that match the registry arrow
@@ -104,6 +106,8 @@ export interface PolygonEditorProps {
onVertexHoverChange?: (vertexIndex: number | null) => void onVertexHoverChange?: (vertexIndex: number | null) => void
/** Called when a midpoint add-vertex handle enters or leaves hover. */ /** Called when a midpoint add-vertex handle enters or leaves hover. */
onMidpointHoverChange?: (edgeIndex: number | null) => void 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. */ /** Called when any polygon drag starts or ends. */
onDragStateChange?: (isDragging: boolean) => void onDragStateChange?: (isDragging: boolean) => void
/** Called once when a polygon drag starts. */ /** Called once when a polygon drag starts. */
@@ -114,6 +118,8 @@ export interface PolygonEditorProps {
showBorderLine?: boolean showBorderLine?: boolean
/** Whether midpoint handles can add new vertices. */ /** Whether midpoint handles can add new vertices. */
showMidpointHandles?: boolean 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. */ /** Optional vertex handle renderer for host-specific affordances. */
renderVertexHandle?: PolygonVertexHandleRenderer renderVertexHandle?: PolygonVertexHandleRenderer
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */ /** Optional midpoint handle renderer for host-specific add-vertex affordances. */
@@ -127,6 +133,7 @@ export interface PolygonEditorProps {
const MIN_HANDLE_HEIGHT = 0.15 const MIN_HANDLE_HEIGHT = 0.15
const EDGE_HANDLE_HEIGHT = 0.06 const EDGE_HANDLE_HEIGHT = 0.06
const EDGE_HANDLE_THICKNESS = 0.12 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 { function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
const dx = end[0] - start[0] const dx = end[0] - start[0]
@@ -137,6 +144,11 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
return [-dz / length, dx / length] return [-dz / length, dx / length]
} }
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
suppressBoxSelectForPointer(event, { markHandled: false })
}
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => 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> = ({ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon, polygon,
color = '#3b82f6', color = '#3b82f6',
@@ -337,11 +390,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onBeforeVertexDrag, onBeforeVertexDrag,
onVertexHoverChange, onVertexHoverChange,
onMidpointHoverChange, onMidpointHoverChange,
onEdgeHoverChange,
onDragStateChange, onDragStateChange,
onDragStart, onDragStart,
onDragCommit, onDragCommit,
showBorderLine = true, showBorderLine = true,
showMidpointHandles = true, showMidpointHandles = true,
highlightConnectedHandles = false,
renderMidpointHandle, renderMidpointHandle,
renderVertexHandle, renderVertexHandle,
}) => { }) => {
@@ -442,6 +497,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange]) useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
useEffect(() => {
onEdgeHoverChange?.(hoveredEdge)
}, [hoveredEdge, onEdgeHoverChange])
useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange])
const lineRef = useRef<Line>(null!) const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null) const previousPositionRef = useRef<[number, number] | null>(null)
@@ -569,6 +630,47 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}) })
}, [displayPolygon]) }, [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(), []) const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
@@ -784,10 +886,28 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
</line> </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 */} {/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => { {displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const isLinkedHighlighted = isVertexLinkedHighlighted(index)
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
const radius = 0.1 const radius = 0.1
const height = handleHeight const height = handleHeight
const point: [number, number] = [x!, z!] const point: [number, number] = [x!, z!]
@@ -806,7 +926,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}, },
onPointerDown: (e) => { onPointerDown: (e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() stopHandlePointerDown(e)
setHoveredEdge(null) setHoveredEdge(null)
onBeforeVertexDrag?.(index, point) onBeforeVertexDrag?.(index, point)
startDrag({ startDrag({
@@ -848,7 +968,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return ( return (
<OutlinedCylinderHandle <OutlinedCylinderHandle
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR} color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height} height={height}
key={`vertex-${index}`} key={`vertex-${index}`}
{...handleProps} {...handleProps}
@@ -867,7 +987,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() stopHandlePointerDown(e)
setHoveredEdge(null) setHoveredEdge(null)
startDrag({ startDrag({
isDragging: true, isDragging: true,
@@ -886,6 +1006,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => { edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
const isHovered = hoveredEdge === index const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === 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 arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
const arrowZ = midpoint[1] + outwardNormal[1] * 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 which sits outside the polygon and never overlaps a
vertex/midpoint handle. */} vertex/midpoint handle. */}
<mesh <mesh
geometry={EDGE_HANDLE_GEOMETRY}
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
position={[midpoint[0], edgeHandleY, midpoint[1]]} position={[midpoint[0], edgeHandleY, midpoint[1]]}
raycast={NO_RAYCAST} raycast={NO_RAYCAST}
rotation={[0, rotationY, 0]} rotation={[0, rotationY, 0]}
scale={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]}
> >
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshBasicMaterial <meshBasicMaterial
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR} color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14} opacity={isDragging ? 0.5 : isHighlighted ? 0.38 : 0.14}
transparent transparent
/> />
</mesh> </mesh>
@@ -935,7 +1058,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
Points outward from the edge; dragging it translates only this Points outward from the edge; dragging it translates only this
edge's two vertices along the outward normal. */} edge's two vertices along the outward normal. */}
<OutlinedEdgeArrowHandle <OutlinedEdgeArrowHandle
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR} color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
geometry={arrowGeometry} geometry={arrowGeometry}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
@@ -943,7 +1066,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() stopHandlePointerDown(e)
beginEdgeDrag(e) beginEdgeDrag(e)
}} }}
onPointerEnter={(e) => { onPointerEnter={(e) => {
@@ -967,6 +1090,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
!dragState && !dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index const isHovered = hoveredMidpoint === index
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
const isHighlighted = isHovered || isLinkedHighlighted
const radius = 0.06 const radius = 0.06
const height = handleHeight const height = handleHeight
const point: [number, number] = [x!, z!] const point: [number, number] = [x!, z!]
@@ -978,7 +1103,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}, },
onPointerDown: (e) => { onPointerDown: (e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() stopHandlePointerDown(e)
onBeforeVertexDrag?.(index + 1, point) onBeforeVertexDrag?.(index + 1, point)
const insertedVertex = handleAddVertex(index, point) const insertedVertex = handleAddVertex(index, point)
if (insertedVertex.vertexIndex >= 0) { if (insertedVertex.vertexIndex >= 0) {
@@ -1021,11 +1146,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return ( return (
<OutlinedCylinderHandle <OutlinedCylinderHandle
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR} color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height} height={height}
key={`midpoint-${index}`} key={`midpoint-${index}`}
{...handleProps} {...handleProps}
opacity={isHovered ? 1 : 0.7} opacity={isHighlighted ? 1 : 0.7}
position={position} position={position}
radius={radius} radius={radius}
/> />
+44 -2
View File
@@ -1,9 +1,9 @@
'use client' 'use client'
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' 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 { 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). * 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 updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty) const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection) 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 const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
@@ -48,10 +50,43 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
[ceilingId, markDirty], [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(() => { useEffect(() => {
return () => { return () => {
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId) useScene.getState().markDirty(ceilingId)
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null)
}
ownsCeilingHoverRef.current = false
} }
}, [ceilingId]) }, [ceilingId])
@@ -61,10 +96,17 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
<PolygonEditor <PolygonEditor
allowEdgeMove allowEdgeMove
color="#d4d4d4" color="#d4d4d4"
highlightConnectedHandles
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3} minVertices={3}
onDragStateChange={handleDragStateChange}
onDragCommit={() => triggerSFX('sfx:item-place')}
onDragStart={() => triggerSFX('sfx:item-pick')}
onEdgeHoverChange={handleHandleHoverChange}
onMidpointHoverChange={handleHandleHoverChange}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview} onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={handleHandleHoverChange}
polygon={ceiling.polygon} polygon={ceiling.polygon}
surfaceHeight={ceiling.height ?? 2.5} 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 * (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 * 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 * not set", which poisons the whole command encoder (cascading into "Invalid
* CommandBuffer" on every queue submit). Three real vertices give it a bound * CommandBuffer" on every queue submit). The zero normals and UVs keep lit
* buffer; the `groupCount` count-0 groups keep nothing drawn while matching the * node-material pipelines from compiling additional required-but-unbound
* mesh's material-array length so raycasts / BVH never index past the materials. * 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 { export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
const geometry = new BufferGeometry() const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) 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++) { for (let group = 0; group < groupCount; group++) {
geometry.addGroup(0, 0, group) geometry.addGroup(0, 0, group)
} }
+19 -6
View File
@@ -15,8 +15,15 @@ import {
useNodeEvents, useNodeEvents,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import {
BufferGeometry,
Float32BufferAttribute,
type Group,
Path,
Shape,
ShapeGeometry,
} from 'three'
import { MeshLambertNodeMaterial } from 'three/webgpu' import { MeshLambertNodeMaterial } from 'three/webgpu'
const Y_OFFSET = 0.01 const Y_OFFSET = 0.01
@@ -134,6 +141,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
if (!polygonPoints || polygonPoints.length < 2) return null if (!polygonPoints || polygonPoints.length < 2) return null
return createBoundaryLineGeometry(polygonPoints) return createBoundaryLineGeometry(polygonPoints)
}, [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') 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 */} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && ( {groundGeometry && (
<mesh <mesh
geometry={groundGeometry}
material={groundMaterial} material={groundMaterial}
position={[0, -0.05, 0]} position={[0, -0.05, 0]}
receiveShadow receiveShadow
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
> />
<shapeGeometry args={[groundShape]} />
</mesh>
)} )}
{/* Simple boundary line */} {/* Simple boundary line */}
@@ -131,6 +131,8 @@ export function generateCeilingGeometry(
// the whole command encoder. // the whole command encoder.
const degenerate = new THREE.BufferGeometry() const degenerate = new THREE.BufferGeometry()
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) 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 return degenerate
} }
@@ -152,6 +152,14 @@ export const RoofSystem = () => {
'position', 'position',
new THREE.Float32BufferAttribute(new Float32Array(9), 3), 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) computeGeometryBoundsTree(placeholder)
mesh.geometry = 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"), // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
// poisoning the command encoder. The count-0 groups keep nothing drawn. // poisoning the command encoder. The count-0 groups keep nothing drawn.
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) 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_TREAD_MATERIAL_INDEX)
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
return geometry return geometry