@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
|
@@ -1,14 +1,100 @@
|
|
|
1
1
|
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
-
import {
|
|
3
|
+
import { Html } from '@react-three/drei'
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
5
|
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
5
6
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
6
7
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
7
8
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
9
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
import {
|
|
11
|
+
formatAngleRadians,
|
|
12
|
+
getAngleToSegmentReference,
|
|
13
|
+
getSegmentAngleReferenceAtPoint,
|
|
14
|
+
} from '../shared/segment-angle'
|
|
9
15
|
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
|
10
16
|
|
|
11
17
|
const WALL_HEIGHT = 2.5
|
|
18
|
+
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
|
|
19
|
+
const DRAFT_ANGLE_LABEL_Y = 0.28
|
|
20
|
+
|
|
21
|
+
type DraftAngleLabel = {
|
|
22
|
+
id: string
|
|
23
|
+
label: string
|
|
24
|
+
position: [number, number, number]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type DraftMeasurementState = {
|
|
28
|
+
lengthLabel: string
|
|
29
|
+
lengthPosition: [number, number, number]
|
|
30
|
+
angleLabels: DraftAngleLabel[]
|
|
31
|
+
} | null
|
|
32
|
+
|
|
33
|
+
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
34
|
+
if (unit === 'imperial') {
|
|
35
|
+
const feet = value * 3.280_84
|
|
36
|
+
const wholeFeet = Math.floor(feet)
|
|
37
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
38
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
39
|
+
return `${wholeFeet}'${inches}"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDraftAngleLabels(
|
|
46
|
+
start: WallPlanPoint,
|
|
47
|
+
end: WallPlanPoint,
|
|
48
|
+
walls: WallNode[],
|
|
49
|
+
): DraftAngleLabel[] {
|
|
50
|
+
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
|
51
|
+
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
|
52
|
+
const endpoints = [
|
|
53
|
+
{ id: 'start', point: start, draftVector: draftFromStart },
|
|
54
|
+
{ id: 'end', point: end, draftVector: draftFromEnd },
|
|
55
|
+
]
|
|
56
|
+
const labels: DraftAngleLabel[] = []
|
|
57
|
+
|
|
58
|
+
for (const endpoint of endpoints) {
|
|
59
|
+
const connectedWall = walls.find((wall) =>
|
|
60
|
+
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
|
61
|
+
)
|
|
62
|
+
if (!connectedWall) continue
|
|
63
|
+
|
|
64
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
|
65
|
+
if (!connectedReference) continue
|
|
66
|
+
|
|
67
|
+
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
|
68
|
+
if (angle === null) continue
|
|
69
|
+
|
|
70
|
+
labels.push({
|
|
71
|
+
id: endpoint.id,
|
|
72
|
+
label: formatAngleRadians(angle),
|
|
73
|
+
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return labels
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getDraftMeasurementState(
|
|
81
|
+
start: WallPlanPoint,
|
|
82
|
+
end: WallPlanPoint,
|
|
83
|
+
walls: WallNode[],
|
|
84
|
+
unit: 'metric' | 'imperial',
|
|
85
|
+
): DraftMeasurementState {
|
|
86
|
+
const dx = end[0] - start[0]
|
|
87
|
+
const dz = end[1] - start[1]
|
|
88
|
+
const length = Math.hypot(dx, dz)
|
|
89
|
+
|
|
90
|
+
if (length < 0.01) return null
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
lengthLabel: formatMeasurement(length, unit),
|
|
94
|
+
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
|
95
|
+
angleLabels: getDraftAngleLabels(start, end, walls),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
12
98
|
|
|
13
99
|
/**
|
|
14
100
|
* Update wall preview mesh geometry to create a vertical plane between two points
|
|
@@ -67,19 +153,21 @@ const getCurrentLevelWalls = (): WallNode[] => {
|
|
|
67
153
|
}
|
|
68
154
|
|
|
69
155
|
export const WallTool: React.FC = () => {
|
|
156
|
+
const unit = useViewer((state) => state.unit)
|
|
70
157
|
const cursorRef = useRef<Group>(null)
|
|
71
158
|
const wallPreviewRef = useRef<Mesh>(null!)
|
|
159
|
+
// All positions are building-local: this tool is inside the ToolManager building group,
|
|
160
|
+
// so local coords are used for both data and visual positioning.
|
|
72
161
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
73
162
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
74
163
|
const buildingState = useRef(0)
|
|
75
164
|
const shiftPressed = useRef(false)
|
|
165
|
+
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
|
76
166
|
|
|
77
167
|
useEffect(() => {
|
|
78
168
|
let gridPosition: WallPlanPoint = [0, 0]
|
|
79
169
|
let previousWallEnd: [number, number] | null = null
|
|
80
170
|
|
|
81
|
-
// All positions are building-local: this tool is inside the ToolManager building group,
|
|
82
|
-
// so local coords are used for both data and visual positioning.
|
|
83
171
|
const onGridMove = (event: GridEvent) => {
|
|
84
172
|
if (!(cursorRef.current && wallPreviewRef.current)) return
|
|
85
173
|
|
|
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
|
|
|
109
197
|
previousWallEnd = currentWallEnd
|
|
110
198
|
|
|
111
199
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
|
200
|
+
setDraftMeasurement(
|
|
201
|
+
getDraftMeasurementState(
|
|
202
|
+
[startingPoint.current.x, startingPoint.current.z],
|
|
203
|
+
snappedLocal,
|
|
204
|
+
walls,
|
|
205
|
+
unit,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
112
208
|
} else {
|
|
113
209
|
// Not drawing a wall yet, show the snapped anchor point.
|
|
114
210
|
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
|
211
|
+
setDraftMeasurement(null)
|
|
115
212
|
}
|
|
116
213
|
}
|
|
117
214
|
|
|
@@ -126,6 +223,7 @@ export const WallTool: React.FC = () => {
|
|
|
126
223
|
endingPoint.current.copy(startingPoint.current)
|
|
127
224
|
buildingState.current = 1
|
|
128
225
|
wallPreviewRef.current.visible = true
|
|
226
|
+
setDraftMeasurement(null)
|
|
129
227
|
} else if (buildingState.current === 1) {
|
|
130
228
|
const snappedEnd = snapWallDraftPoint({
|
|
131
229
|
point: localClick,
|
|
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
|
|
|
140
238
|
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
141
239
|
wallPreviewRef.current.visible = false
|
|
142
240
|
buildingState.current = 0
|
|
241
|
+
setDraftMeasurement(null)
|
|
143
242
|
}
|
|
144
243
|
}
|
|
145
244
|
|
|
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
|
|
|
160
259
|
markToolCancelConsumed()
|
|
161
260
|
buildingState.current = 0
|
|
162
261
|
wallPreviewRef.current.visible = false
|
|
262
|
+
setDraftMeasurement(null)
|
|
163
263
|
}
|
|
164
264
|
}
|
|
165
265
|
|
|
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
|
|
|
176
276
|
window.removeEventListener('keydown', onKeyDown)
|
|
177
277
|
window.removeEventListener('keyup', onKeyUp)
|
|
178
278
|
}
|
|
179
|
-
}, [])
|
|
279
|
+
}, [unit])
|
|
180
280
|
|
|
181
281
|
return (
|
|
182
282
|
<group>
|
|
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
|
|
|
195
295
|
transparent
|
|
196
296
|
/>
|
|
197
297
|
</mesh>
|
|
298
|
+
|
|
299
|
+
{draftMeasurement && (
|
|
300
|
+
<>
|
|
301
|
+
<DraftMeasurementLabel
|
|
302
|
+
label={draftMeasurement.lengthLabel}
|
|
303
|
+
position={draftMeasurement.lengthPosition}
|
|
304
|
+
/>
|
|
305
|
+
{draftMeasurement.angleLabels.map((angleLabel) => (
|
|
306
|
+
<DraftMeasurementLabel
|
|
307
|
+
key={angleLabel.id}
|
|
308
|
+
label={angleLabel.label}
|
|
309
|
+
position={angleLabel.position}
|
|
310
|
+
/>
|
|
311
|
+
))}
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
198
314
|
</group>
|
|
199
315
|
)
|
|
200
316
|
}
|
|
317
|
+
|
|
318
|
+
function DraftMeasurementLabel({
|
|
319
|
+
label,
|
|
320
|
+
position,
|
|
321
|
+
}: {
|
|
322
|
+
label: string
|
|
323
|
+
position: [number, number, number]
|
|
324
|
+
}) {
|
|
325
|
+
return (
|
|
326
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
327
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
328
|
+
{label}
|
|
329
|
+
</div>
|
|
330
|
+
</Html>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isCurvedWall,
|
|
5
5
|
sceneRegistry,
|
|
6
6
|
spatialGridManager,
|
|
7
|
+
useLiveTransforms,
|
|
7
8
|
useScene,
|
|
8
9
|
type WallEvent,
|
|
9
10
|
WindowNode,
|
|
@@ -144,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
144
145
|
parentId: event.node.id,
|
|
145
146
|
wallId: event.node.id,
|
|
146
147
|
})
|
|
148
|
+
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
149
|
+
position: [clampedX, clampedY, 0],
|
|
150
|
+
rotation: itemRotation,
|
|
151
|
+
})
|
|
147
152
|
|
|
148
153
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
149
154
|
markWallDirty(event.node.id)
|
|
@@ -215,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
215
220
|
windowMesh.updateMatrixWorld(true)
|
|
216
221
|
}
|
|
217
222
|
}
|
|
223
|
+
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
224
|
+
position: [clampedX, clampedY, 0],
|
|
225
|
+
rotation: itemRotation,
|
|
226
|
+
})
|
|
218
227
|
markWallDirty(event.node.id)
|
|
219
228
|
|
|
220
229
|
const valid = !hasWallChildOverlap(
|
|
@@ -285,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
285
294
|
parentId: event.node.id,
|
|
286
295
|
width: movingWindowNode.width,
|
|
287
296
|
height: movingWindowNode.height,
|
|
297
|
+
windowType: movingWindowNode.windowType,
|
|
298
|
+
operationState: movingWindowNode.operationState,
|
|
299
|
+
awningDirection: movingWindowNode.awningDirection,
|
|
300
|
+
casementStyle: movingWindowNode.casementStyle,
|
|
301
|
+
hingesSide: movingWindowNode.hingesSide,
|
|
288
302
|
frameThickness: movingWindowNode.frameThickness,
|
|
289
303
|
frameDepth: movingWindowNode.frameDepth,
|
|
290
304
|
columnRatios: movingWindowNode.columnRatios,
|
|
@@ -326,6 +340,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
326
340
|
}
|
|
327
341
|
|
|
328
342
|
markWallDirty(event.node.id)
|
|
343
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
329
344
|
useScene.temporal.getState().pause()
|
|
330
345
|
|
|
331
346
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -337,6 +352,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
337
352
|
|
|
338
353
|
const onWallLeave = () => {
|
|
339
354
|
hideCursor()
|
|
355
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
340
356
|
if (isNew) return // No original to restore for duplicates
|
|
341
357
|
// Move mode: restore to original position while off-wall
|
|
342
358
|
if (currentWallId && currentWallId !== original.parentId) {
|
|
@@ -354,6 +370,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
354
370
|
}
|
|
355
371
|
|
|
356
372
|
const onCancel = () => {
|
|
373
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
357
374
|
if (isNew) {
|
|
358
375
|
useScene.getState().deleteNode(movingWindowNode.id)
|
|
359
376
|
if (currentWallId) markWallDirty(currentWallId)
|
|
@@ -401,6 +418,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
401
418
|
if (original.parentId) markWallDirty(original.parentId)
|
|
402
419
|
}
|
|
403
420
|
}
|
|
421
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
404
422
|
useScene.temporal.getState().resume()
|
|
405
423
|
emitter.off('wall:enter', onWallEnter)
|
|
406
424
|
emitter.off('wall:move', onWallMove)
|
|
@@ -262,6 +262,11 @@ export const WindowTool: React.FC = () => {
|
|
|
262
262
|
parentId: event.node.id,
|
|
263
263
|
width: draft.width,
|
|
264
264
|
height: draft.height,
|
|
265
|
+
windowType: draft.windowType,
|
|
266
|
+
operationState: draft.operationState,
|
|
267
|
+
awningDirection: draft.awningDirection,
|
|
268
|
+
casementStyle: draft.casementStyle,
|
|
269
|
+
hingesSide: draft.hingesSide,
|
|
265
270
|
frameThickness: draft.frameThickness,
|
|
266
271
|
frameDepth: draft.frameDepth,
|
|
267
272
|
columnRatios: draft.columnRatios,
|
|
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
|
5
5
|
import { EDITOR_LAYER } from './../../../lib/constants'
|
|
6
|
+
import { sfxEmitter } from './../../../lib/sfx-bus'
|
|
6
7
|
import useEditor from './../../../store/use-editor'
|
|
7
8
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
8
9
|
|
|
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
|
|
67
68
|
|
|
68
69
|
// Select the newly created zone
|
|
69
70
|
useViewer.getState().setSelection({ zoneId: zone.id })
|
|
71
|
+
|
|
72
|
+
// Play structure build sound
|
|
73
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
type PreviewState = {
|
|
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
|
|
|
86
90
|
const mainLineRef = useRef<Line>(null!)
|
|
87
91
|
const closingLineRef = useRef<Line>(null!)
|
|
88
92
|
const pointsRef = useRef<Array<[number, number]>>([])
|
|
93
|
+
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
|
89
94
|
const levelYRef = useRef(0) // Track current level Y position
|
|
90
95
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
91
96
|
const setTool = useEditor((state) => state.setTool)
|
|
@@ -181,12 +186,22 @@ export const ZoneTool: React.FC = () => {
|
|
|
181
186
|
|
|
182
187
|
// If we have points, snap to axis from last point
|
|
183
188
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
const displayPoint = lastPoint
|
|
190
|
+
? calculateSnapPoint(lastPoint, cursorPosition)
|
|
191
|
+
: cursorPosition
|
|
192
|
+
|
|
193
|
+
// Play snap sound when the snapped position changes during drawing
|
|
194
|
+
if (
|
|
195
|
+
pointsRef.current.length > 0 &&
|
|
196
|
+
previousSnappedPointRef.current &&
|
|
197
|
+
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
|
198
|
+
displayPoint[1] !== previousSnappedPointRef.current[1])
|
|
199
|
+
) {
|
|
200
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
189
201
|
}
|
|
202
|
+
previousSnappedPointRef.current = displayPoint
|
|
203
|
+
|
|
204
|
+
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
|
|
190
205
|
|
|
191
206
|
updatePreview()
|
|
192
207
|
}
|
|
@@ -4,7 +4,7 @@ import { emitter } from '@pascal-app/core'
|
|
|
4
4
|
import Image from 'next/image'
|
|
5
5
|
import { ActionButton } from './action-button'
|
|
6
6
|
|
|
7
|
-
export function CameraActions() {
|
|
7
|
+
export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
|
8
8
|
const goToTopView = () => {
|
|
9
9
|
emitter.emit('camera-controls:top-view')
|
|
10
10
|
}
|
|
@@ -19,39 +19,43 @@ export function CameraActions() {
|
|
|
19
19
|
|
|
20
20
|
return (
|
|
21
21
|
<div className="flex items-center gap-1">
|
|
22
|
-
{
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
{!hideOrbit && (
|
|
23
|
+
<>
|
|
24
|
+
{/* Orbit CCW */}
|
|
25
|
+
<ActionButton
|
|
26
|
+
className="group hover:bg-white/5"
|
|
27
|
+
label="Orbit Left"
|
|
28
|
+
onClick={orbitCCW}
|
|
29
|
+
size="icon"
|
|
30
|
+
variant="ghost"
|
|
31
|
+
>
|
|
32
|
+
<Image
|
|
33
|
+
alt="Orbit Left"
|
|
34
|
+
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
|
35
|
+
height={28}
|
|
36
|
+
src="/icons/rotate.png"
|
|
37
|
+
width={28}
|
|
38
|
+
/>
|
|
39
|
+
</ActionButton>
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
{/* Orbit CW */}
|
|
42
|
+
<ActionButton
|
|
43
|
+
className="group hover:bg-white/5"
|
|
44
|
+
label="Orbit Right"
|
|
45
|
+
onClick={orbitCW}
|
|
46
|
+
size="icon"
|
|
47
|
+
variant="ghost"
|
|
48
|
+
>
|
|
49
|
+
<Image
|
|
50
|
+
alt="Orbit Right"
|
|
51
|
+
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
|
52
|
+
height={28}
|
|
53
|
+
src="/icons/rotate.png"
|
|
54
|
+
width={28}
|
|
55
|
+
/>
|
|
56
|
+
</ActionButton>
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
55
59
|
|
|
56
60
|
{/* Top View */}
|
|
57
61
|
<ActionButton
|
|
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
|
|
|
9
9
|
import useEditor from './../../../store/use-editor'
|
|
10
10
|
import { ActionButton } from './action-button'
|
|
11
11
|
|
|
12
|
-
type ControlId =
|
|
12
|
+
type ControlId =
|
|
13
|
+
| 'select'
|
|
14
|
+
| 'box-select'
|
|
15
|
+
| 'site-edit'
|
|
16
|
+
| 'build'
|
|
17
|
+
| 'material-paint'
|
|
18
|
+
| 'furnish'
|
|
19
|
+
| 'zone'
|
|
20
|
+
| 'delete'
|
|
13
21
|
|
|
14
22
|
type ControlConfig = {
|
|
15
23
|
id: ControlId
|
|
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
|
|
|
54
62
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
|
55
63
|
activeColor: 'bg-green-500/20 text-green-400',
|
|
56
64
|
},
|
|
65
|
+
{
|
|
66
|
+
id: 'material-paint',
|
|
67
|
+
imageSrc: '/icons/paint.png',
|
|
68
|
+
label: 'Material Paint',
|
|
69
|
+
shortcut: 'P',
|
|
70
|
+
color: 'hover:bg-amber-500/20 hover:text-amber-400',
|
|
71
|
+
activeColor: 'bg-amber-500/20 text-amber-400',
|
|
72
|
+
},
|
|
57
73
|
{
|
|
58
74
|
id: 'furnish',
|
|
59
75
|
imageSrc: '/icons/couch.png',
|
|
@@ -88,6 +104,9 @@ export function ControlModes() {
|
|
|
88
104
|
const setPhase = useEditor((state) => state.setPhase)
|
|
89
105
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
|
90
106
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
|
107
|
+
const primeMaterialPaintFromSelection = useEditor(
|
|
108
|
+
(state) => state.primeMaterialPaintFromSelection,
|
|
109
|
+
)
|
|
91
110
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
92
111
|
|
|
93
112
|
// Only subscribe to the primitive `level` number — when walls are added to
|
|
@@ -112,6 +131,7 @@ export function ControlModes() {
|
|
|
112
131
|
if (id === 'site-edit') return false
|
|
113
132
|
if (id === 'build')
|
|
114
133
|
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
|
134
|
+
if (id === 'material-paint') return mode === 'material-paint'
|
|
115
135
|
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
|
116
136
|
if (id === 'zone')
|
|
117
137
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
|
@@ -130,6 +150,8 @@ export function ControlModes() {
|
|
|
130
150
|
// setPhase('site') calls viewer.resetSelection() which clears levelId,
|
|
131
151
|
// breaking the 2D floorplan (it needs a level to render the SVG).
|
|
132
152
|
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
|
|
153
|
+
// Clear object selection so the polygon editor handles receive pointer events
|
|
154
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
133
155
|
}
|
|
134
156
|
return
|
|
135
157
|
}
|
|
@@ -155,12 +177,23 @@ export function ControlModes() {
|
|
|
155
177
|
setStructureLayer('elements')
|
|
156
178
|
setMode('build')
|
|
157
179
|
}
|
|
180
|
+
} else if (id === 'material-paint') {
|
|
181
|
+
if (getIsActive('material-paint')) {
|
|
182
|
+
setMode('select')
|
|
183
|
+
} else {
|
|
184
|
+
primeMaterialPaintFromSelection()
|
|
185
|
+
setPhase('structure')
|
|
186
|
+
setStructureLayer('elements')
|
|
187
|
+
setMode('material-paint')
|
|
188
|
+
}
|
|
158
189
|
} else if (id === 'furnish') {
|
|
159
190
|
if (getIsActive('furnish')) {
|
|
160
191
|
setMode('select')
|
|
161
192
|
} else {
|
|
162
193
|
setPhase('furnish')
|
|
163
194
|
setMode('build')
|
|
195
|
+
// Auto-switch sidebar to the items panel so the user can pick furniture
|
|
196
|
+
useEditor.getState().setActiveSidebarPanel('items')
|
|
164
197
|
}
|
|
165
198
|
} else if (id === 'zone') {
|
|
166
199
|
if (getIsActive('zone')) {
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
'use
|
|
2
|
-
|
|
3
|
-
import NextImage from 'next/image'
|
|
4
|
-
import { cn } from './../../../lib/utils'
|
|
5
|
-
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
|
6
|
-
import { ActionButton } from './action-button'
|
|
1
|
+
import type { CatalogCategory } from './../../../store/use-editor'
|
|
7
2
|
|
|
8
3
|
export type FurnishToolConfig = {
|
|
9
4
|
id: 'item'
|
|
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
|
|
|
12
7
|
catalogCategory: CatalogCategory
|
|
13
8
|
}
|
|
14
9
|
|
|
15
|
-
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
|
16
10
|
export const furnishTools: FurnishToolConfig[] = [
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
id: 'item',
|
|
25
|
-
iconSrc: '/icons/appliance.png',
|
|
26
|
-
label: 'Appliance',
|
|
27
|
-
catalogCategory: 'appliance',
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
id: 'item',
|
|
31
|
-
iconSrc: '/icons/kitchen.png',
|
|
32
|
-
label: 'Kitchen',
|
|
33
|
-
catalogCategory: 'kitchen',
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: 'item',
|
|
37
|
-
iconSrc: '/icons/bathroom.png',
|
|
38
|
-
label: 'Bathroom',
|
|
39
|
-
catalogCategory: 'bathroom',
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
id: 'item',
|
|
43
|
-
iconSrc: '/icons/tree.png',
|
|
44
|
-
label: 'Outdoor',
|
|
45
|
-
catalogCategory: 'outdoor',
|
|
46
|
-
},
|
|
11
|
+
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
|
|
12
|
+
{ id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
|
|
13
|
+
{ id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
|
|
14
|
+
{ id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
|
|
15
|
+
{ id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
|
|
47
16
|
]
|
|
48
|
-
|
|
49
|
-
export function FurnishTools() {
|
|
50
|
-
const mode = useEditor((state) => state.mode)
|
|
51
|
-
const activeTool = useEditor((state) => state.tool)
|
|
52
|
-
const setActiveTool = useEditor((state) => state.setTool)
|
|
53
|
-
const setMode = useEditor((state) => state.setMode)
|
|
54
|
-
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
55
|
-
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
|
56
|
-
|
|
57
|
-
const hasActiveTool = furnishTools.some(
|
|
58
|
-
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<div className="flex items-center gap-1.5 px-1">
|
|
63
|
-
{furnishTools.map((tool, index) => {
|
|
64
|
-
// For item tools with catalog category, check both tool and category match
|
|
65
|
-
const isActive =
|
|
66
|
-
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
<ActionButton
|
|
70
|
-
className={cn(
|
|
71
|
-
'rounded-lg duration-300',
|
|
72
|
-
isActive
|
|
73
|
-
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
|
74
|
-
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
|
75
|
-
)}
|
|
76
|
-
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
|
77
|
-
label={tool.label}
|
|
78
|
-
onClick={() => {
|
|
79
|
-
if (!isActive) {
|
|
80
|
-
setCatalogCategory(tool.catalogCategory)
|
|
81
|
-
setActiveTool('item')
|
|
82
|
-
if (mode !== 'build') {
|
|
83
|
-
setMode('build')
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}}
|
|
87
|
-
size="icon"
|
|
88
|
-
variant="ghost"
|
|
89
|
-
>
|
|
90
|
-
<NextImage
|
|
91
|
-
alt={tool.label}
|
|
92
|
-
className="size-full object-contain"
|
|
93
|
-
height={28}
|
|
94
|
-
src={tool.iconSrc}
|
|
95
|
-
width={28}
|
|
96
|
-
/>
|
|
97
|
-
</ActionButton>
|
|
98
|
-
)
|
|
99
|
-
})}
|
|
100
|
-
</div>
|
|
101
|
-
)
|
|
102
|
-
}
|