Improve ceiling selection and move preview visibility
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
import { initSFXBus } from '../../lib/sfx-bus'
|
import { initSFXBus } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
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 { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||||
@@ -523,6 +524,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
|||||||
<ExportManager />
|
<ExportManager />
|
||||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
|
<CeilingSelectionAffordanceSystem />
|
||||||
<RoofEditSystem />
|
<RoofEditSystem />
|
||||||
<StairEditSystem />
|
<StairEditSystem />
|
||||||
{!isLoading && !isFirstPersonMode && (
|
{!isLoading && !isFirstPersonMode && (
|
||||||
|
|||||||
+233
@@ -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) => (
|
||||||
|
<CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||||
|
{corners.map((corner, index) => (
|
||||||
|
<CornerBracket
|
||||||
|
ceiling={ceiling}
|
||||||
|
corner={corner}
|
||||||
|
key={`${ceiling.id}-corner-${index}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>,
|
||||||
|
levelObject,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CornerBracket = ({
|
||||||
|
ceiling,
|
||||||
|
corner,
|
||||||
|
}: {
|
||||||
|
ceiling: CeilingNode
|
||||||
|
corner: CornerBracketData
|
||||||
|
}) => {
|
||||||
|
const color = '#d4d4d4'
|
||||||
|
const opacity = 0.72
|
||||||
|
|
||||||
|
const handleClick = (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
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 (
|
||||||
|
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[CORNER_BLOCK_SIZE, BRACKET_HEIGHT, CORNER_BLOCK_SIZE]} />
|
||||||
|
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<BracketLeg
|
||||||
|
color={color}
|
||||||
|
direction={corner.incomingDirection}
|
||||||
|
length={corner.incomingLength}
|
||||||
|
opacity={opacity}
|
||||||
|
/>
|
||||||
|
<BracketLeg
|
||||||
|
color={color}
|
||||||
|
direction={corner.outgoingDirection}
|
||||||
|
length={corner.outgoingLength}
|
||||||
|
opacity={opacity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<mesh
|
||||||
|
onPointerDown={handleClick}
|
||||||
|
position={[corner.hitCenter[0] - corner.corner[0], 0, corner.hitCenter[1] - corner.corner[1]]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={HIT_BOX_SIZE} />
|
||||||
|
<meshBasicMaterial opacity={0} transparent />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<mesh position={position} rotation={[0, angle, 0]}>
|
||||||
|
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||||
|
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, { parentId: string | null }>): string | null {
|
||||||
|
if (!levelId) return null
|
||||||
|
const level = nodes[levelId]
|
||||||
|
return level?.parentId ?? null
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
|
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
|
|
||||||
function snap(value: number) {
|
function snap(value: number) {
|
||||||
return Math.round(value * 2) / 2
|
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 dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const previousGridPosRef = 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<{
|
const previewRef = useRef<{
|
||||||
polygon: Array<[number, number]>
|
polygon: Array<[number, number]>
|
||||||
holes: Array<Array<[number, number]>>
|
holes: Array<Array<[number, number]>>
|
||||||
@@ -48,6 +51,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
const center = getPolygonCenter(node.polygon)
|
const center = getPolygonCenter(node.polygon)
|
||||||
return [center[0], node.height ?? 2.5, center[1]]
|
return [center[0], node.height ?? 2.5, center[1]]
|
||||||
})
|
})
|
||||||
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
||||||
|
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
@@ -65,13 +70,26 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
holes: Array<Array<[number, number]>>,
|
holes: Array<Array<[number, number]>>,
|
||||||
) => {
|
) => {
|
||||||
previewRef.current = { polygon, holes }
|
previewRef.current = { polygon, holes }
|
||||||
|
setPreviewPolygon(polygon)
|
||||||
|
setPreviewHoles(holes)
|
||||||
const center = getPolygonCenter(polygon)
|
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().updateNode(node.id, { polygon, holes })
|
||||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = () => {
|
const restoreOriginal = () => {
|
||||||
|
setPreviewPolygon(originalPolygon)
|
||||||
|
setPreviewHoles(originalHoles)
|
||||||
useScene.getState().updateNode(node.id, {
|
useScene.getState().updateNode(node.id, {
|
||||||
holes: originalHoles,
|
holes: originalHoles,
|
||||||
polygon: originalPolygon,
|
polygon: originalPolygon,
|
||||||
@@ -97,6 +115,15 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
const deltaX = localX - anchor[0]
|
const deltaX = localX - anchor[0]
|
||||||
const deltaZ = localZ - anchor[1]
|
const deltaZ = localZ - anchor[1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousDeltaRef.current &&
|
||||||
|
previousDeltaRef.current[0] === deltaX &&
|
||||||
|
previousDeltaRef.current[1] === deltaZ
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previousDeltaRef.current = [deltaX, deltaZ]
|
||||||
|
|
||||||
applyPreview(
|
applyPreview(
|
||||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||||
originalHoles.map((hole) => translatePolygon(hole, 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])
|
}, [exitMoveMode, node.height, node.id])
|
||||||
|
|
||||||
|
const previewFillGeometry = useMemo(
|
||||||
|
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
||||||
|
[previewHoles, previewPolygon],
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewOutlineGeometry = useMemo(
|
||||||
|
() => createCeilingOutlineGeometry(previewPolygon),
|
||||||
|
[previewPolygon],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#f5f5f4"
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.3}
|
||||||
|
side={DoubleSide}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
||||||
|
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
||||||
|
</line>
|
||||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCeilingPreviewGeometry(
|
||||||
|
polygon: Array<[number, number]>,
|
||||||
|
holes: Array<Array<[number, number]>>,
|
||||||
|
): 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user