@pascal-app/editor 0.6.0 → 0.8.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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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 +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -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/ceiling/move-ceiling-tool.tsx +9 -2
- 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/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- 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 +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- 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 +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- 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 +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -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/level-duplication.test.ts +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
18
18
|
import * as THREE from 'three'
|
|
19
|
+
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
|
|
19
20
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
20
21
|
import useEditor from '../../../store/use-editor'
|
|
21
22
|
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
|
@@ -100,6 +101,7 @@ export const MoveRoofTool: React.FC<{
|
|
|
100
101
|
// resetting the mesh position (it resets on dirty) and from triggering
|
|
101
102
|
// expensive merged-mesh CSG rebuilds on every frame.
|
|
102
103
|
let wasCommitted = false
|
|
104
|
+
let wasCancelled = false
|
|
103
105
|
|
|
104
106
|
// Track pending rotation — no store updates during drag
|
|
105
107
|
let pendingRotation: number = movingNode.rotation as number
|
|
@@ -156,7 +158,9 @@ export const MoveRoofTool: React.FC<{
|
|
|
156
158
|
|
|
157
159
|
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
|
158
160
|
if (buildingObj) {
|
|
159
|
-
const worldPoint = buildingObj.localToWorld(
|
|
161
|
+
const worldPoint = buildingObj.localToWorld(
|
|
162
|
+
new THREE.Vector3(localPoint[0], y, localPoint[1]),
|
|
163
|
+
)
|
|
160
164
|
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
|
161
165
|
}
|
|
162
166
|
|
|
@@ -209,7 +213,10 @@ export const MoveRoofTool: React.FC<{
|
|
|
209
213
|
})
|
|
210
214
|
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
211
215
|
|
|
212
|
-
if (
|
|
216
|
+
if (
|
|
217
|
+
previousGridPosRef.current &&
|
|
218
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
219
|
+
) {
|
|
213
220
|
sfxEmitter.emit('sfx:grid-snap')
|
|
214
221
|
}
|
|
215
222
|
|
|
@@ -251,11 +258,19 @@ export const MoveRoofTool: React.FC<{
|
|
|
251
258
|
// Resume temporal and apply the final state as a single undoable step.
|
|
252
259
|
useScene.temporal.getState().resume()
|
|
253
260
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
if (isNew && movingNode.type === 'roof') {
|
|
262
|
+
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
|
|
263
|
+
position: [localX, movingNode.position[1], localZ],
|
|
264
|
+
rotation: pendingRotation,
|
|
265
|
+
metadata: committedMeta,
|
|
266
|
+
})
|
|
267
|
+
} else {
|
|
268
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
269
|
+
position: [localX, movingNode.position[1], localZ],
|
|
270
|
+
rotation: pendingRotation,
|
|
271
|
+
metadata: committedMeta,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
259
274
|
|
|
260
275
|
useScene.temporal.getState().pause()
|
|
261
276
|
|
|
@@ -267,6 +282,7 @@ export const MoveRoofTool: React.FC<{
|
|
|
267
282
|
}
|
|
268
283
|
|
|
269
284
|
const onCancel = () => {
|
|
285
|
+
wasCancelled = true
|
|
270
286
|
useLiveTransforms.getState().clear(movingNode.id)
|
|
271
287
|
if (isNew) {
|
|
272
288
|
useScene.getState().deleteNode(movingNode.id)
|
|
@@ -325,16 +341,12 @@ export const MoveRoofTool: React.FC<{
|
|
|
325
341
|
// Clear ephemeral live transform
|
|
326
342
|
useLiveTransforms.getState().clear(movingNode.id)
|
|
327
343
|
|
|
328
|
-
if (!wasCommitted) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
rotation: original.rotation,
|
|
335
|
-
metadata: original.metadata,
|
|
336
|
-
})
|
|
337
|
-
}
|
|
344
|
+
if (!(wasCommitted || wasCancelled || isNew)) {
|
|
345
|
+
useScene.getState().updateNode(movingNode.id, {
|
|
346
|
+
position: original.position,
|
|
347
|
+
rotation: original.rotation,
|
|
348
|
+
metadata: original.metadata,
|
|
349
|
+
})
|
|
338
350
|
}
|
|
339
351
|
useScene.temporal.getState().resume()
|
|
340
352
|
emitter.off('grid:move', onGridMove)
|
|
@@ -192,7 +192,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|
|
192
192
|
|
|
193
193
|
const result: string[] = []
|
|
194
194
|
|
|
195
|
-
if (phase === 'structure' && structureLayer === '
|
|
195
|
+
if (phase === 'structure' && structureLayer === 'zones') {
|
|
196
|
+
for (const childId of levelNode.children) {
|
|
197
|
+
const node = nodes[childId as AnyNodeId]
|
|
198
|
+
if (!node || node.type !== 'zone') continue
|
|
199
|
+
const zone = node as ZoneNode
|
|
200
|
+
if (polygonIntersectsBounds(zone.polygon, bounds)) {
|
|
201
|
+
result.push(zone.id)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// structure (elements) and furnish: collect all node types
|
|
196
206
|
for (const childId of levelNode.children) {
|
|
197
207
|
const node = nodes[childId as AnyNodeId]
|
|
198
208
|
if (!node) continue
|
|
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|
|
240
250
|
if (objectBoundsIntersectsBounds(node.id, bounds)) {
|
|
241
251
|
result.push(node.id)
|
|
242
252
|
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
} else if (phase === 'structure' && structureLayer === 'zones') {
|
|
246
|
-
for (const childId of levelNode.children) {
|
|
247
|
-
const node = nodes[childId as AnyNodeId]
|
|
248
|
-
if (!node || node.type !== 'zone') continue
|
|
249
|
-
const zone = node as ZoneNode
|
|
250
|
-
if (polygonIntersectsBounds(zone.polygon, bounds)) {
|
|
251
|
-
result.push(zone.id)
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
} else if (phase === 'furnish') {
|
|
255
|
-
for (const childId of levelNode.children) {
|
|
256
|
-
const node = nodes[childId as AnyNodeId]
|
|
257
|
-
if (!node) continue
|
|
258
|
-
if (node.type === 'item') {
|
|
253
|
+
} else if (node.type === 'item') {
|
|
259
254
|
const item = node as ItemNode
|
|
260
255
|
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
|
261
256
|
const xz = getNodeWorldXZ(item.id)
|
|
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
|
|
|
10
10
|
|
|
11
11
|
type DragState = {
|
|
12
12
|
isDragging: boolean
|
|
13
|
-
mode: 'vertex' | 'polygon'
|
|
13
|
+
mode: 'vertex' | 'polygon' | 'edge'
|
|
14
14
|
vertexIndex: number | null
|
|
15
|
+
edgeIndex?: number
|
|
16
|
+
edgeNormal?: [number, number]
|
|
15
17
|
initialPosition: [number, number]
|
|
16
18
|
initialPolygon: Array<[number, number]>
|
|
17
19
|
pointerId: number
|
|
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
|
|
|
28
30
|
surfaceHeight?: number
|
|
29
31
|
/** Whether to show the center handle that moves the entire polygon. */
|
|
30
32
|
allowPolygonMove?: boolean
|
|
33
|
+
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
|
34
|
+
allowEdgeMove?: boolean
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -35,6 +39,17 @@ export interface PolygonEditorProps {
|
|
|
35
39
|
* Used by zone and site boundary editors
|
|
36
40
|
*/
|
|
37
41
|
const MIN_HANDLE_HEIGHT = 0.15
|
|
42
|
+
const EDGE_HANDLE_HEIGHT = 0.06
|
|
43
|
+
const EDGE_HANDLE_THICKNESS = 0.12
|
|
44
|
+
|
|
45
|
+
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
|
|
46
|
+
const dx = end[0] - start[0]
|
|
47
|
+
const dz = end[1] - start[1]
|
|
48
|
+
const length = Math.hypot(dx, dz)
|
|
49
|
+
if (length < 1e-6) return null
|
|
50
|
+
|
|
51
|
+
return [-dz / length, dx / length]
|
|
52
|
+
}
|
|
38
53
|
|
|
39
54
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
40
55
|
polygon,
|
|
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
44
59
|
levelId,
|
|
45
60
|
surfaceHeight = 0,
|
|
46
61
|
allowPolygonMove = false,
|
|
62
|
+
allowEdgeMove = false,
|
|
47
63
|
}) => {
|
|
48
64
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
|
49
65
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
|
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
89
105
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
|
90
106
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
|
91
107
|
|
|
108
|
+
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
|
109
|
+
previewPolygonRef.current = nextPolygon
|
|
110
|
+
setPreviewPolygon(nextPolygon)
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
92
113
|
// Keep ref in sync
|
|
93
114
|
useEffect(() => {
|
|
94
115
|
previewPolygonRef.current = previewPolygon
|
|
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
96
117
|
|
|
97
118
|
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
|
98
119
|
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
|
120
|
+
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
|
99
121
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
100
122
|
|
|
101
123
|
const lineRef = useRef<Line>(null!)
|
|
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
106
128
|
if (polygon !== lastPolygonRef.current) {
|
|
107
129
|
lastPolygonRef.current = polygon
|
|
108
130
|
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
|
109
|
-
if (previewPolygon)
|
|
131
|
+
if (previewPolygon) updatePreviewPolygon(null)
|
|
110
132
|
if (dragState) setDragState(null)
|
|
111
133
|
}
|
|
112
134
|
|
|
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
134
156
|
})
|
|
135
157
|
}, [displayPolygon])
|
|
136
158
|
|
|
159
|
+
const edgeHandles = useMemo(() => {
|
|
160
|
+
if (displayPolygon.length < 2) return []
|
|
161
|
+
|
|
162
|
+
return displayPolygon.flatMap(([x1, z1], index) => {
|
|
163
|
+
const nextIndex = (index + 1) % displayPolygon.length
|
|
164
|
+
const [x2, z2] = displayPolygon[nextIndex]!
|
|
165
|
+
const dx = x2 - x1
|
|
166
|
+
const dz = z2 - z1
|
|
167
|
+
const length = Math.hypot(dx, dz)
|
|
168
|
+
if (length < 1e-6) return []
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
index,
|
|
173
|
+
length,
|
|
174
|
+
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
|
|
175
|
+
rotationY: -Math.atan2(dz, dx),
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
})
|
|
179
|
+
}, [displayPolygon])
|
|
180
|
+
|
|
137
181
|
// Update vertex position using grid cursor position
|
|
138
182
|
const handleVertexDrag = useCallback(
|
|
139
183
|
(vertexIndex: number, position: [number, number]) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return newPolygon
|
|
145
|
-
})
|
|
184
|
+
const basePolygon = previewPolygonRef.current ?? polygon
|
|
185
|
+
const newPolygon = [...basePolygon]
|
|
186
|
+
newPolygon[vertexIndex] = position
|
|
187
|
+
updatePreviewPolygon(newPolygon)
|
|
146
188
|
},
|
|
147
|
-
[polygon],
|
|
189
|
+
[polygon, updatePreviewPolygon],
|
|
148
190
|
)
|
|
149
191
|
|
|
150
192
|
// Commit polygon changes
|
|
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
152
194
|
if (previewPolygonRef.current) {
|
|
153
195
|
onPolygonChange(previewPolygonRef.current)
|
|
154
196
|
}
|
|
155
|
-
|
|
197
|
+
updatePreviewPolygon(null)
|
|
156
198
|
setDragState(null)
|
|
157
|
-
}, [onPolygonChange])
|
|
199
|
+
}, [onPolygonChange, updatePreviewPolygon])
|
|
158
200
|
|
|
159
201
|
// Handle adding a new vertex at midpoint
|
|
160
202
|
const handleAddVertex = useCallback(
|
|
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
166
208
|
...basePolygon.slice(afterIndex + 1),
|
|
167
209
|
]
|
|
168
210
|
|
|
169
|
-
|
|
170
|
-
return
|
|
211
|
+
updatePreviewPolygon(newPolygon)
|
|
212
|
+
return {
|
|
213
|
+
polygon: newPolygon,
|
|
214
|
+
vertexIndex: afterIndex + 1,
|
|
215
|
+
}
|
|
171
216
|
},
|
|
172
|
-
[polygon, previewPolygon],
|
|
217
|
+
[polygon, previewPolygon, updatePreviewPolygon],
|
|
173
218
|
)
|
|
174
219
|
|
|
175
220
|
// Handle deleting a vertex
|
|
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
180
225
|
|
|
181
226
|
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
|
182
227
|
onPolygonChange(newPolygon)
|
|
183
|
-
|
|
228
|
+
updatePreviewPolygon(null)
|
|
184
229
|
},
|
|
185
|
-
[polygon, previewPolygon, onPolygonChange, minVertices],
|
|
230
|
+
[polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
|
|
186
231
|
)
|
|
187
232
|
|
|
188
233
|
// Listen to grid:move events to track cursor position
|
|
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
212
257
|
} else if (dragState.mode === 'polygon') {
|
|
213
258
|
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
|
214
259
|
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
|
215
|
-
|
|
260
|
+
updatePreviewPolygon(
|
|
216
261
|
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
|
|
217
262
|
)
|
|
263
|
+
} else if (
|
|
264
|
+
dragState.mode === 'edge' &&
|
|
265
|
+
dragState.edgeIndex !== undefined &&
|
|
266
|
+
dragState.edgeNormal
|
|
267
|
+
) {
|
|
268
|
+
const [normalX, normalZ] = dragState.edgeNormal
|
|
269
|
+
const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
|
|
270
|
+
const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
|
|
271
|
+
const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
|
|
272
|
+
const edgeStartIndex = dragState.edgeIndex
|
|
273
|
+
const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
|
|
274
|
+
const nextPolygon = dragState.initialPolygon.map((point, index) => {
|
|
275
|
+
if (index !== edgeStartIndex && index !== edgeEndIndex) {
|
|
276
|
+
return point
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
|
|
280
|
+
number,
|
|
281
|
+
number,
|
|
282
|
+
]
|
|
283
|
+
})
|
|
284
|
+
updatePreviewPolygon(nextPolygon)
|
|
218
285
|
}
|
|
219
286
|
}
|
|
220
287
|
}
|
|
@@ -223,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
223
290
|
return () => {
|
|
224
291
|
emitter.off('grid:move', onGridMove)
|
|
225
292
|
}
|
|
226
|
-
}, [dragState, handleVertexDrag])
|
|
293
|
+
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
|
227
294
|
|
|
228
295
|
// Set up pointer up listener for ending drag
|
|
229
296
|
useEffect(() => {
|
|
@@ -288,6 +355,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
288
355
|
if (displayPolygon.length < minVertices) return null
|
|
289
356
|
|
|
290
357
|
const canDelete = displayPolygon.length > minVertices
|
|
358
|
+
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
359
|
+
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
|
|
291
360
|
|
|
292
361
|
const editorContent = (
|
|
293
362
|
<group>
|
|
@@ -316,7 +385,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
316
385
|
const isHovered = hoveredVertex === index
|
|
317
386
|
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
|
318
387
|
const radius = 0.1
|
|
319
|
-
const height =
|
|
388
|
+
const height = handleHeight
|
|
320
389
|
|
|
321
390
|
return (
|
|
322
391
|
<mesh
|
|
@@ -337,6 +406,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
337
406
|
onPointerDown={(e) => {
|
|
338
407
|
if (e.button !== 0) return
|
|
339
408
|
e.stopPropagation()
|
|
409
|
+
setHoveredEdge(null)
|
|
340
410
|
setDragState({
|
|
341
411
|
isDragging: true,
|
|
342
412
|
mode: 'vertex',
|
|
@@ -375,6 +445,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
375
445
|
onPointerDown={(e) => {
|
|
376
446
|
if (e.button !== 0) return
|
|
377
447
|
e.stopPropagation()
|
|
448
|
+
setHoveredEdge(null)
|
|
378
449
|
setDragState({
|
|
379
450
|
isDragging: true,
|
|
380
451
|
mode: 'polygon',
|
|
@@ -384,23 +455,75 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
384
455
|
pointerId: e.pointerId,
|
|
385
456
|
})
|
|
386
457
|
}}
|
|
387
|
-
position={[
|
|
388
|
-
polygonCenter[0],
|
|
389
|
-
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
|
|
390
|
-
polygonCenter[1],
|
|
391
|
-
]}
|
|
458
|
+
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
|
392
459
|
>
|
|
393
460
|
<sphereGeometry args={[0.09, 20, 20]} />
|
|
394
461
|
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
|
395
462
|
</mesh>
|
|
396
463
|
)}
|
|
397
464
|
|
|
465
|
+
{allowEdgeMove &&
|
|
466
|
+
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
|
467
|
+
const isHovered = hoveredEdge === index
|
|
468
|
+
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<mesh
|
|
472
|
+
key={`edge-${index}`}
|
|
473
|
+
layers={EDITOR_LAYER}
|
|
474
|
+
onClick={(e) => {
|
|
475
|
+
if (e.button !== 0) return
|
|
476
|
+
e.stopPropagation()
|
|
477
|
+
}}
|
|
478
|
+
onPointerDown={(e) => {
|
|
479
|
+
if (e.button !== 0) return
|
|
480
|
+
e.stopPropagation()
|
|
481
|
+
const start = displayPolygon[index]
|
|
482
|
+
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
|
483
|
+
if (!(start && end)) return
|
|
484
|
+
|
|
485
|
+
const edgeNormal = getEdgeNormal(start, end)
|
|
486
|
+
if (!edgeNormal) return
|
|
487
|
+
|
|
488
|
+
setHoveredEdge(null)
|
|
489
|
+
setDragState({
|
|
490
|
+
isDragging: true,
|
|
491
|
+
mode: 'edge',
|
|
492
|
+
vertexIndex: null,
|
|
493
|
+
edgeIndex: index,
|
|
494
|
+
edgeNormal,
|
|
495
|
+
initialPosition: cursorPosition,
|
|
496
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
497
|
+
pointerId: e.pointerId,
|
|
498
|
+
})
|
|
499
|
+
}}
|
|
500
|
+
onPointerEnter={(e) => {
|
|
501
|
+
e.stopPropagation()
|
|
502
|
+
setHoveredEdge(index)
|
|
503
|
+
}}
|
|
504
|
+
onPointerLeave={(e) => {
|
|
505
|
+
e.stopPropagation()
|
|
506
|
+
setHoveredEdge(null)
|
|
507
|
+
}}
|
|
508
|
+
position={[midpoint[0], edgeHandleY, midpoint[1]]}
|
|
509
|
+
rotation={[0, rotationY, 0]}
|
|
510
|
+
>
|
|
511
|
+
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
|
512
|
+
<meshStandardMaterial
|
|
513
|
+
color={isDragging ? '#22c55e' : '#94a3b8'}
|
|
514
|
+
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
|
515
|
+
transparent
|
|
516
|
+
/>
|
|
517
|
+
</mesh>
|
|
518
|
+
)
|
|
519
|
+
})}
|
|
520
|
+
|
|
398
521
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
399
522
|
{!dragState &&
|
|
400
523
|
midpoints.map(([x, z], index) => {
|
|
401
524
|
const isHovered = hoveredMidpoint === index
|
|
402
525
|
const radius = 0.06
|
|
403
|
-
const height =
|
|
526
|
+
const height = handleHeight
|
|
404
527
|
|
|
405
528
|
return (
|
|
406
529
|
<mesh
|
|
@@ -413,12 +536,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
413
536
|
onPointerDown={(e) => {
|
|
414
537
|
if (e.button !== 0) return
|
|
415
538
|
e.stopPropagation()
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
539
|
+
const insertedVertex = handleAddVertex(index, [x!, z!])
|
|
540
|
+
if (insertedVertex.vertexIndex >= 0) {
|
|
418
541
|
setDragState({
|
|
419
542
|
isDragging: true,
|
|
420
|
-
|
|
543
|
+
mode: 'vertex',
|
|
544
|
+
vertexIndex: insertedVertex.vertexIndex,
|
|
421
545
|
initialPosition: [x!, z!],
|
|
546
|
+
initialPolygon: insertedVertex.polygon,
|
|
422
547
|
pointerId: e.pointerId,
|
|
423
548
|
})
|
|
424
549
|
setHoveredMidpoint(null)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FenceNode,
|
|
3
|
+
getWallCurveFrameAt,
|
|
4
|
+
getWallCurveLength,
|
|
5
|
+
isCurvedWall,
|
|
6
|
+
type WallNode,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export type PlanPoint = [number, number]
|
|
10
|
+
|
|
11
|
+
export type SegmentAngleLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
|
12
|
+
|
|
13
|
+
export type SegmentAngleReference = {
|
|
14
|
+
vector: PlanPoint
|
|
15
|
+
orientation: 'directed' | 'axis'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const POINT_MATCH_TOLERANCE = 1e-5
|
|
19
|
+
const SEGMENT_POINT_TOLERANCE = 0.15
|
|
20
|
+
const CURVE_TANGENT_SAMPLE_SPACING = 0.08
|
|
21
|
+
|
|
22
|
+
function distanceSquared(a: PlanPoint, b: PlanPoint) {
|
|
23
|
+
const dx = a[0] - b[0]
|
|
24
|
+
const dz = a[1] - b[1]
|
|
25
|
+
|
|
26
|
+
return dx * dx + dz * dz
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) {
|
|
30
|
+
return distanceSquared(a, b) <= tolerance * tolerance
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
|
|
34
|
+
const [x1, z1] = segment.start
|
|
35
|
+
const [x2, z2] = segment.end
|
|
36
|
+
const dx = x2 - x1
|
|
37
|
+
const dz = z2 - z1
|
|
38
|
+
const lengthSquared = dx * dx + dz * dz
|
|
39
|
+
|
|
40
|
+
if (lengthSquared < 1e-9) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
45
|
+
if (t <= 0 || t >= 1) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [x1 + dx * t, z1 + dz * t]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
|
|
53
|
+
const curveLength = getWallCurveLength(segment)
|
|
54
|
+
const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING))
|
|
55
|
+
let best: { distance: number; tangent: PlanPoint } | null = null
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
58
|
+
const frame = getWallCurveFrameAt(segment, index / sampleCount)
|
|
59
|
+
const candidate: PlanPoint = [frame.point.x, frame.point.y]
|
|
60
|
+
const distance = distanceSquared(point, candidate)
|
|
61
|
+
|
|
62
|
+
if (best && distance >= best.distance) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
best = {
|
|
67
|
+
distance,
|
|
68
|
+
tangent: [frame.tangent.x, frame.tangent.y],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return best.tangent
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatAngleRadians(angle: number) {
|
|
80
|
+
return `${Math.round((angle * 180) / Math.PI)}°`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
|
|
84
|
+
const firstLength = Math.hypot(first[0], first[1])
|
|
85
|
+
const secondLength = Math.hypot(second[0], second[1])
|
|
86
|
+
|
|
87
|
+
if (firstLength < 1e-6 || secondLength < 1e-6) return null
|
|
88
|
+
|
|
89
|
+
const dot = first[0] * second[0] + first[1] * second[1]
|
|
90
|
+
const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength)))
|
|
91
|
+
|
|
92
|
+
return Math.acos(cosine)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getAngleToSegmentReference(
|
|
96
|
+
vector: PlanPoint,
|
|
97
|
+
reference: SegmentAngleReference,
|
|
98
|
+
): number | null {
|
|
99
|
+
const angle = getAngleBetweenVectors(vector, reference.vector)
|
|
100
|
+
|
|
101
|
+
if (angle === null || reference.orientation === 'directed') {
|
|
102
|
+
return angle
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]])
|
|
106
|
+
|
|
107
|
+
if (reverseAngle === null) {
|
|
108
|
+
return angle
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Math.min(angle, reverseAngle)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getSegmentAngleReferenceAtPoint(
|
|
115
|
+
point: PlanPoint,
|
|
116
|
+
segment: SegmentAngleLike,
|
|
117
|
+
): SegmentAngleReference | null {
|
|
118
|
+
if (pointsMatch(point, segment.start)) {
|
|
119
|
+
const frame = getWallCurveFrameAt(segment, 0)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
vector: [frame.tangent.x, frame.tangent.y],
|
|
123
|
+
orientation: 'directed',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pointsMatch(point, segment.end)) {
|
|
128
|
+
const frame = getWallCurveFrameAt(segment, 1)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
vector: [-frame.tangent.x, -frame.tangent.y],
|
|
132
|
+
orientation: 'directed',
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isCurvedWall(segment)) {
|
|
137
|
+
const tangent = getCurveTangentAtPoint(point, segment)
|
|
138
|
+
|
|
139
|
+
return tangent
|
|
140
|
+
? {
|
|
141
|
+
vector: tangent,
|
|
142
|
+
orientation: 'axis',
|
|
143
|
+
}
|
|
144
|
+
: null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const projected = getProjectedPointOnSegment(point, segment)
|
|
148
|
+
if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]],
|
|
154
|
+
orientation: 'axis',
|
|
155
|
+
}
|
|
156
|
+
}
|