diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 31134980..c5435948 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -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) { + 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( () => sceneRegistry.nodes.get(levelId) ?? null, ) + const [hoveredCornerIndex, setHoveredCornerIndex] = useState(null) + const [draggedCornerIndex, setDraggedCornerIndex] = useState(null) + const [previewPolygon, setPreviewPolygon] = useState | null>(null) + const dragRef = useRef(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() + 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() + 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) => { + 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( {corners.map((corner, index) => ( - + { + setHoveredCornerIndex((current) => { + if (hovered) return corner.index + return current === corner.index ? null : current + }) + }} + onPointerDown={(event) => handleCornerPointerDown(corner, event)} + /> ))} , 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) => 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) => { 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 ( { e.stopPropagation() - setIsHovered(true) + onHoverChange(true) }} onPointerLeave={(e) => { e.stopPropagation() - setIsHovered(false) + onHoverChange(false) }} renderOrder={CORNER_RENDER_ORDER} + scale={HIT_BOX_SIZE} > - ) => void - opacity: number + onHoverChange: (hovered: boolean) => void + onPointerDown: (event: ThreeEvent) => void }) => { const angle = Math.atan2(direction[1], direction[0]) const position: [number, number, number] = [ @@ -221,13 +523,29 @@ const BracketLeg = ({ return ( { + 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]} > - - + ) } @@ -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), diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index f557b3fd..80d1403f 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -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() @@ -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 } diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 094ca94b..3b186742 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -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 diff --git a/packages/editor/src/components/tools/select/box-select-state.ts b/packages/editor/src/components/tools/select/box-select-state.ts index a3c2ca4d..941a962b 100644 --- a/packages/editor/src/components/tools/select/box-select-state.ts +++ b/packages/editor/src/components/tools/select/box-select-state.ts @@ -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) } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 8f75d69f..2d651f39 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -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) { + event.stopPropagation() + suppressBoxSelectForPointer(event, { markHandled: false }) +} + type HandleClickHandler = (event: ThreeEvent) => void type HandlePointerHandler = (event: ThreeEvent) => 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 ( + element conflicts with SVG type + frustumCulled={false} + geometry={geometry} + layers={EDITOR_LAYER} + raycast={NO_RAYCAST} + renderOrder={12} + > + + + ) +} + export const PolygonEditor: React.FC = ({ polygon, color = '#3b82f6', @@ -337,11 +390,13 @@ export const PolygonEditor: React.FC = ({ onBeforeVertexDrag, onVertexHoverChange, onMidpointHoverChange, + onEdgeHoverChange, onDragStateChange, onDragStart, onDragCommit, showBorderLine = true, showMidpointHandles = true, + highlightConnectedHandles = false, renderMidpointHandle, renderVertexHandle, }) => { @@ -442,6 +497,12 @@ export const PolygonEditor: React.FC = ({ useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange]) + useEffect(() => { + onEdgeHoverChange?.(hoveredEdge) + }, [hoveredEdge, onEdgeHoverChange]) + + useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange]) + const lineRef = useRef(null!) const previousPositionRef = useRef<[number, number] | null>(null) @@ -569,6 +630,47 @@ export const PolygonEditor: React.FC = ({ }) }, [displayPolygon]) + const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex + const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge + + const highlightedEdgeIndices = useMemo(() => { + const next = new Set() + 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 = ({ )} + {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 ( + + ) + })} + {/* 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 = ({ }, 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 = ({ return ( = ({ }} 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 = ({ 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 = ({ which sits outside the polygon and never overlaps a vertex/midpoint handle. */} - @@ -935,7 +1058,7 @@ export const PolygonEditor: React.FC = ({ Points outward from the edge; dragging it translates only this edge's two vertices along the outward normal. */} { if (e.button !== 0) return @@ -943,7 +1066,7 @@ export const PolygonEditor: React.FC = ({ }} onPointerDown={(e) => { if (e.button !== 0) return - e.stopPropagation() + stopHandlePointerDown(e) beginEdgeDrag(e) }} onPointerEnter={(e) => { @@ -967,6 +1090,8 @@ export const PolygonEditor: React.FC = ({ !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 = ({ }, 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 = ({ return ( diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index eb174e50..0b06f5cf 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -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'] }> = 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} /> diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts index 287a45af..52ce511c 100644 --- a/packages/nodes/src/shared/placeholder-geometry.ts +++ b/packages/nodes/src/shared/placeholder-geometry.ts @@ -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) } diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 33e3ba34..87d66dc1 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -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 && ( - - + /> )} {/* Simple boundary line */} diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 496ec333..f98da183 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -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 } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 704b27e8..79dca73e 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -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 } diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index d000bea2..f5d09f7c 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -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