From 0e5a4d9fea141b6456008dc0baa7325b2af43569 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 16 Apr 2026 15:04:52 +0530 Subject: [PATCH] Improve ceiling selection and move preview visibility --- .../editor/src/components/editor/index.tsx | 2 + .../ceiling-selection-affordance-system.tsx | 233 ++++++++++++++++++ .../tools/ceiling/move-ceiling-tool.tsx | 99 +++++++- 3 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 74fe0694..4dbbda1a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -23,6 +23,7 @@ import { import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { CeilingSystem } from '../systems/ceiling/ceiling-system' +import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' @@ -523,6 +524,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {isFirstPersonMode ? : } + {!isLoading && !isFirstPersonMode && ( 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 new file mode 100644 index 00000000..2cc12834 --- /dev/null +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -0,0 +1,233 @@ +'use client' + +import { + type CeilingNode, + resolveLevelId, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import useEditor from '../../../store/use-editor' + +const BRACKET_THICKNESS = 0.04 +const BRACKET_HEIGHT = 0.04 +const BRACKET_Y_OFFSET = 0.035 +const CORNER_BLOCK_SIZE = 0.085 +const HIT_BOX_SIZE: [number, number, number] = [0.24, 0.12, 0.24] +const HIT_INSET = 0.16 + +type CornerBracketData = { + corner: [number, number] + hitCenter: [number, number] + incomingDirection: [number, number] + outgoingDirection: [number, number] + incomingLength: number + outgoingLength: number +} + +export const CeilingSelectionAffordanceSystem = () => { + const phase = useEditor((state) => state.phase) + const mode = useEditor((state) => state.mode) + const structureLayer = useEditor((state) => state.structureLayer) + const movingNode = useEditor((state) => state.movingNode) + const curvingWall = useEditor((state) => state.curvingWall) + const currentLevelId = useViewer((state) => state.selection.levelId) + + const ceilings = useScene( + useShallow((state) => + Object.values(state.nodes).filter((node): node is CeilingNode => { + return ( + node.type === 'ceiling' && + node.visible !== false && + currentLevelId !== null && + resolveLevelId(node, state.nodes) === currentLevelId + ) + }), + ), + ) + + const shouldRender = + phase === 'structure' && + mode === 'select' && + structureLayer === 'elements' && + !movingNode && + !curvingWall && + currentLevelId !== null + + if (!shouldRender) return null + + return ( + <> + {ceilings.map((ceiling) => ( + + ))} + + ) +} + +const CeilingSelectionAffordance = ({ + ceiling, + levelId, +}: { + ceiling: CeilingNode + levelId: string +}) => { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const isSelected = selectedIds.includes(ceiling.id) + const levelObject = sceneRegistry.nodes.get(levelId) + + const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon]) + + if (!levelObject || corners.length === 0 || isSelected) return null + + return createPortal( + + {corners.map((corner, index) => ( + + ))} + , + levelObject, + ) +} + +const CornerBracket = ({ + ceiling, + corner, +}: { + ceiling: CeilingNode + corner: CornerBracketData +}) => { + const color = '#d4d4d4' + const opacity = 0.72 + + const handleClick = (e: ThreeEvent) => { + if (e.button !== 0) return + e.stopPropagation() + + const nodes = useScene.getState().nodes + const selection = useViewer.getState().selection + const levelId = resolveLevelId(ceiling, nodes) + const buildingId = findBuildingId(levelId, nodes) + + useEditor.getState().setMovingNode(null) + useEditor.getState().setMovingWallEndpoint(null) + useEditor.getState().setCurvingWall(null) + useEditor.getState().setEditingHole(null) + useEditor.getState().setMode('select') + + useViewer.getState().setSelection({ + buildingId: buildingId ?? selection.buildingId, + levelId, + selectedIds: [ceiling.id], + }) + } + + return ( + + + + + + + + + + + + + + + ) +} + +const BracketLeg = ({ + direction, + length, + color, + opacity, +}: { + direction: [number, number] + length: number + color: string + opacity: number +}) => { + const angle = Math.atan2(direction[1], direction[0]) + const position: [number, number, number] = [ + direction[0] * (length / 2), + 0, + direction[1] * (length / 2), + ] + + return ( + + + + + ) +} + +function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] { + if (polygon.length < 3) return [] + + return polygon.map((corner, index) => { + const previous = polygon[(index - 1 + polygon.length) % polygon.length]! + const next = polygon[(index + 1) % polygon.length]! + const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number] + const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number] + + const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) + const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) + const insetDirection = normalize2D([ + normalize2D(incomingVector)[0] + normalize2D(outgoingVector)[0], + normalize2D(incomingVector)[1] + normalize2D(outgoingVector)[1], + ]) + + return { + corner, + hitCenter: [ + corner[0] + insetDirection[0] * HIT_INSET, + corner[1] + insetDirection[1] * HIT_INSET, + ], + incomingDirection: normalize2D(incomingVector), + outgoingDirection: normalize2D(outgoingVector), + incomingLength: getBracketLength(incomingLength), + outgoingLength: getBracketLength(outgoingLength), + } + }) +} + +function normalize2D(vector: [number, number]): [number, number] { + const length = Math.hypot(vector[0], vector[1]) + if (length < 1e-6) return [1, 0] + return [vector[0] / length, vector[1] / length] +} + +function getBracketLength(edgeLength: number): number { + return Math.max(0.14, Math.min(0.38, edgeLength * 0.22)) +} + +function findBuildingId(levelId: string | null, nodes: Record): string | null { + if (!levelId) return null + const level = nodes[levelId] + return level?.parentId ?? null +} diff --git a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx index 79243db3..77bd2760 100644 --- a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx +++ b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx @@ -2,11 +2,12 @@ import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' function snap(value: number) { return Math.round(value * 2) / 2 @@ -39,6 +40,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { ) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) + const previousCursorPosRef = useRef<[number, number, number] | null>(null) + const previousDeltaRef = useRef<[number, number] | null>(null) const previewRef = useRef<{ polygon: Array<[number, number]> holes: Array> @@ -48,6 +51,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const center = getPolygonCenter(node.polygon) return [center[0], node.height ?? 2.5, center[1]] }) + const [previewPolygon, setPreviewPolygon] = useState>(node.polygon) + const [previewHoles, setPreviewHoles] = useState>>(node.holes ?? []) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -65,13 +70,26 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { holes: Array>, ) => { previewRef.current = { polygon, holes } + setPreviewPolygon(polygon) + setPreviewHoles(holes) const center = getPolygonCenter(polygon) - setCursorLocalPos([center[0], node.height ?? 2.5, center[1]]) + const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]] + if ( + !previousCursorPosRef.current || + previousCursorPosRef.current[0] !== nextCursorPos[0] || + previousCursorPosRef.current[1] !== nextCursorPos[1] || + previousCursorPosRef.current[2] !== nextCursorPos[2] + ) { + previousCursorPosRef.current = nextCursorPos + setCursorLocalPos(nextCursorPos) + } useScene.getState().updateNode(node.id, { polygon, holes }) useScene.getState().markDirty(node.id as AnyNodeId) } const restoreOriginal = () => { + setPreviewPolygon(originalPolygon) + setPreviewHoles(originalHoles) useScene.getState().updateNode(node.id, { holes: originalHoles, polygon: originalPolygon, @@ -97,6 +115,15 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const deltaX = localX - anchor[0] const deltaZ = localZ - anchor[1] + if ( + previousDeltaRef.current && + previousDeltaRef.current[0] === deltaX && + previousDeltaRef.current[1] === deltaZ + ) { + return + } + previousDeltaRef.current = [deltaX, deltaZ] + applyPreview( translatePolygon(originalPolygon, deltaX, deltaZ), originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), @@ -146,9 +173,77 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { } }, [exitMoveMode, node.height, node.id]) + const previewFillGeometry = useMemo( + () => createCeilingPreviewGeometry(previewPolygon, previewHoles), + [previewHoles, previewPolygon], + ) + + const previewOutlineGeometry = useMemo( + () => createCeilingOutlineGeometry(previewPolygon), + [previewPolygon], + ) + return ( + + + + + + ) } + +function createCeilingPreviewGeometry( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry { + if (polygon.length < 3) return new BufferGeometry() + + const shape = new Shape() + const [firstX, firstZ] = polygon[0]! + shape.moveTo(firstX, -firstZ) + + for (let i = 1; i < polygon.length; i++) { + const [x, z] = polygon[i]! + shape.lineTo(x, -z) + } + shape.closePath() + + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const [hx, hz] = holePolygon[0]! + hole.moveTo(hx, -hz) + for (let i = 1; i < holePolygon.length; i++) { + const [x, z] = holePolygon[i]! + hole.lineTo(x, -z) + } + hole.closePath() + shape.holes.push(hole) + } + + const geometry = new ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + return geometry +} + +function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry { + const geometry = new BufferGeometry() + if (polygon.length < 2) return geometry + + const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) + const [firstX, firstZ] = polygon[0]! + points.push(new Vector3(firstX, 0, firstZ)) + geometry.setFromPoints(points) + return geometry +}