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
+}