@pascal-app/editor 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
isCurvedWall,
|
|
4
5
|
sceneRegistry,
|
|
5
6
|
spatialGridManager,
|
|
7
|
+
useLiveTransforms,
|
|
6
8
|
useScene,
|
|
7
9
|
type WallEvent,
|
|
8
10
|
WindowNode,
|
|
@@ -112,6 +114,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
112
114
|
|
|
113
115
|
const onWallEnter = (event: WallEvent) => {
|
|
114
116
|
if (!isValidWallSideFace(event.normal)) return
|
|
117
|
+
if (isCurvedWall(event.node)) {
|
|
118
|
+
hideCursor()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
115
121
|
// Only interact with walls on the current level
|
|
116
122
|
if (event.node.parentId !== getLevelId()) return
|
|
117
123
|
|
|
@@ -139,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
139
145
|
parentId: event.node.id,
|
|
140
146
|
wallId: event.node.id,
|
|
141
147
|
})
|
|
148
|
+
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
149
|
+
position: [clampedX, clampedY, 0],
|
|
150
|
+
rotation: itemRotation,
|
|
151
|
+
})
|
|
142
152
|
|
|
143
153
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
144
154
|
markWallDirty(event.node.id)
|
|
@@ -168,6 +178,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
168
178
|
|
|
169
179
|
const onWallMove = (event: WallEvent) => {
|
|
170
180
|
if (!isValidWallSideFace(event.normal)) return
|
|
181
|
+
if (isCurvedWall(event.node)) {
|
|
182
|
+
hideCursor()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
171
185
|
// Only interact with walls on the current level
|
|
172
186
|
if (event.node.parentId !== getLevelId()) return
|
|
173
187
|
|
|
@@ -206,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
206
220
|
windowMesh.updateMatrixWorld(true)
|
|
207
221
|
}
|
|
208
222
|
}
|
|
223
|
+
useLiveTransforms.getState().set(movingWindowNode.id, {
|
|
224
|
+
position: [clampedX, clampedY, 0],
|
|
225
|
+
rotation: itemRotation,
|
|
226
|
+
})
|
|
209
227
|
markWallDirty(event.node.id)
|
|
210
228
|
|
|
211
229
|
const valid = !hasWallChildOverlap(
|
|
@@ -233,6 +251,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
233
251
|
|
|
234
252
|
const onWallClick = (event: WallEvent) => {
|
|
235
253
|
if (!isValidWallSideFace(event.normal)) return
|
|
254
|
+
if (isCurvedWall(event.node)) return
|
|
236
255
|
// Only interact with walls on the current level
|
|
237
256
|
if (event.node.parentId !== getLevelId()) return
|
|
238
257
|
|
|
@@ -275,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
275
294
|
parentId: event.node.id,
|
|
276
295
|
width: movingWindowNode.width,
|
|
277
296
|
height: movingWindowNode.height,
|
|
297
|
+
windowType: movingWindowNode.windowType,
|
|
298
|
+
operationState: movingWindowNode.operationState,
|
|
299
|
+
awningDirection: movingWindowNode.awningDirection,
|
|
300
|
+
casementStyle: movingWindowNode.casementStyle,
|
|
301
|
+
hingesSide: movingWindowNode.hingesSide,
|
|
278
302
|
frameThickness: movingWindowNode.frameThickness,
|
|
279
303
|
frameDepth: movingWindowNode.frameDepth,
|
|
280
304
|
columnRatios: movingWindowNode.columnRatios,
|
|
@@ -316,6 +340,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
316
340
|
}
|
|
317
341
|
|
|
318
342
|
markWallDirty(event.node.id)
|
|
343
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
319
344
|
useScene.temporal.getState().pause()
|
|
320
345
|
|
|
321
346
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -327,6 +352,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
327
352
|
|
|
328
353
|
const onWallLeave = () => {
|
|
329
354
|
hideCursor()
|
|
355
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
330
356
|
if (isNew) return // No original to restore for duplicates
|
|
331
357
|
// Move mode: restore to original position while off-wall
|
|
332
358
|
if (currentWallId && currentWallId !== original.parentId) {
|
|
@@ -344,6 +370,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
344
370
|
}
|
|
345
371
|
|
|
346
372
|
const onCancel = () => {
|
|
373
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
347
374
|
if (isNew) {
|
|
348
375
|
useScene.getState().deleteNode(movingWindowNode.id)
|
|
349
376
|
if (currentWallId) markWallDirty(currentWallId)
|
|
@@ -391,6 +418,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
391
418
|
if (original.parentId) markWallDirty(original.parentId)
|
|
392
419
|
}
|
|
393
420
|
}
|
|
421
|
+
useLiveTransforms.getState().clear(movingWindowNode.id)
|
|
394
422
|
useScene.temporal.getState().resume()
|
|
395
423
|
emitter.off('wall:enter', onWallEnter)
|
|
396
424
|
emitter.off('wall:move', onWallMove)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
isCurvedWall,
|
|
4
5
|
sceneRegistry,
|
|
5
6
|
spatialGridManager,
|
|
6
7
|
useScene,
|
|
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
|
|
|
86
87
|
|
|
87
88
|
const onWallEnter = (event: WallEvent) => {
|
|
88
89
|
if (!isValidWallSideFace(event.normal)) return
|
|
90
|
+
if (isCurvedWall(event.node)) {
|
|
91
|
+
destroyDraft()
|
|
92
|
+
hideCursor()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
89
95
|
const levelId = getLevelId()
|
|
90
96
|
if (!levelId) return
|
|
91
97
|
// Only interact with walls on the current level
|
|
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
|
|
|
135
141
|
|
|
136
142
|
const onWallMove = (event: WallEvent) => {
|
|
137
143
|
if (!isValidWallSideFace(event.normal)) return
|
|
144
|
+
if (isCurvedWall(event.node)) {
|
|
145
|
+
destroyDraft()
|
|
146
|
+
hideCursor()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
138
149
|
// Only interact with walls on the current level
|
|
139
150
|
if (event.node.parentId !== getLevelId()) return
|
|
140
151
|
|
|
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
|
|
|
198
209
|
const onWallClick = (event: WallEvent) => {
|
|
199
210
|
if (!draftRef.current) return
|
|
200
211
|
if (!isValidWallSideFace(event.normal)) return
|
|
212
|
+
if (isCurvedWall(event.node)) return
|
|
201
213
|
// Only interact with walls on the current level
|
|
202
214
|
if (event.node.parentId !== getLevelId()) return
|
|
203
215
|
|
|
@@ -250,6 +262,11 @@ export const WindowTool: React.FC = () => {
|
|
|
250
262
|
parentId: event.node.id,
|
|
251
263
|
width: draft.width,
|
|
252
264
|
height: draft.height,
|
|
265
|
+
windowType: draft.windowType,
|
|
266
|
+
operationState: draft.operationState,
|
|
267
|
+
awningDirection: draft.awningDirection,
|
|
268
|
+
casementStyle: draft.casementStyle,
|
|
269
|
+
hingesSide: draft.hingesSide,
|
|
253
270
|
frameThickness: draft.frameThickness,
|
|
254
271
|
frameDepth: draft.frameDepth,
|
|
255
272
|
columnRatios: draft.columnRatios,
|
|
@@ -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,14 +104,20 @@ 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((state) => state.primeMaterialPaintFromSelection)
|
|
91
108
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
// Only subscribe to the primitive `level` number — when walls are added to
|
|
111
|
+
// this level the object ref changes but this number doesn't, so Object.is
|
|
112
|
+
// dedupes and we avoid a re-render.
|
|
113
|
+
const levelIndex = useScene((state) => {
|
|
114
|
+
if (!levelId) return null
|
|
115
|
+
const node = state.nodes[levelId]
|
|
116
|
+
return node?.type === 'level' ? (node as LevelNode).level : null
|
|
117
|
+
})
|
|
96
118
|
|
|
97
119
|
const isSiteEditing = phase === 'site'
|
|
98
|
-
const isGroundFloor =
|
|
120
|
+
const isGroundFloor = levelIndex === 0
|
|
99
121
|
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
|
100
122
|
|
|
101
123
|
const structureLayer = useEditor((state) => state.structureLayer)
|
|
@@ -107,6 +129,7 @@ export function ControlModes() {
|
|
|
107
129
|
if (id === 'site-edit') return false
|
|
108
130
|
if (id === 'build')
|
|
109
131
|
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
|
132
|
+
if (id === 'material-paint') return mode === 'material-paint'
|
|
110
133
|
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
|
111
134
|
if (id === 'zone')
|
|
112
135
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
|
@@ -150,6 +173,15 @@ export function ControlModes() {
|
|
|
150
173
|
setStructureLayer('elements')
|
|
151
174
|
setMode('build')
|
|
152
175
|
}
|
|
176
|
+
} else if (id === 'material-paint') {
|
|
177
|
+
if (getIsActive('material-paint')) {
|
|
178
|
+
setMode('select')
|
|
179
|
+
} else {
|
|
180
|
+
primeMaterialPaintFromSelection()
|
|
181
|
+
setPhase('structure')
|
|
182
|
+
setStructureLayer('elements')
|
|
183
|
+
setMode('material-paint')
|
|
184
|
+
}
|
|
153
185
|
} else if (id === 'furnish') {
|
|
154
186
|
if (getIsActive('furnish')) {
|
|
155
187
|
setMode('select')
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
3
4
|
import { AnimatePresence, motion } from 'motion/react'
|
|
4
|
-
import {
|
|
5
|
+
import { useEffect, useMemo } from 'react'
|
|
6
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
7
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
|
8
|
+
import { useIsMobile } from './../../../hooks/use-mobile'
|
|
9
|
+
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
|
10
|
+
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
|
11
|
+
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
|
6
12
|
import { cn } from './../../../lib/utils'
|
|
7
13
|
import useEditor from './../../../store/use-editor'
|
|
8
14
|
import { ItemCatalog } from '../item-catalog/item-catalog'
|
|
@@ -12,12 +18,64 @@ import { FurnishTools } from './furnish-tools'
|
|
|
12
18
|
import { StructureTools } from './structure-tools'
|
|
13
19
|
import { ViewToggles } from './view-toggles'
|
|
14
20
|
|
|
21
|
+
function PaintMaterialTray() {
|
|
22
|
+
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
23
|
+
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
24
|
+
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
|
25
|
+
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
26
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
27
|
+
const nodes = useScene((state) => state.nodes)
|
|
28
|
+
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const selectedPaintTarget = resolvePaintTargetFromSelection({
|
|
32
|
+
nodes,
|
|
33
|
+
selectedId,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if (selectedPaintTarget) {
|
|
37
|
+
setActivePaintTarget(selectedPaintTarget)
|
|
38
|
+
}
|
|
39
|
+
}, [nodes, selectedId, setActivePaintTarget])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
|
|
43
|
+
<MaterialPicker
|
|
44
|
+
onChange={(material) => {
|
|
45
|
+
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
|
46
|
+
}}
|
|
47
|
+
onSelectMaterialPreset={(materialPreset) => {
|
|
48
|
+
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
|
49
|
+
}}
|
|
50
|
+
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
|
51
|
+
value={activePaintMaterial?.material}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
export function ActionMenu({ className }: { className?: string }) {
|
|
16
58
|
const phase = useEditor((state) => state.phase)
|
|
17
59
|
const mode = useEditor((state) => state.mode)
|
|
18
60
|
const tool = useEditor((state) => state.tool)
|
|
19
61
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
62
|
+
const isMobile = useIsMobile()
|
|
63
|
+
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
|
64
|
+
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
|
65
|
+
const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
|
|
66
|
+
const isContextualPanelOnMobile = useEditor(
|
|
67
|
+
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
|
68
|
+
)
|
|
20
69
|
const reducedMotion = useReducedMotion()
|
|
70
|
+
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
|
71
|
+
|
|
72
|
+
// On mobile, defer the bottom rail to the selection bar when something
|
|
73
|
+
// is selected — the contextual actions take priority over mode controls.
|
|
74
|
+
// Also hide on Chat / Items / Studio tabs; those are contextual workflows
|
|
75
|
+
// (composing / picking furniture / generating renders) where the build
|
|
76
|
+
// menu is irrelevant.
|
|
77
|
+
if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
|
|
78
|
+
|
|
21
79
|
const transition = reducedMotion
|
|
22
80
|
? { duration: 0 }
|
|
23
81
|
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
|
@@ -138,6 +196,38 @@ export function ActionMenu({ className }: { className?: string }) {
|
|
|
138
196
|
</motion.div>
|
|
139
197
|
)}
|
|
140
198
|
</AnimatePresence>
|
|
199
|
+
|
|
200
|
+
<AnimatePresence>
|
|
201
|
+
{showPaintTray && (
|
|
202
|
+
<motion.div
|
|
203
|
+
animate={{
|
|
204
|
+
opacity: 1,
|
|
205
|
+
maxHeight: 96,
|
|
206
|
+
paddingTop: 8,
|
|
207
|
+
paddingBottom: 8,
|
|
208
|
+
borderBottomWidth: 1,
|
|
209
|
+
}}
|
|
210
|
+
className={cn('overflow-hidden border-border border-b px-3')}
|
|
211
|
+
exit={{
|
|
212
|
+
opacity: 0,
|
|
213
|
+
maxHeight: 0,
|
|
214
|
+
paddingTop: 0,
|
|
215
|
+
paddingBottom: 0,
|
|
216
|
+
borderBottomWidth: 0,
|
|
217
|
+
}}
|
|
218
|
+
initial={{
|
|
219
|
+
opacity: 0,
|
|
220
|
+
maxHeight: 0,
|
|
221
|
+
paddingTop: 0,
|
|
222
|
+
paddingBottom: 0,
|
|
223
|
+
borderBottomWidth: 0,
|
|
224
|
+
}}
|
|
225
|
+
transition={transition}
|
|
226
|
+
>
|
|
227
|
+
<PaintMaterialTray />
|
|
228
|
+
</motion.div>
|
|
229
|
+
)}
|
|
230
|
+
</AnimatePresence>
|
|
141
231
|
{/* Control Mode Row - Always visible, centered */}
|
|
142
232
|
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
|
143
233
|
<ControlModes />
|
|
@@ -24,12 +24,14 @@ export const tools: ToolConfig[] = [
|
|
|
24
24
|
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
|
25
25
|
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
|
26
26
|
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
|
27
|
+
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
|
27
28
|
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
|
28
29
|
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
|
29
30
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
|
30
31
|
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
|
31
32
|
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
|
32
33
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
|
34
|
+
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
|
33
35
|
]
|
|
34
36
|
|
|
35
37
|
export function StructureTools() {
|