@pascal-app/editor 0.6.0 → 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 +9 -5
- 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 +20 -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 +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- 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 +267 -36
- 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/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- 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 +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- 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 +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- 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 +1116 -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 +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- 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 +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- 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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- 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/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/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getWallMiterBoundaryPoints,
|
|
9
9
|
getWallPlanFootprint,
|
|
10
10
|
getWallSurfacePolygon,
|
|
11
|
+
type ItemNode,
|
|
11
12
|
isCurvedWall,
|
|
12
13
|
type Point2D,
|
|
13
14
|
pointToKey,
|
|
@@ -27,6 +28,8 @@ const GUIDE_Y_OFFSET = 0.08
|
|
|
27
28
|
const LABEL_LIFT = 0.08
|
|
28
29
|
const BAR_THICKNESS = 0.012
|
|
29
30
|
const LINE_OPACITY = 0.95
|
|
31
|
+
const HEIGHT_TICK_HALF_LENGTH = 0.14
|
|
32
|
+
const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
|
|
30
33
|
|
|
31
34
|
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
|
32
35
|
|
|
@@ -39,6 +42,18 @@ type MeasurementGuide = {
|
|
|
39
42
|
extEndStart: Vec3
|
|
40
43
|
extEndEnd: Vec3
|
|
41
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
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
@@ -57,28 +72,28 @@ export function WallMeasurementLabel() {
|
|
|
57
72
|
const nodes = useScene((state) => state.nodes)
|
|
58
73
|
|
|
59
74
|
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
60
|
-
const selectedNode = selectedId ? nodes[selectedId as
|
|
61
|
-
const
|
|
75
|
+
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
|
|
76
|
+
const measurableNode =
|
|
77
|
+
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
|
|
62
78
|
|
|
63
|
-
const [
|
|
64
|
-
id:
|
|
79
|
+
const [objectState, setObjectState] = useState<{
|
|
80
|
+
id: AnyNodeId
|
|
65
81
|
object: THREE.Object3D
|
|
66
82
|
} | null>(null)
|
|
67
|
-
const
|
|
68
|
-
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
|
|
83
|
+
const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
|
|
69
84
|
|
|
70
85
|
useFrame(() => {
|
|
71
|
-
if (!selectedId ||
|
|
86
|
+
if (!selectedId || selectedObject) return
|
|
72
87
|
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
|
|
88
|
+
const nextObject = sceneRegistry.nodes.get(selectedId)
|
|
89
|
+
if (nextObject) {
|
|
90
|
+
setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
|
|
76
91
|
}
|
|
77
92
|
})
|
|
78
93
|
|
|
79
|
-
if (!(
|
|
94
|
+
if (!(measurableNode && selectedObject)) return null
|
|
80
95
|
|
|
81
|
-
return createPortal(<
|
|
96
|
+
return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
function getLevelWalls(
|
|
@@ -97,6 +112,114 @@ function getLevelWalls(
|
|
|
97
112
|
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
|
|
98
113
|
}
|
|
99
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
|
+
|
|
100
223
|
function getWallMiddlePoints(
|
|
101
224
|
wall: WallNode,
|
|
102
225
|
miterData: WallMiterData,
|
|
@@ -136,7 +259,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
|
|
|
136
259
|
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
|
137
260
|
}
|
|
138
261
|
|
|
139
|
-
function getWallExteriorOffsetSign(
|
|
262
|
+
function getWallExteriorOffsetSign(
|
|
263
|
+
wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
|
|
264
|
+
levelWalls: WallNode[],
|
|
265
|
+
) {
|
|
140
266
|
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
|
|
141
267
|
return 1
|
|
142
268
|
}
|
|
@@ -145,10 +271,31 @@ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'
|
|
|
145
271
|
return -1
|
|
146
272
|
}
|
|
147
273
|
|
|
148
|
-
|
|
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
|
|
149
292
|
}
|
|
150
293
|
|
|
151
|
-
function getCurvedWallMeasurementPath(
|
|
294
|
+
function getCurvedWallMeasurementPath(
|
|
295
|
+
wall: WallNode,
|
|
296
|
+
miterData: WallMiterData,
|
|
297
|
+
levelWalls: WallNode[],
|
|
298
|
+
): Point2D[] | null {
|
|
152
299
|
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
|
|
153
300
|
if (!boundaryPoints) return null
|
|
154
301
|
|
|
@@ -156,7 +303,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
|
|
|
156
303
|
const sidePointCount = 25
|
|
157
304
|
if (surface.length < sidePointCount * 2) return null
|
|
158
305
|
|
|
159
|
-
const offsetSign = getWallExteriorOffsetSign(wall)
|
|
306
|
+
const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
|
|
160
307
|
if (offsetSign >= 0) {
|
|
161
308
|
return surface.slice(sidePointCount).reverse()
|
|
162
309
|
}
|
|
@@ -170,14 +317,16 @@ function buildMeasurementGuide(
|
|
|
170
317
|
): MeasurementGuide | null {
|
|
171
318
|
const levelWalls = getLevelWalls(wall, nodes)
|
|
172
319
|
const miterData = calculateLevelMiters(levelWalls)
|
|
173
|
-
const
|
|
174
|
-
|
|
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
|
|
175
324
|
|
|
176
325
|
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
177
|
-
const startLocal = worldPointToWallLocal(wall,
|
|
178
|
-
const endLocal = worldPointToWallLocal(wall,
|
|
326
|
+
const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
|
|
327
|
+
const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
|
|
179
328
|
const curvedMeasurementPath = isCurvedWall(wall)
|
|
180
|
-
? getCurvedWallMeasurementPath(wall, miterData)
|
|
329
|
+
? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
|
|
181
330
|
: null
|
|
182
331
|
const guidePath: Vec3[] = curvedMeasurementPath
|
|
183
332
|
? curvedMeasurementPath.map((point) => {
|
|
@@ -224,6 +373,38 @@ function buildMeasurementGuide(
|
|
|
224
373
|
guideStart[1],
|
|
225
374
|
(guideStart[2] + guideEnd[2]) / 2,
|
|
226
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)
|
|
227
408
|
|
|
228
409
|
return {
|
|
229
410
|
guidePath,
|
|
@@ -236,6 +417,13 @@ function buildMeasurementGuide(
|
|
|
236
417
|
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
|
|
237
418
|
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
|
|
238
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]],
|
|
239
427
|
}
|
|
240
428
|
}
|
|
241
429
|
|
|
@@ -286,6 +474,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
|
|
|
286
474
|
)
|
|
287
475
|
}
|
|
288
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
|
+
|
|
289
516
|
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
290
517
|
const nodes = useScene((state) => state.nodes)
|
|
291
518
|
const theme = useViewer((state) => state.theme)
|
|
@@ -316,6 +543,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
316
543
|
return total
|
|
317
544
|
}, [guide, wall])
|
|
318
545
|
const label = formatMeasurement(length, unit)
|
|
546
|
+
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
|
|
319
547
|
|
|
320
548
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
|
321
549
|
|
|
@@ -324,23 +552,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
324
552
|
<MeasurementPath color={color} path={guide.guidePath} />
|
|
325
553
|
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
|
326
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} />
|
|
327
562
|
|
|
328
|
-
<
|
|
329
|
-
|
|
563
|
+
<MeasurementLabel
|
|
564
|
+
color={color}
|
|
565
|
+
label={label}
|
|
330
566
|
position={guide.labelPosition}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}}
|
|
340
|
-
>
|
|
341
|
-
{label}
|
|
342
|
-
</div>
|
|
343
|
-
</Html>
|
|
567
|
+
shadowColor={shadowColor}
|
|
568
|
+
/>
|
|
569
|
+
<MeasurementLabel
|
|
570
|
+
color={color}
|
|
571
|
+
label={heightLabel}
|
|
572
|
+
position={guide.heightLabelPosition}
|
|
573
|
+
shadowColor={shadowColor}
|
|
574
|
+
/>
|
|
344
575
|
</group>
|
|
345
576
|
)
|
|
346
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
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import { memo, useMemo } from 'react'
|
|
5
|
+
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
|
|
6
|
+
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
|
7
|
+
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
|
8
|
+
|
|
9
|
+
type SvgPoint = {
|
|
10
|
+
x: number
|
|
11
|
+
y: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type FloorplanCursorIndicator =
|
|
15
|
+
| {
|
|
16
|
+
kind: 'asset'
|
|
17
|
+
iconSrc: string
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
kind: 'icon'
|
|
21
|
+
icon: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type FloorplanCursorIndicatorOverlayProps = {
|
|
25
|
+
cursorPosition: SvgPoint | null
|
|
26
|
+
cursorAnchorPosition: SvgPoint | null
|
|
27
|
+
floorplanSelectionTool: FloorplanSelectionTool
|
|
28
|
+
movingOpeningType: 'door' | 'window' | null
|
|
29
|
+
isPanning: boolean
|
|
30
|
+
cursorColor: string
|
|
31
|
+
indicatorLineHeight?: number
|
|
32
|
+
indicatorBadgeOffsetX?: number
|
|
33
|
+
indicatorBadgeOffsetY?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
|
|
37
|
+
cursorPosition,
|
|
38
|
+
cursorAnchorPosition,
|
|
39
|
+
floorplanSelectionTool,
|
|
40
|
+
movingOpeningType,
|
|
41
|
+
isPanning,
|
|
42
|
+
cursorColor,
|
|
43
|
+
indicatorLineHeight = 18,
|
|
44
|
+
indicatorBadgeOffsetX = 14,
|
|
45
|
+
indicatorBadgeOffsetY = 14,
|
|
46
|
+
}: FloorplanCursorIndicatorOverlayProps) {
|
|
47
|
+
const mode = useEditor((state) => state.mode)
|
|
48
|
+
const tool = useEditor((state) => state.tool)
|
|
49
|
+
const structureLayer = useEditor((state) => state.structureLayer)
|
|
50
|
+
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
51
|
+
|
|
52
|
+
const activeFloorplanToolConfig = useMemo(() => {
|
|
53
|
+
if (movingOpeningType) {
|
|
54
|
+
return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (mode !== 'build' || !tool) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (tool === 'item' && catalogCategory) {
|
|
62
|
+
return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return structureTools.find((entry) => entry.id === tool) ?? null
|
|
66
|
+
}, [catalogCategory, mode, movingOpeningType, tool])
|
|
67
|
+
|
|
68
|
+
const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
|
|
69
|
+
if (activeFloorplanToolConfig) {
|
|
70
|
+
return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
|
|
74
|
+
return { kind: 'icon', icon: 'mdi:select-drag' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (mode === 'delete') {
|
|
78
|
+
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
|
83
|
+
|
|
84
|
+
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
|
|
85
|
+
|
|
86
|
+
if (!(indicator && position) || isPanning) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
className="pointer-events-none absolute z-20"
|
|
94
|
+
style={{ left: position.x, top: position.y }}
|
|
95
|
+
>
|
|
96
|
+
{mode === 'delete' ? (
|
|
97
|
+
<div
|
|
98
|
+
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
|
99
|
+
style={{
|
|
100
|
+
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
|
|
101
|
+
transform: `translate(${indicatorBadgeOffsetX}px, ${indicatorBadgeOffsetY}px)`,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{indicator.kind === 'asset' ? (
|
|
105
|
+
<img
|
|
106
|
+
alt=""
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
109
|
+
src={indicator.iconSrc}
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
<Icon
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
115
|
+
color={cursorColor}
|
|
116
|
+
height={18}
|
|
117
|
+
icon={indicator.icon}
|
|
118
|
+
width={18}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
) : (
|
|
123
|
+
<>
|
|
124
|
+
<div
|
|
125
|
+
className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
|
|
126
|
+
style={{
|
|
127
|
+
backgroundColor: cursorColor,
|
|
128
|
+
boxShadow: `0 0 12px ${cursorColor}55`,
|
|
129
|
+
height: indicatorLineHeight,
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
<div
|
|
133
|
+
className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
|
134
|
+
style={{
|
|
135
|
+
transform: `translate(-50%, calc(-100% - ${indicatorLineHeight}px))`,
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{indicator.kind === 'asset' ? (
|
|
139
|
+
<img
|
|
140
|
+
alt=""
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
143
|
+
src={indicator.iconSrc}
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
146
|
+
<Icon
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
149
|
+
color="white"
|
|
150
|
+
height={18}
|
|
151
|
+
icon={indicator.icon}
|
|
152
|
+
width={18}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
})
|