@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- 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-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- 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 +138 -56
- 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 +9 -5
- 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/spawn-tree-node.tsx +82 -0
- 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 +12 -6
- 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 +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -4,9 +4,15 @@ import {
|
|
|
4
4
|
type AnyNodeId,
|
|
5
5
|
calculateLevelMiters,
|
|
6
6
|
DEFAULT_WALL_HEIGHT,
|
|
7
|
+
getWallCurveLength,
|
|
8
|
+
getWallMiterBoundaryPoints,
|
|
7
9
|
getWallPlanFootprint,
|
|
10
|
+
getWallSurfacePolygon,
|
|
11
|
+
type ItemNode,
|
|
12
|
+
isCurvedWall,
|
|
8
13
|
type Point2D,
|
|
9
14
|
pointToKey,
|
|
15
|
+
sampleWallCenterline,
|
|
10
16
|
sceneRegistry,
|
|
11
17
|
useScene,
|
|
12
18
|
type WallMiterData,
|
|
@@ -15,26 +21,39 @@ import {
|
|
|
15
21
|
import { useViewer } from '@pascal-app/viewer'
|
|
16
22
|
import { Html } from '@react-three/drei'
|
|
17
23
|
import { createPortal, useFrame } from '@react-three/fiber'
|
|
18
|
-
import {
|
|
24
|
+
import { useMemo, useState } from 'react'
|
|
19
25
|
import * as THREE from 'three'
|
|
20
26
|
|
|
21
27
|
const GUIDE_Y_OFFSET = 0.08
|
|
22
28
|
const LABEL_LIFT = 0.08
|
|
23
29
|
const BAR_THICKNESS = 0.012
|
|
24
30
|
const LINE_OPACITY = 0.95
|
|
31
|
+
const HEIGHT_TICK_HALF_LENGTH = 0.14
|
|
32
|
+
const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
|
|
25
33
|
|
|
26
34
|
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
|
27
35
|
|
|
28
36
|
type Vec3 = [number, number, number]
|
|
29
37
|
|
|
30
38
|
type MeasurementGuide = {
|
|
31
|
-
|
|
32
|
-
guideEnd: Vec3
|
|
39
|
+
guidePath: Vec3[]
|
|
33
40
|
extStartStart: Vec3
|
|
34
41
|
extStartEnd: Vec3
|
|
35
42
|
extEndStart: Vec3
|
|
36
43
|
extEndEnd: Vec3
|
|
37
44
|
labelPosition: Vec3
|
|
45
|
+
heightStart: Vec3
|
|
46
|
+
heightEnd: Vec3
|
|
47
|
+
heightBottomTickStart: Vec3
|
|
48
|
+
heightBottomTickEnd: Vec3
|
|
49
|
+
heightTopTickStart: Vec3
|
|
50
|
+
heightTopTickEnd: Vec3
|
|
51
|
+
heightLabelPosition: Vec3
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type WallFaceLine = {
|
|
55
|
+
start: Point2D
|
|
56
|
+
end: Point2D
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
@@ -53,27 +72,28 @@ export function WallMeasurementLabel() {
|
|
|
53
72
|
const nodes = useScene((state) => state.nodes)
|
|
54
73
|
|
|
55
74
|
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
56
|
-
const selectedNode = selectedId ? nodes[selectedId as
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
|
|
75
|
+
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
|
|
76
|
+
const measurableNode =
|
|
77
|
+
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
|
|
60
78
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
const [objectState, setObjectState] = useState<{
|
|
80
|
+
id: AnyNodeId
|
|
81
|
+
object: THREE.Object3D
|
|
82
|
+
} | null>(null)
|
|
83
|
+
const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
|
|
64
84
|
|
|
65
85
|
useFrame(() => {
|
|
66
|
-
if (!selectedId ||
|
|
86
|
+
if (!selectedId || selectedObject) return
|
|
67
87
|
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
88
|
+
const nextObject = sceneRegistry.nodes.get(selectedId)
|
|
89
|
+
if (nextObject) {
|
|
90
|
+
setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
|
|
71
91
|
}
|
|
72
92
|
})
|
|
73
93
|
|
|
74
|
-
if (!(
|
|
94
|
+
if (!(measurableNode && selectedObject)) return null
|
|
75
95
|
|
|
76
|
-
return createPortal(<
|
|
96
|
+
return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
function getLevelWalls(
|
|
@@ -92,6 +112,114 @@ function getLevelWalls(
|
|
|
92
112
|
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
|
|
93
113
|
}
|
|
94
114
|
|
|
115
|
+
function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) {
|
|
116
|
+
if (!point) return false
|
|
117
|
+
|
|
118
|
+
return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getWallFaceLines(
|
|
122
|
+
wall: WallNode,
|
|
123
|
+
miterData: WallMiterData,
|
|
124
|
+
): { left: WallFaceLine; right: WallFaceLine } | null {
|
|
125
|
+
if (isCurvedWall(wall)) return null
|
|
126
|
+
|
|
127
|
+
const footprint = getWallPlanFootprint(wall, miterData)
|
|
128
|
+
if (footprint.length < 4) return null
|
|
129
|
+
|
|
130
|
+
const startRight = footprint[0]
|
|
131
|
+
const endRight = footprint[1]
|
|
132
|
+
const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end)
|
|
133
|
+
const endLeft = footprint[hasEndCenterPoint ? 3 : 2]
|
|
134
|
+
const lastPoint = footprint[footprint.length - 1]
|
|
135
|
+
const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)
|
|
136
|
+
const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1]
|
|
137
|
+
|
|
138
|
+
if (!(startRight && endRight && endLeft && startLeft)) return null
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
left: {
|
|
142
|
+
start: startLeft,
|
|
143
|
+
end: endLeft,
|
|
144
|
+
},
|
|
145
|
+
right: {
|
|
146
|
+
start: startRight,
|
|
147
|
+
end: endRight,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getLineMidpoint(line: WallFaceLine): Point2D {
|
|
153
|
+
return {
|
|
154
|
+
x: (line.start.x + line.end.x) / 2,
|
|
155
|
+
y: (line.start.y + line.end.y) / 2,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getLevelWallsCenter(levelWalls: WallNode[]): Point2D {
|
|
160
|
+
let minX = Number.POSITIVE_INFINITY
|
|
161
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
162
|
+
let minY = Number.POSITIVE_INFINITY
|
|
163
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
164
|
+
|
|
165
|
+
for (const candidateWall of levelWalls) {
|
|
166
|
+
minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0])
|
|
167
|
+
maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0])
|
|
168
|
+
minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1])
|
|
169
|
+
maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2,
|
|
174
|
+
y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getWallOuterFaceLine(
|
|
179
|
+
wall: WallNode,
|
|
180
|
+
miterData: WallMiterData,
|
|
181
|
+
levelWalls: WallNode[],
|
|
182
|
+
): WallFaceLine | null {
|
|
183
|
+
const faceLines = getWallFaceLines(wall, miterData)
|
|
184
|
+
if (!faceLines) return null
|
|
185
|
+
|
|
186
|
+
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
|
187
|
+
return faceLines.left
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
|
|
191
|
+
return faceLines.right
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const dx = wall.end[0] - wall.start[0]
|
|
195
|
+
const dy = wall.end[1] - wall.start[1]
|
|
196
|
+
const length = Math.hypot(dx, dy)
|
|
197
|
+
if (length < 1e-6) return null
|
|
198
|
+
|
|
199
|
+
const wallMidpoint = {
|
|
200
|
+
x: (wall.start[0] + wall.end[0]) / 2,
|
|
201
|
+
y: (wall.start[1] + wall.end[1]) / 2,
|
|
202
|
+
}
|
|
203
|
+
const levelCenter = getLevelWallsCenter(levelWalls)
|
|
204
|
+
const normal = { x: -dy / length, y: dx / length }
|
|
205
|
+
const fromCenter = {
|
|
206
|
+
x: wallMidpoint.x - levelCenter.x,
|
|
207
|
+
y: wallMidpoint.y - levelCenter.y,
|
|
208
|
+
}
|
|
209
|
+
const outwardNormal =
|
|
210
|
+
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
|
|
211
|
+
const rightMidpoint = getLineMidpoint(faceLines.right)
|
|
212
|
+
const leftMidpoint = getLineMidpoint(faceLines.left)
|
|
213
|
+
const rightScore =
|
|
214
|
+
(rightMidpoint.x - wallMidpoint.x) * outwardNormal.x +
|
|
215
|
+
(rightMidpoint.y - wallMidpoint.y) * outwardNormal.y
|
|
216
|
+
const leftScore =
|
|
217
|
+
(leftMidpoint.x - wallMidpoint.x) * outwardNormal.x +
|
|
218
|
+
(leftMidpoint.y - wallMidpoint.y) * outwardNormal.y
|
|
219
|
+
|
|
220
|
+
return rightScore >= leftScore ? faceLines.right : faceLines.left
|
|
221
|
+
}
|
|
222
|
+
|
|
95
223
|
function getWallMiddlePoints(
|
|
96
224
|
wall: WallNode,
|
|
97
225
|
miterData: WallMiterData,
|
|
@@ -131,43 +259,171 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
|
|
|
131
259
|
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
|
132
260
|
}
|
|
133
261
|
|
|
262
|
+
function getWallExteriorOffsetSign(
|
|
263
|
+
wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
|
|
264
|
+
levelWalls: WallNode[],
|
|
265
|
+
) {
|
|
266
|
+
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
|
267
|
+
return 1
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
|
|
271
|
+
return -1
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const dx = wall.end[0] - wall.start[0]
|
|
275
|
+
const dy = wall.end[1] - wall.start[1]
|
|
276
|
+
const length = Math.hypot(dx, dy)
|
|
277
|
+
|
|
278
|
+
if (length < 1e-6) return 1
|
|
279
|
+
|
|
280
|
+
const wallMidpoint = {
|
|
281
|
+
x: (wall.start[0] + wall.end[0]) / 2,
|
|
282
|
+
y: (wall.start[1] + wall.end[1]) / 2,
|
|
283
|
+
}
|
|
284
|
+
const levelCenter = getLevelWallsCenter(levelWalls)
|
|
285
|
+
const normal = { x: -dy / length, y: dx / length }
|
|
286
|
+
const fromCenter = {
|
|
287
|
+
x: wallMidpoint.x - levelCenter.x,
|
|
288
|
+
y: wallMidpoint.y - levelCenter.y,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getCurvedWallMeasurementPath(
|
|
295
|
+
wall: WallNode,
|
|
296
|
+
miterData: WallMiterData,
|
|
297
|
+
levelWalls: WallNode[],
|
|
298
|
+
): Point2D[] | null {
|
|
299
|
+
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
|
|
300
|
+
if (!boundaryPoints) return null
|
|
301
|
+
|
|
302
|
+
const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
|
|
303
|
+
const sidePointCount = 25
|
|
304
|
+
if (surface.length < sidePointCount * 2) return null
|
|
305
|
+
|
|
306
|
+
const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
|
|
307
|
+
if (offsetSign >= 0) {
|
|
308
|
+
return surface.slice(sidePointCount).reverse()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return surface.slice(0, sidePointCount)
|
|
312
|
+
}
|
|
313
|
+
|
|
134
314
|
function buildMeasurementGuide(
|
|
135
315
|
wall: WallNode,
|
|
136
316
|
nodes: Record<string, WallNode | { type: string; children?: string[] }>,
|
|
137
317
|
): MeasurementGuide | null {
|
|
138
318
|
const levelWalls = getLevelWalls(wall, nodes)
|
|
139
319
|
const miterData = calculateLevelMiters(levelWalls)
|
|
140
|
-
const
|
|
141
|
-
|
|
320
|
+
const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
|
|
321
|
+
const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
|
|
322
|
+
const measurementPoints = measurementLine ?? fallbackMiddlePoints
|
|
323
|
+
if (!measurementPoints) return null
|
|
142
324
|
|
|
143
325
|
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
144
|
-
const startLocal = worldPointToWallLocal(wall,
|
|
145
|
-
const endLocal = worldPointToWallLocal(wall,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
326
|
+
const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
|
|
327
|
+
const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
|
|
328
|
+
const curvedMeasurementPath = isCurvedWall(wall)
|
|
329
|
+
? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
|
|
330
|
+
: null
|
|
331
|
+
const guidePath: Vec3[] = curvedMeasurementPath
|
|
332
|
+
? curvedMeasurementPath.map((point) => {
|
|
333
|
+
const localPoint = worldPointToWallLocal(wall, point)
|
|
334
|
+
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
|
|
335
|
+
})
|
|
336
|
+
: isCurvedWall(wall)
|
|
337
|
+
? sampleWallCenterline(wall, 24).map((point, index, points) => {
|
|
338
|
+
const localPoint =
|
|
339
|
+
index === 0
|
|
340
|
+
? startLocal
|
|
341
|
+
: index === points.length - 1
|
|
342
|
+
? endLocal
|
|
343
|
+
: worldPointToWallLocal(wall, point)
|
|
344
|
+
|
|
345
|
+
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
|
|
346
|
+
})
|
|
347
|
+
: [
|
|
348
|
+
[startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
|
|
349
|
+
[endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
if (guidePath.length < 2) return null
|
|
353
|
+
|
|
354
|
+
let guideLength = 0
|
|
355
|
+
for (let index = 1; index < guidePath.length; index += 1) {
|
|
356
|
+
const prev = guidePath[index - 1]!
|
|
357
|
+
const next = guidePath[index]!
|
|
358
|
+
guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
|
|
359
|
+
}
|
|
153
360
|
|
|
154
|
-
if (!Number.isFinite(
|
|
361
|
+
if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
|
|
155
362
|
|
|
156
363
|
// Extension lines coming out of the extremity markers of the wall
|
|
157
364
|
const extOvershoot = 0.04
|
|
365
|
+
const guideStart = guidePath[0]!
|
|
366
|
+
const guideEnd = guidePath[guidePath.length - 1]!
|
|
367
|
+
const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
|
|
368
|
+
const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
|
|
369
|
+
const midpoint = curvedMeasurementPath
|
|
370
|
+
? guidePath[Math.floor(guidePath.length / 2)]!
|
|
371
|
+
: ([
|
|
372
|
+
(guideStart[0] + guideEnd[0]) / 2,
|
|
373
|
+
guideStart[1],
|
|
374
|
+
(guideStart[2] + guideEnd[2]) / 2,
|
|
375
|
+
] as Vec3)
|
|
376
|
+
const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3
|
|
377
|
+
const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart
|
|
378
|
+
const tickDx = guideEnd[0] - beforeGuideEnd[0]
|
|
379
|
+
const tickDz = guideEnd[2] - beforeGuideEnd[2]
|
|
380
|
+
const tickLength = Math.hypot(tickDx, tickDz)
|
|
381
|
+
const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1
|
|
382
|
+
const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0
|
|
383
|
+
const tickUnitX = -tangentZ
|
|
384
|
+
const tickUnitZ = tangentX
|
|
385
|
+
const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] })
|
|
386
|
+
const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0]
|
|
387
|
+
const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2]
|
|
388
|
+
const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1
|
|
389
|
+
const heightGuidePosition = [
|
|
390
|
+
rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
|
|
391
|
+
0,
|
|
392
|
+
rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
|
|
393
|
+
] as Vec3
|
|
394
|
+
const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({
|
|
395
|
+
start: [
|
|
396
|
+
heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH,
|
|
397
|
+
y,
|
|
398
|
+
heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
|
|
399
|
+
],
|
|
400
|
+
end: [
|
|
401
|
+
heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH,
|
|
402
|
+
y,
|
|
403
|
+
heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
|
|
404
|
+
],
|
|
405
|
+
})
|
|
406
|
+
const bottomHeightTick = getHorizontalHeightTick(0)
|
|
407
|
+
const topHeightTick = getHorizontalHeightTick(height)
|
|
158
408
|
|
|
159
409
|
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,
|
|
410
|
+
guidePath,
|
|
411
|
+
extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
|
|
412
|
+
extStartEnd: [
|
|
413
|
+
extensionStartBase[0],
|
|
414
|
+
height + GUIDE_Y_OFFSET + extOvershoot,
|
|
415
|
+
extensionStartBase[2],
|
|
170
416
|
],
|
|
417
|
+
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
|
|
418
|
+
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
|
|
419
|
+
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
|
|
420
|
+
heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
|
|
421
|
+
heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
|
|
422
|
+
heightBottomTickStart: bottomHeightTick.start,
|
|
423
|
+
heightBottomTickEnd: bottomHeightTick.end,
|
|
424
|
+
heightTopTickStart: topHeightTick.start,
|
|
425
|
+
heightTopTickEnd: topHeightTick.end,
|
|
426
|
+
heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
|
|
171
427
|
}
|
|
172
428
|
}
|
|
173
429
|
|
|
@@ -208,6 +464,55 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
|
|
|
208
464
|
)
|
|
209
465
|
}
|
|
210
466
|
|
|
467
|
+
function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
|
|
468
|
+
return (
|
|
469
|
+
<>
|
|
470
|
+
{path.slice(1).map((point, index) => (
|
|
471
|
+
<MeasurementBar color={color} end={point} key={index} start={path[index]!} />
|
|
472
|
+
))}
|
|
473
|
+
</>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function MeasurementLabel({
|
|
478
|
+
label,
|
|
479
|
+
position,
|
|
480
|
+
color,
|
|
481
|
+
shadowColor,
|
|
482
|
+
}: {
|
|
483
|
+
label: string
|
|
484
|
+
position: Vec3
|
|
485
|
+
color: string
|
|
486
|
+
shadowColor: string
|
|
487
|
+
}) {
|
|
488
|
+
return (
|
|
489
|
+
<Html
|
|
490
|
+
center
|
|
491
|
+
position={position}
|
|
492
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
493
|
+
zIndexRange={[20, 0]}
|
|
494
|
+
>
|
|
495
|
+
<div
|
|
496
|
+
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
|
497
|
+
style={{
|
|
498
|
+
color,
|
|
499
|
+
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
{label}
|
|
503
|
+
</div>
|
|
504
|
+
</Html>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
|
|
509
|
+
if (node.type === 'wall') {
|
|
510
|
+
return <WallMeasurementAnnotation wall={node} />
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return null
|
|
514
|
+
}
|
|
515
|
+
|
|
211
516
|
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
212
517
|
const nodes = useScene((state) => state.nodes)
|
|
213
518
|
const theme = useViewer((state) => state.theme)
|
|
@@ -216,10 +521,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
216
521
|
const color = isNight ? '#ffffff' : '#111111'
|
|
217
522
|
const shadowColor = isNight ? '#111111' : '#ffffff'
|
|
218
523
|
|
|
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
524
|
const guide = useMemo(
|
|
224
525
|
() =>
|
|
225
526
|
buildMeasurementGuide(
|
|
@@ -228,31 +529,49 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
228
529
|
),
|
|
229
530
|
[nodes, wall],
|
|
230
531
|
)
|
|
532
|
+
const length = useMemo(() => {
|
|
533
|
+
if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
|
|
534
|
+
return getWallCurveLength(wall)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let total = 0
|
|
538
|
+
for (let index = 1; index < guide.guidePath.length; index += 1) {
|
|
539
|
+
const prev = guide.guidePath[index - 1]!
|
|
540
|
+
const next = guide.guidePath[index]!
|
|
541
|
+
total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
|
|
542
|
+
}
|
|
543
|
+
return total
|
|
544
|
+
}, [guide, wall])
|
|
545
|
+
const label = formatMeasurement(length, unit)
|
|
546
|
+
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
|
231
547
|
|
|
232
548
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
|
233
549
|
|
|
234
550
|
return (
|
|
235
551
|
<group>
|
|
236
|
-
<
|
|
552
|
+
<MeasurementPath color={color} path={guide.guidePath} />
|
|
237
553
|
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
|
238
554
|
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
|
|
555
|
+
<MeasurementBar color={color} end={guide.heightEnd} start={guide.heightStart} />
|
|
556
|
+
<MeasurementBar
|
|
557
|
+
color={color}
|
|
558
|
+
end={guide.heightBottomTickEnd}
|
|
559
|
+
start={guide.heightBottomTickStart}
|
|
560
|
+
/>
|
|
561
|
+
<MeasurementBar color={color} end={guide.heightTopTickEnd} start={guide.heightTopTickStart} />
|
|
239
562
|
|
|
240
|
-
<
|
|
241
|
-
|
|
563
|
+
<MeasurementLabel
|
|
564
|
+
color={color}
|
|
565
|
+
label={label}
|
|
242
566
|
position={guide.labelPosition}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}}
|
|
252
|
-
>
|
|
253
|
-
{label}
|
|
254
|
-
</div>
|
|
255
|
-
</Html>
|
|
567
|
+
shadowColor={shadowColor}
|
|
568
|
+
/>
|
|
569
|
+
<MeasurementLabel
|
|
570
|
+
color={color}
|
|
571
|
+
label={heightLabel}
|
|
572
|
+
position={guide.heightLabelPosition}
|
|
573
|
+
shadowColor={shadowColor}
|
|
574
|
+
/>
|
|
256
575
|
</group>
|
|
257
576
|
)
|
|
258
577
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, type MouseEvent as ReactMouseEvent } from 'react'
|
|
4
|
+
import useEditor from '../../store/use-editor'
|
|
5
|
+
import { NodeActionMenu } from '../editor/node-action-menu'
|
|
6
|
+
|
|
7
|
+
type SvgPoint = {
|
|
8
|
+
x: number
|
|
9
|
+
y: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
|
|
13
|
+
|
|
14
|
+
export type FloorplanActionMenuEntry = {
|
|
15
|
+
position: SvgPoint | null
|
|
16
|
+
onDelete: FloorplanActionMenuHandler
|
|
17
|
+
onMove: FloorplanActionMenuHandler
|
|
18
|
+
onAddHole?: FloorplanActionMenuHandler
|
|
19
|
+
onDuplicate?: FloorplanActionMenuHandler
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type FloorplanActionMenuLayerProps = {
|
|
23
|
+
item: FloorplanActionMenuEntry
|
|
24
|
+
wall: FloorplanActionMenuEntry
|
|
25
|
+
fence: FloorplanActionMenuEntry
|
|
26
|
+
slab: FloorplanActionMenuEntry
|
|
27
|
+
ceiling: FloorplanActionMenuEntry
|
|
28
|
+
opening: FloorplanActionMenuEntry
|
|
29
|
+
spawn: FloorplanActionMenuEntry
|
|
30
|
+
stair: FloorplanActionMenuEntry
|
|
31
|
+
roof: FloorplanActionMenuEntry
|
|
32
|
+
offsetY?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|
36
|
+
item,
|
|
37
|
+
wall,
|
|
38
|
+
fence,
|
|
39
|
+
slab,
|
|
40
|
+
ceiling,
|
|
41
|
+
opening,
|
|
42
|
+
spawn,
|
|
43
|
+
stair,
|
|
44
|
+
roof,
|
|
45
|
+
offsetY = 10,
|
|
46
|
+
}: FloorplanActionMenuLayerProps) {
|
|
47
|
+
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
48
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
49
|
+
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
|
50
|
+
const curvingWall = useEditor((state) => state.curvingWall)
|
|
51
|
+
const curvingFence = useEditor((state) => state.curvingFence)
|
|
52
|
+
|
|
53
|
+
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entries: FloorplanActionMenuEntry[] = [
|
|
58
|
+
item,
|
|
59
|
+
wall,
|
|
60
|
+
fence,
|
|
61
|
+
slab,
|
|
62
|
+
ceiling,
|
|
63
|
+
opening,
|
|
64
|
+
spawn,
|
|
65
|
+
stair,
|
|
66
|
+
roof,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
{entries.map((entry, index) =>
|
|
72
|
+
entry.position ? (
|
|
73
|
+
<div
|
|
74
|
+
className="absolute z-30"
|
|
75
|
+
key={index}
|
|
76
|
+
style={{
|
|
77
|
+
left: entry.position.x,
|
|
78
|
+
top: entry.position.y,
|
|
79
|
+
transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<NodeActionMenu
|
|
83
|
+
onAddHole={entry.onAddHole}
|
|
84
|
+
onDelete={entry.onDelete}
|
|
85
|
+
onDuplicate={entry.onDuplicate}
|
|
86
|
+
onMove={entry.onMove}
|
|
87
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
88
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
) : null,
|
|
92
|
+
)}
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
})
|