@pascal-app/editor 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +118 -10
|
@@ -4,9 +4,14 @@ import {
|
|
|
4
4
|
type AnyNodeId,
|
|
5
5
|
calculateLevelMiters,
|
|
6
6
|
DEFAULT_WALL_HEIGHT,
|
|
7
|
+
getWallCurveLength,
|
|
8
|
+
getWallMiterBoundaryPoints,
|
|
7
9
|
getWallPlanFootprint,
|
|
10
|
+
getWallSurfacePolygon,
|
|
11
|
+
isCurvedWall,
|
|
8
12
|
type Point2D,
|
|
9
13
|
pointToKey,
|
|
14
|
+
sampleWallCenterline,
|
|
10
15
|
sceneRegistry,
|
|
11
16
|
useScene,
|
|
12
17
|
type WallMiterData,
|
|
@@ -15,7 +20,7 @@ import {
|
|
|
15
20
|
import { useViewer } from '@pascal-app/viewer'
|
|
16
21
|
import { Html } from '@react-three/drei'
|
|
17
22
|
import { createPortal, useFrame } from '@react-three/fiber'
|
|
18
|
-
import {
|
|
23
|
+
import { useMemo, useState } from 'react'
|
|
19
24
|
import * as THREE from 'three'
|
|
20
25
|
|
|
21
26
|
const GUIDE_Y_OFFSET = 0.08
|
|
@@ -28,8 +33,7 @@ const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
|
|
28
33
|
type Vec3 = [number, number, number]
|
|
29
34
|
|
|
30
35
|
type MeasurementGuide = {
|
|
31
|
-
|
|
32
|
-
guideEnd: Vec3
|
|
36
|
+
guidePath: Vec3[]
|
|
33
37
|
extStartStart: Vec3
|
|
34
38
|
extStartEnd: Vec3
|
|
35
39
|
extEndStart: Vec3
|
|
@@ -56,18 +60,19 @@ export function WallMeasurementLabel() {
|
|
|
56
60
|
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
|
|
57
61
|
const wall = selectedNode?.type === 'wall' ? selectedNode : null
|
|
58
62
|
|
|
59
|
-
const [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const [wallObjectState, setWallObjectState] = useState<{
|
|
64
|
+
id: WallNode['id']
|
|
65
|
+
object: THREE.Object3D
|
|
66
|
+
} | null>(null)
|
|
67
|
+
const wallObject =
|
|
68
|
+
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
|
|
64
69
|
|
|
65
70
|
useFrame(() => {
|
|
66
71
|
if (!selectedId || wallObject) return
|
|
67
72
|
|
|
68
73
|
const nextWallObject = sceneRegistry.nodes.get(selectedId)
|
|
69
74
|
if (nextWallObject) {
|
|
70
|
-
|
|
75
|
+
setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
|
|
71
76
|
}
|
|
72
77
|
})
|
|
73
78
|
|
|
@@ -131,6 +136,34 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
|
|
|
131
136
|
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
|
|
140
|
+
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
|
141
|
+
return 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
|
|
145
|
+
return -1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return 1
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
|
|
152
|
+
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
|
|
153
|
+
if (!boundaryPoints) return null
|
|
154
|
+
|
|
155
|
+
const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
|
|
156
|
+
const sidePointCount = 25
|
|
157
|
+
if (surface.length < sidePointCount * 2) return null
|
|
158
|
+
|
|
159
|
+
const offsetSign = getWallExteriorOffsetSign(wall)
|
|
160
|
+
if (offsetSign >= 0) {
|
|
161
|
+
return surface.slice(sidePointCount).reverse()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return surface.slice(0, sidePointCount)
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
function buildMeasurementGuide(
|
|
135
168
|
wall: WallNode,
|
|
136
169
|
nodes: Record<string, WallNode | { type: string; children?: string[] }>,
|
|
@@ -143,31 +176,66 @@ function buildMeasurementGuide(
|
|
|
143
176
|
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
144
177
|
const startLocal = worldPointToWallLocal(wall, middlePoints.start)
|
|
145
178
|
const endLocal = worldPointToWallLocal(wall, middlePoints.end)
|
|
179
|
+
const curvedMeasurementPath = isCurvedWall(wall)
|
|
180
|
+
? getCurvedWallMeasurementPath(wall, miterData)
|
|
181
|
+
: null
|
|
182
|
+
const guidePath: Vec3[] = curvedMeasurementPath
|
|
183
|
+
? curvedMeasurementPath.map((point) => {
|
|
184
|
+
const localPoint = worldPointToWallLocal(wall, point)
|
|
185
|
+
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
|
|
186
|
+
})
|
|
187
|
+
: isCurvedWall(wall)
|
|
188
|
+
? sampleWallCenterline(wall, 24).map((point, index, points) => {
|
|
189
|
+
const localPoint =
|
|
190
|
+
index === 0
|
|
191
|
+
? startLocal
|
|
192
|
+
: index === points.length - 1
|
|
193
|
+
? endLocal
|
|
194
|
+
: worldPointToWallLocal(wall, point)
|
|
195
|
+
|
|
196
|
+
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
|
|
197
|
+
})
|
|
198
|
+
: [
|
|
199
|
+
[startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
|
|
200
|
+
[endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
if (guidePath.length < 2) return null
|
|
204
|
+
|
|
205
|
+
let guideLength = 0
|
|
206
|
+
for (let index = 1; index < guidePath.length; index += 1) {
|
|
207
|
+
const prev = guidePath[index - 1]!
|
|
208
|
+
const next = guidePath[index]!
|
|
209
|
+
guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
|
|
210
|
+
}
|
|
146
211
|
|
|
147
|
-
|
|
148
|
-
const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
|
|
149
|
-
|
|
150
|
-
const dirX = guideEnd[0] - guideStart[0]
|
|
151
|
-
const dirZ = guideEnd[2] - guideStart[2]
|
|
152
|
-
const dirLength = Math.hypot(dirX, dirZ)
|
|
153
|
-
|
|
154
|
-
if (!Number.isFinite(dirLength) || dirLength < 0.001) return null
|
|
212
|
+
if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
|
|
155
213
|
|
|
156
214
|
// Extension lines coming out of the extremity markers of the wall
|
|
157
215
|
const extOvershoot = 0.04
|
|
216
|
+
const guideStart = guidePath[0]!
|
|
217
|
+
const guideEnd = guidePath[guidePath.length - 1]!
|
|
218
|
+
const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
|
|
219
|
+
const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
|
|
220
|
+
const midpoint = curvedMeasurementPath
|
|
221
|
+
? guidePath[Math.floor(guidePath.length / 2)]!
|
|
222
|
+
: ([
|
|
223
|
+
(guideStart[0] + guideEnd[0]) / 2,
|
|
224
|
+
guideStart[1],
|
|
225
|
+
(guideStart[2] + guideEnd[2]) / 2,
|
|
226
|
+
] as Vec3)
|
|
158
227
|
|
|
159
228
|
return {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
labelPosition: [
|
|
167
|
-
(guideStart[0] + guideEnd[0]) / 2,
|
|
168
|
-
guideStart[1] + LABEL_LIFT,
|
|
169
|
-
(guideStart[2] + guideEnd[2]) / 2,
|
|
229
|
+
guidePath,
|
|
230
|
+
extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
|
|
231
|
+
extStartEnd: [
|
|
232
|
+
extensionStartBase[0],
|
|
233
|
+
height + GUIDE_Y_OFFSET + extOvershoot,
|
|
234
|
+
extensionStartBase[2],
|
|
170
235
|
],
|
|
236
|
+
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
|
|
237
|
+
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
|
|
238
|
+
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
|
|
171
239
|
}
|
|
172
240
|
}
|
|
173
241
|
|
|
@@ -208,6 +276,16 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
|
|
|
208
276
|
)
|
|
209
277
|
}
|
|
210
278
|
|
|
279
|
+
function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
|
|
280
|
+
return (
|
|
281
|
+
<>
|
|
282
|
+
{path.slice(1).map((point, index) => (
|
|
283
|
+
<MeasurementBar color={color} end={point} key={index} start={path[index]!} />
|
|
284
|
+
))}
|
|
285
|
+
</>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
211
289
|
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
212
290
|
const nodes = useScene((state) => state.nodes)
|
|
213
291
|
const theme = useViewer((state) => state.theme)
|
|
@@ -216,10 +294,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
216
294
|
const color = isNight ? '#ffffff' : '#111111'
|
|
217
295
|
const shadowColor = isNight ? '#111111' : '#ffffff'
|
|
218
296
|
|
|
219
|
-
const dx = wall.end[0] - wall.start[0]
|
|
220
|
-
const dz = wall.end[1] - wall.start[1]
|
|
221
|
-
const length = Math.hypot(dx, dz)
|
|
222
|
-
const label = formatMeasurement(length, unit)
|
|
223
297
|
const guide = useMemo(
|
|
224
298
|
() =>
|
|
225
299
|
buildMeasurementGuide(
|
|
@@ -228,12 +302,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
228
302
|
),
|
|
229
303
|
[nodes, wall],
|
|
230
304
|
)
|
|
305
|
+
const length = useMemo(() => {
|
|
306
|
+
if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
|
|
307
|
+
return getWallCurveLength(wall)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let total = 0
|
|
311
|
+
for (let index = 1; index < guide.guidePath.length; index += 1) {
|
|
312
|
+
const prev = guide.guidePath[index - 1]!
|
|
313
|
+
const next = guide.guidePath[index]!
|
|
314
|
+
total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
|
|
315
|
+
}
|
|
316
|
+
return total
|
|
317
|
+
}, [guide, wall])
|
|
318
|
+
const label = formatMeasurement(length, unit)
|
|
231
319
|
|
|
232
320
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
|
233
321
|
|
|
234
322
|
return (
|
|
235
323
|
<group>
|
|
236
|
-
<
|
|
324
|
+
<MeasurementPath color={color} path={guide.guidePath} />
|
|
237
325
|
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
|
238
326
|
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
|
|
239
327
|
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
resolveLevelId,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
import type { Object3D } from 'three'
|
|
14
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
15
|
+
import useEditor from '../../../store/use-editor'
|
|
16
|
+
|
|
17
|
+
const BRACKET_THICKNESS = 0.04
|
|
18
|
+
const BRACKET_HEIGHT = 0.04
|
|
19
|
+
const BRACKET_Y_OFFSET = 0.035
|
|
20
|
+
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
|
21
|
+
|
|
22
|
+
type CornerBracketData = {
|
|
23
|
+
corner: [number, number]
|
|
24
|
+
incomingDirection: [number, number]
|
|
25
|
+
outgoingDirection: [number, number]
|
|
26
|
+
incomingLength: number
|
|
27
|
+
outgoingLength: number
|
|
28
|
+
cornerStrength: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CeilingSelectionAffordanceSystem = () => {
|
|
32
|
+
const phase = useEditor((state) => state.phase)
|
|
33
|
+
const mode = useEditor((state) => state.mode)
|
|
34
|
+
const structureLayer = useEditor((state) => state.structureLayer)
|
|
35
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
36
|
+
const curvingWall = useEditor((state) => state.curvingWall)
|
|
37
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
38
|
+
|
|
39
|
+
const ceilings = useScene(
|
|
40
|
+
useShallow((state) =>
|
|
41
|
+
Object.values(state.nodes).filter((node): node is CeilingNode => {
|
|
42
|
+
return (
|
|
43
|
+
node.type === 'ceiling' &&
|
|
44
|
+
node.visible !== false &&
|
|
45
|
+
currentLevelId !== null &&
|
|
46
|
+
resolveLevelId(node, state.nodes) === currentLevelId
|
|
47
|
+
)
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const shouldRender =
|
|
53
|
+
phase === 'structure' &&
|
|
54
|
+
mode === 'select' &&
|
|
55
|
+
structureLayer === 'elements' &&
|
|
56
|
+
!movingNode &&
|
|
57
|
+
!curvingWall &&
|
|
58
|
+
currentLevelId !== null
|
|
59
|
+
|
|
60
|
+
if (!shouldRender) return null
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{ceilings.map((ceiling) => (
|
|
65
|
+
<CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
|
|
66
|
+
))}
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CeilingSelectionAffordance = ({
|
|
72
|
+
ceiling,
|
|
73
|
+
levelId,
|
|
74
|
+
}: {
|
|
75
|
+
ceiling: CeilingNode
|
|
76
|
+
levelId: string
|
|
77
|
+
}) => {
|
|
78
|
+
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
|
|
79
|
+
|
|
80
|
+
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let frameId = 0
|
|
84
|
+
|
|
85
|
+
const resolveLevelObject = () => {
|
|
86
|
+
const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
|
|
87
|
+
setLevelObject((currentLevelObject) => {
|
|
88
|
+
if (currentLevelObject === nextLevelObject) {
|
|
89
|
+
return currentLevelObject
|
|
90
|
+
}
|
|
91
|
+
return nextLevelObject
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (!nextLevelObject) {
|
|
95
|
+
frameId = window.requestAnimationFrame(resolveLevelObject)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resolveLevelObject()
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (frameId) {
|
|
103
|
+
window.cancelAnimationFrame(frameId)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}, [levelId])
|
|
107
|
+
|
|
108
|
+
if (!levelObject || corners.length === 0) return null
|
|
109
|
+
|
|
110
|
+
return createPortal(
|
|
111
|
+
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
|
112
|
+
{corners.map((corner, index) => (
|
|
113
|
+
<CornerBracket
|
|
114
|
+
ceiling={ceiling}
|
|
115
|
+
corner={corner}
|
|
116
|
+
key={`${ceiling.id}-corner-${index}`}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</group>,
|
|
120
|
+
levelObject,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const CornerBracket = ({
|
|
125
|
+
ceiling,
|
|
126
|
+
corner,
|
|
127
|
+
}: {
|
|
128
|
+
ceiling: CeilingNode
|
|
129
|
+
corner: CornerBracketData
|
|
130
|
+
}) => {
|
|
131
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
132
|
+
const color = '#d4d4d4'
|
|
133
|
+
const opacity = 0.72
|
|
134
|
+
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
|
|
135
|
+
const cubeOpacity = isHovered ? 0.92 : 0.72
|
|
136
|
+
|
|
137
|
+
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
|
|
140
|
+
const nodes = useScene.getState().nodes
|
|
141
|
+
|
|
142
|
+
useEditor.getState().setMovingNode(null)
|
|
143
|
+
useEditor.getState().setMovingWallEndpoint(null)
|
|
144
|
+
useEditor.getState().setCurvingWall(null)
|
|
145
|
+
useEditor.getState().setEditingHole(null)
|
|
146
|
+
useEditor.getState().setMode('select')
|
|
147
|
+
|
|
148
|
+
emitter.emit('ceiling:click' as any, {
|
|
149
|
+
node: ceiling,
|
|
150
|
+
nativeEvent: e.nativeEvent,
|
|
151
|
+
localPosition: [0, 0, 0],
|
|
152
|
+
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
|
|
153
|
+
stopPropagation: () => e.stopPropagation(),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
|
159
|
+
<BracketLeg
|
|
160
|
+
color={color}
|
|
161
|
+
direction={corner.incomingDirection}
|
|
162
|
+
length={corner.incomingLength}
|
|
163
|
+
onClick={handleClick}
|
|
164
|
+
opacity={opacity}
|
|
165
|
+
/>
|
|
166
|
+
<BracketLeg
|
|
167
|
+
color={color}
|
|
168
|
+
direction={corner.outgoingDirection}
|
|
169
|
+
length={corner.outgoingLength}
|
|
170
|
+
onClick={handleClick}
|
|
171
|
+
opacity={opacity}
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<mesh
|
|
175
|
+
onClick={handleClick}
|
|
176
|
+
onPointerEnter={(e) => {
|
|
177
|
+
e.stopPropagation()
|
|
178
|
+
setIsHovered(true)
|
|
179
|
+
}}
|
|
180
|
+
onPointerLeave={(e) => {
|
|
181
|
+
e.stopPropagation()
|
|
182
|
+
setIsHovered(false)
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<boxGeometry args={HIT_BOX_SIZE} />
|
|
186
|
+
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
|
|
187
|
+
</mesh>
|
|
188
|
+
</group>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const BracketLeg = ({
|
|
193
|
+
direction,
|
|
194
|
+
length,
|
|
195
|
+
color,
|
|
196
|
+
onClick,
|
|
197
|
+
opacity,
|
|
198
|
+
}: {
|
|
199
|
+
direction: [number, number]
|
|
200
|
+
length: number
|
|
201
|
+
color: string
|
|
202
|
+
onClick: (e: ThreeEvent<MouseEvent>) => void
|
|
203
|
+
opacity: number
|
|
204
|
+
}) => {
|
|
205
|
+
const angle = Math.atan2(direction[1], direction[0])
|
|
206
|
+
const position: [number, number, number] = [
|
|
207
|
+
direction[0] * (length / 2),
|
|
208
|
+
0,
|
|
209
|
+
direction[1] * (length / 2),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<mesh
|
|
214
|
+
onClick={onClick}
|
|
215
|
+
position={position}
|
|
216
|
+
rotation={[0, angle, 0]}
|
|
217
|
+
>
|
|
218
|
+
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
|
219
|
+
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
|
220
|
+
</mesh>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
|
|
225
|
+
if (polygon.length < 3) return []
|
|
226
|
+
|
|
227
|
+
const allCorners = polygon.map((corner, index) => {
|
|
228
|
+
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
|
|
229
|
+
const next = polygon[(index + 1) % polygon.length]!
|
|
230
|
+
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
|
|
231
|
+
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
|
|
232
|
+
const incomingDirection = normalize2D(incomingVector)
|
|
233
|
+
const outgoingDirection = normalize2D(outgoingVector)
|
|
234
|
+
|
|
235
|
+
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
|
236
|
+
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
|
237
|
+
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
corner,
|
|
241
|
+
incomingDirection,
|
|
242
|
+
outgoingDirection,
|
|
243
|
+
incomingLength: getBracketLength(incomingLength),
|
|
244
|
+
outgoingLength: getBracketLength(outgoingLength),
|
|
245
|
+
cornerStrength,
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (allCorners.length <= 4) {
|
|
250
|
+
return allCorners
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const selectedIndices = new Set(
|
|
254
|
+
allCorners
|
|
255
|
+
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
|
|
256
|
+
.sort((a, b) => b.strength - a.strength)
|
|
257
|
+
.slice(0, 4)
|
|
258
|
+
.map(({ index }) => index),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return allCorners.filter((_, index) => selectedIndices.has(index))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalize2D(vector: [number, number]): [number, number] {
|
|
265
|
+
const length = Math.hypot(vector[0], vector[1])
|
|
266
|
+
if (length < 1e-6) return [1, 0]
|
|
267
|
+
return [vector[0] / length, vector[1] / length]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getBracketLength(edgeLength: number): number {
|
|
271
|
+
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
|
|
272
|
+
}
|
|
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
|
|
6
6
|
* Imperatively toggles the Three.js visibility of roof objects based on the
|
|
7
7
|
* editor selection — without causing React re-renders in RoofRenderer.
|
|
8
8
|
*
|
|
9
|
-
* When a roof
|
|
9
|
+
* When a roof-segment is selected:
|
|
10
10
|
* - merged-roof mesh is hidden
|
|
11
11
|
* - segments-wrapper group is shown (individual segments visible for editing)
|
|
12
12
|
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
|
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
|
|
|
22
22
|
useEffect(() => {
|
|
23
23
|
const nodes = useScene.getState().nodes
|
|
24
24
|
|
|
25
|
-
// Collect which roof nodes should be in "edit mode"
|
|
25
|
+
// Collect which roof nodes should be in "edit mode".
|
|
26
|
+
// Selecting the roof itself should keep the merged visual intact so
|
|
27
|
+
// material appearance does not jump between merged and per-segment meshes.
|
|
26
28
|
const activeRoofIds = new Set<string>()
|
|
27
29
|
for (const id of selectedIds) {
|
|
28
30
|
const node = nodes[id as AnyNodeId]
|
|
29
31
|
if (!node) continue
|
|
30
|
-
if (node.type === 'roof') {
|
|
31
|
-
activeRoofIds.add(id)
|
|
32
|
-
} else if (node.type === 'roof-segment' && node.parentId) {
|
|
32
|
+
if (node.type === 'roof-segment' && node.parentId) {
|
|
33
33
|
activeRoofIds.add(node.parentId)
|
|
34
34
|
}
|
|
35
35
|
}
|