@pascal-app/editor 0.8.0 → 0.9.1
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 +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React
|
|
2
|
+
// deps, so it's unit-testable in isolation. Coordinates are XZ plan points
|
|
3
|
+
// (building-local meters). `wall-drafting.ts` layers grid/angle snapping and
|
|
4
|
+
// scene access on top of these primitives.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getWallCurveFrameAt,
|
|
8
|
+
getWallCurveLength,
|
|
9
|
+
isCurvedWall,
|
|
10
|
+
type WallNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
|
|
13
|
+
export type WallPlanPoint = [number, number]
|
|
14
|
+
|
|
15
|
+
/** Which kind of existing-geometry snap produced a drafted point. */
|
|
16
|
+
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
|
17
|
+
|
|
18
|
+
export type WallDraftSnapResult = {
|
|
19
|
+
point: WallPlanPoint
|
|
20
|
+
/**
|
|
21
|
+
* Set when `point` locked onto existing wall geometry (a corner, midpoint,
|
|
22
|
+
* crossing, or wall body) rather than a plain grid/angle position. This is
|
|
23
|
+
* the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
|
|
24
|
+
*/
|
|
25
|
+
snap: WallDraftSnapKind | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
|
29
|
+
// Generous radius for snapping to an *existing* wall's endpoint while
|
|
30
|
+
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
|
31
|
+
// is the strongest user intent (closing a polygon, attaching to a
|
|
32
|
+
// corner) and the cursor never lands pixel-perfect on a corner.
|
|
33
|
+
export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
|
|
34
|
+
// Discrete "special point" snaps taken from the raw cursor (like the
|
|
35
|
+
// endpoint snap) but slightly tighter — a corner is the strongest intent,
|
|
36
|
+
// a midpoint / crossing is the next tier down.
|
|
37
|
+
export const WALL_MIDPOINT_SNAP_RADIUS = 0.5
|
|
38
|
+
export const WALL_INTERSECTION_SNAP_RADIUS = 0.5
|
|
39
|
+
|
|
40
|
+
export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
41
|
+
const dx = a[0] - b[0]
|
|
42
|
+
const dz = a[1] - b[1]
|
|
43
|
+
return dx * dx + dz * dz
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
|
47
|
+
const [x1, z1] = wall.start
|
|
48
|
+
const [x2, z2] = wall.end
|
|
49
|
+
const dx = x2 - x1
|
|
50
|
+
const dz = z2 - z1
|
|
51
|
+
const lengthSquared = dx * dx + dz * dz
|
|
52
|
+
if (lengthSquared < 1e-9) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
57
|
+
if (t <= 0 || t >= 1) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [x1 + dx * t, z1 + dz * t]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findWallSnapTarget(
|
|
65
|
+
point: WallPlanPoint,
|
|
66
|
+
walls: WallNode[],
|
|
67
|
+
options?: { ignoreWallIds?: string[]; radius?: number },
|
|
68
|
+
): WallPlanPoint | null {
|
|
69
|
+
const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
|
|
70
|
+
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
|
71
|
+
let bestTarget: WallPlanPoint | null = null
|
|
72
|
+
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
73
|
+
|
|
74
|
+
for (const wall of walls) {
|
|
75
|
+
if (ignoreWallIds.has(wall.id)) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
|
80
|
+
|
|
81
|
+
if (isCurvedWall(wall)) {
|
|
82
|
+
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
|
83
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
84
|
+
const frame = getWallCurveFrameAt(wall, index / sampleCount)
|
|
85
|
+
candidates.push([frame.point.x, frame.point.y])
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
candidates.push(projectPointOntoWall(point, wall))
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (!candidate) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
96
|
+
if (
|
|
97
|
+
candidateDistanceSquared > radiusSquared ||
|
|
98
|
+
candidateDistanceSquared >= bestDistanceSquared
|
|
99
|
+
) {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
bestTarget = candidate
|
|
104
|
+
bestDistanceSquared = candidateDistanceSquared
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return bestTarget
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
|
|
113
|
+
* generous radius. Use this before `findWallSnapTarget` so the strong
|
|
114
|
+
* "attach to an existing wall corner" intent isn't accidentally pushed
|
|
115
|
+
* out of range by an interim grid snap that moved the cursor away from
|
|
116
|
+
* the endpoint.
|
|
117
|
+
*/
|
|
118
|
+
export function findWallEndpointFromRaw(
|
|
119
|
+
point: WallPlanPoint,
|
|
120
|
+
walls: WallNode[],
|
|
121
|
+
ignoreWallIds?: string[],
|
|
122
|
+
): WallPlanPoint | null {
|
|
123
|
+
const ignored = new Set(ignoreWallIds ?? [])
|
|
124
|
+
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
|
125
|
+
let best: WallPlanPoint | null = null
|
|
126
|
+
let bestDistSq = Number.POSITIVE_INFINITY
|
|
127
|
+
|
|
128
|
+
for (const wall of walls) {
|
|
129
|
+
if (ignored.has(wall.id)) continue
|
|
130
|
+
for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
|
|
131
|
+
const d = distanceSquared(point, corner)
|
|
132
|
+
if (d <= radiusSquared && d < bestDistSq) {
|
|
133
|
+
best = corner
|
|
134
|
+
bestDistSq = d
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */
|
|
142
|
+
function wallMidpoint(wall: WallNode): WallPlanPoint {
|
|
143
|
+
if (isCurvedWall(wall)) {
|
|
144
|
+
const frame = getWallCurveFrameAt(wall, 0.5)
|
|
145
|
+
return [frame.point.x, frame.point.y]
|
|
146
|
+
}
|
|
147
|
+
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */
|
|
151
|
+
export function findWallMidpointFromRaw(
|
|
152
|
+
point: WallPlanPoint,
|
|
153
|
+
walls: WallNode[],
|
|
154
|
+
ignoreWallIds?: string[],
|
|
155
|
+
): WallPlanPoint | null {
|
|
156
|
+
const ignored = new Set(ignoreWallIds ?? [])
|
|
157
|
+
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
|
|
158
|
+
let best: WallPlanPoint | null = null
|
|
159
|
+
let bestDistSq = Number.POSITIVE_INFINITY
|
|
160
|
+
|
|
161
|
+
for (const wall of walls) {
|
|
162
|
+
if (ignored.has(wall.id)) continue
|
|
163
|
+
const mid = wallMidpoint(wall)
|
|
164
|
+
const d = distanceSquared(point, mid)
|
|
165
|
+
if (d <= radiusSquared && d < bestDistSq) {
|
|
166
|
+
best = mid
|
|
167
|
+
bestDistSq = d
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Crossing point of two straight segments, or null if they don't intersect within both. */
|
|
174
|
+
function segmentIntersection(
|
|
175
|
+
a1: WallPlanPoint,
|
|
176
|
+
a2: WallPlanPoint,
|
|
177
|
+
b1: WallPlanPoint,
|
|
178
|
+
b2: WallPlanPoint,
|
|
179
|
+
): WallPlanPoint | null {
|
|
180
|
+
const rx = a2[0] - a1[0]
|
|
181
|
+
const rz = a2[1] - a1[1]
|
|
182
|
+
const sx = b2[0] - b1[0]
|
|
183
|
+
const sz = b2[1] - b1[1]
|
|
184
|
+
const denom = rx * sz - rz * sx
|
|
185
|
+
if (Math.abs(denom) < 1e-9) return null // parallel / collinear
|
|
186
|
+
|
|
187
|
+
const qpx = b1[0] - a1[0]
|
|
188
|
+
const qpz = b1[1] - a1[1]
|
|
189
|
+
const t = (qpx * sz - qpz * sx) / denom
|
|
190
|
+
const u = (qpx * rz - qpz * rx) / denom
|
|
191
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null
|
|
192
|
+
|
|
193
|
+
return [a1[0] + t * rx, a1[1] + t * rz]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Nearest point where two existing straight walls cross, within
|
|
198
|
+
* `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the
|
|
199
|
+
* level's walls — fine at editor scale.
|
|
200
|
+
*/
|
|
201
|
+
export function findWallIntersectionFromRaw(
|
|
202
|
+
point: WallPlanPoint,
|
|
203
|
+
walls: WallNode[],
|
|
204
|
+
ignoreWallIds?: string[],
|
|
205
|
+
): WallPlanPoint | null {
|
|
206
|
+
const ignored = new Set(ignoreWallIds ?? [])
|
|
207
|
+
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
|
|
208
|
+
const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
|
|
209
|
+
let best: WallPlanPoint | null = null
|
|
210
|
+
let bestDistSq = Number.POSITIVE_INFINITY
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < straight.length; i += 1) {
|
|
213
|
+
for (let j = i + 1; j < straight.length; j += 1) {
|
|
214
|
+
const crossing = segmentIntersection(
|
|
215
|
+
straight[i]!.start,
|
|
216
|
+
straight[i]!.end,
|
|
217
|
+
straight[j]!.start,
|
|
218
|
+
straight[j]!.end,
|
|
219
|
+
)
|
|
220
|
+
if (!crossing) continue
|
|
221
|
+
const d = distanceSquared(point, crossing)
|
|
222
|
+
if (d <= radiusSquared && d < bestDistSq) {
|
|
223
|
+
best = crossing
|
|
224
|
+
bestDistSq = d
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return best
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pick the candidate nearest to `point`, ignoring nulls. */
|
|
232
|
+
function nearestCandidate(
|
|
233
|
+
point: WallPlanPoint,
|
|
234
|
+
candidates: Array<WallDraftSnapResult | null | false>,
|
|
235
|
+
): WallDraftSnapResult | null {
|
|
236
|
+
let best: WallDraftSnapResult | null = null
|
|
237
|
+
let bestDistSq = Number.POSITIVE_INFINITY
|
|
238
|
+
for (const candidate of candidates) {
|
|
239
|
+
if (!candidate) continue
|
|
240
|
+
const d = distanceSquared(point, candidate.point)
|
|
241
|
+
if (d < bestDistSq) {
|
|
242
|
+
best = candidate
|
|
243
|
+
bestDistSq = d
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return best
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Discrete "special point" snap from the raw cursor, in priority order:
|
|
251
|
+
* 1. corners (endpoints) — strongest intent, largest radius
|
|
252
|
+
* 2. midpoints / crossings — next tier; the nearer of the two wins
|
|
253
|
+
* A corner within range always wins over a midpoint/crossing. Returns null
|
|
254
|
+
* when no special point is in range (caller falls back to grid/edge snap).
|
|
255
|
+
*/
|
|
256
|
+
export function findWallSpecialPointSnap(
|
|
257
|
+
point: WallPlanPoint,
|
|
258
|
+
walls: WallNode[],
|
|
259
|
+
ignoreWallIds?: string[],
|
|
260
|
+
): WallDraftSnapResult | null {
|
|
261
|
+
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
|
262
|
+
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
|
263
|
+
|
|
264
|
+
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
|
|
265
|
+
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
|
|
266
|
+
return nearestCandidate(point, [
|
|
267
|
+
midpoint && { point: midpoint, snap: 'midpoint' },
|
|
268
|
+
intersection && { point: intersection, snap: 'intersection' },
|
|
269
|
+
])
|
|
270
|
+
}
|
|
@@ -4,6 +4,7 @@ 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
6
|
import { sfxEmitter } from './../../../lib/sfx-bus'
|
|
7
|
+
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
|
7
8
|
import useEditor from './../../../store/use-editor'
|
|
8
9
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
9
10
|
|
|
@@ -178,9 +179,13 @@ export const ZoneTool: React.FC = () => {
|
|
|
178
179
|
const onGridMove = (event: GridEvent) => {
|
|
179
180
|
if (!cursorRef.current) return
|
|
180
181
|
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
const gridZ =
|
|
182
|
+
// World-grid snap projected into building-local; rotated buildings
|
|
183
|
+
// used to pull the snap off the visible grid lines.
|
|
184
|
+
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
|
185
|
+
event.position[0],
|
|
186
|
+
event.position[2],
|
|
187
|
+
0.5,
|
|
188
|
+
).local
|
|
184
189
|
cursorPosition = [gridX, gridZ]
|
|
185
190
|
levelYRef.current = event.localPosition[1]
|
|
186
191
|
|
|
@@ -209,8 +214,11 @@ export const ZoneTool: React.FC = () => {
|
|
|
209
214
|
const onGridClick = (event: GridEvent) => {
|
|
210
215
|
if (!currentLevelId) return
|
|
211
216
|
|
|
212
|
-
const gridX =
|
|
213
|
-
|
|
217
|
+
const [gridX, gridZ] = snapWorldXZForActiveBuilding(
|
|
218
|
+
event.position[0],
|
|
219
|
+
event.position[2],
|
|
220
|
+
0.5,
|
|
221
|
+
).local
|
|
214
222
|
let clickPoint: [number, number] = [gridX, gridZ]
|
|
215
223
|
|
|
216
224
|
// Snap to axis from last point
|
|
@@ -236,7 +244,9 @@ export const ZoneTool: React.FC = () => {
|
|
|
236
244
|
mainLineRef.current.visible = false
|
|
237
245
|
closingLineRef.current.visible = false
|
|
238
246
|
} else {
|
|
239
|
-
// Add point to polygon
|
|
247
|
+
// Add point to polygon. Every non-closing vertex is a "start" tick;
|
|
248
|
+
// closing the polygon above fires the structure-build (end) cue.
|
|
249
|
+
sfxEmitter.emit('sfx:structure-build-start')
|
|
240
250
|
pointsRef.current = [...pointsRef.current, clickPoint]
|
|
241
251
|
updatePreview()
|
|
242
252
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
TooltipContent,
|
|
6
6
|
TooltipTrigger,
|
|
7
7
|
} from './../../../components/ui/primitives/tooltip'
|
|
8
|
+
import { triggerSFX } from './../../../lib/sfx-bus'
|
|
8
9
|
import { cn } from './../../../lib/utils'
|
|
9
10
|
|
|
10
11
|
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
|
@@ -17,7 +18,18 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
|
|
17
18
|
|
|
18
19
|
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
|
19
20
|
(
|
|
20
|
-
{
|
|
21
|
+
{
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
label,
|
|
25
|
+
shortcut,
|
|
26
|
+
isActive,
|
|
27
|
+
tooltipContent,
|
|
28
|
+
tooltipSide,
|
|
29
|
+
onClick,
|
|
30
|
+
onMouseEnter,
|
|
31
|
+
...props
|
|
32
|
+
},
|
|
21
33
|
ref,
|
|
22
34
|
) => {
|
|
23
35
|
return (
|
|
@@ -25,6 +37,14 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
|
|
25
37
|
<TooltipTrigger asChild>
|
|
26
38
|
<Button
|
|
27
39
|
className={cn('relative h-11 w-11 transition-all', className)}
|
|
40
|
+
onClick={(event) => {
|
|
41
|
+
triggerSFX('sfx:menu-click')
|
|
42
|
+
onClick?.(event)
|
|
43
|
+
}}
|
|
44
|
+
onMouseEnter={(event) => {
|
|
45
|
+
triggerSFX('sfx:menu-hover')
|
|
46
|
+
onMouseEnter?.(event)
|
|
47
|
+
}}
|
|
28
48
|
ref={ref}
|
|
29
49
|
{...props}
|
|
30
50
|
>
|
|
@@ -6,18 +6,10 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
6
6
|
import { type LucideIcon, Trash2 } from 'lucide-react'
|
|
7
7
|
import Image from 'next/image'
|
|
8
8
|
import { cn } from './../../../lib/utils'
|
|
9
|
-
import useEditor from './../../../store/use-editor'
|
|
9
|
+
import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
|
|
10
10
|
import { ActionButton } from './action-button'
|
|
11
11
|
|
|
12
|
-
type ControlId =
|
|
13
|
-
| 'select'
|
|
14
|
-
| 'box-select'
|
|
15
|
-
| 'site-edit'
|
|
16
|
-
| 'build'
|
|
17
|
-
| 'material-paint'
|
|
18
|
-
| 'furnish'
|
|
19
|
-
| 'zone'
|
|
20
|
-
| 'delete'
|
|
12
|
+
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
|
21
13
|
|
|
22
14
|
type ControlConfig = {
|
|
23
15
|
id: ControlId
|
|
@@ -40,44 +32,13 @@ const controls: ControlConfig[] = [
|
|
|
40
32
|
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
|
41
33
|
activeColor: 'bg-blue-500/20 text-blue-400',
|
|
42
34
|
},
|
|
43
|
-
{
|
|
44
|
-
id: 'box-select',
|
|
45
|
-
iconifyIcon: 'mdi:select-drag',
|
|
46
|
-
label: 'Box select',
|
|
47
|
-
color: 'hover:bg-white/5',
|
|
48
|
-
activeColor: 'bg-white/10 hover:bg-white/10',
|
|
49
|
-
},
|
|
50
35
|
{
|
|
51
36
|
id: 'site-edit',
|
|
52
|
-
imageSrc: '/icons/site.png',
|
|
37
|
+
imageSrc: '/icons/site-flag.png',
|
|
53
38
|
label: 'Edit site',
|
|
54
39
|
color: 'hover:bg-white/5',
|
|
55
40
|
activeColor: 'bg-white/10 hover:bg-white/10',
|
|
56
41
|
},
|
|
57
|
-
{
|
|
58
|
-
id: 'build',
|
|
59
|
-
imageSrc: '/icons/build.png',
|
|
60
|
-
label: 'Build',
|
|
61
|
-
shortcut: 'B',
|
|
62
|
-
color: 'hover:bg-green-500/20 hover:text-green-400',
|
|
63
|
-
activeColor: 'bg-green-500/20 text-green-400',
|
|
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
|
-
},
|
|
73
|
-
{
|
|
74
|
-
id: 'furnish',
|
|
75
|
-
imageSrc: '/icons/couch.png',
|
|
76
|
-
label: 'Furnish',
|
|
77
|
-
shortcut: 'F',
|
|
78
|
-
color: 'hover:bg-green-500/20 hover:text-green-400',
|
|
79
|
-
activeColor: 'bg-green-500/20 text-green-400',
|
|
80
|
-
},
|
|
81
42
|
{
|
|
82
43
|
id: 'zone',
|
|
83
44
|
imageSrc: '/icons/zone.png',
|
|
@@ -90,7 +51,7 @@ const controls: ControlConfig[] = [
|
|
|
90
51
|
id: 'delete',
|
|
91
52
|
icon: Trash2,
|
|
92
53
|
label: 'Delete',
|
|
93
|
-
shortcut: '
|
|
54
|
+
shortcut: 'X',
|
|
94
55
|
color: 'hover:bg-red-500/20 hover:text-red-400',
|
|
95
56
|
activeColor: 'bg-red-500/20 text-red-400',
|
|
96
57
|
},
|
|
@@ -104,9 +65,6 @@ export function ControlModes() {
|
|
|
104
65
|
const setPhase = useEditor((state) => state.setPhase)
|
|
105
66
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
|
106
67
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
|
107
|
-
const primeMaterialPaintFromSelection = useEditor(
|
|
108
|
-
(state) => state.primeMaterialPaintFromSelection,
|
|
109
|
-
)
|
|
110
68
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
111
69
|
|
|
112
70
|
// Only subscribe to the primitive `level` number — when walls are added to
|
|
@@ -129,10 +87,6 @@ export function ControlModes() {
|
|
|
129
87
|
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
|
130
88
|
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
|
131
89
|
if (id === 'site-edit') return false
|
|
132
|
-
if (id === 'build')
|
|
133
|
-
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
|
134
|
-
if (id === 'material-paint') return mode === 'material-paint'
|
|
135
|
-
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
|
136
90
|
if (id === 'zone')
|
|
137
91
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
|
138
92
|
return mode === id
|
|
@@ -146,12 +100,8 @@ export function ControlModes() {
|
|
|
146
100
|
setMode('select')
|
|
147
101
|
setStructureLayer('elements')
|
|
148
102
|
} else if (isGroundFloor) {
|
|
149
|
-
// Enter site editing — set state directly to preserve level selection.
|
|
150
|
-
// setPhase('site') calls viewer.resetSelection() which clears levelId,
|
|
151
|
-
// breaking the 2D floorplan (it needs a level to render the SVG).
|
|
152
103
|
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
|
|
153
|
-
|
|
154
|
-
useViewer.getState().setSelection({ selectedIds: [] })
|
|
104
|
+
selectSiteFloorplanContext()
|
|
155
105
|
}
|
|
156
106
|
return
|
|
157
107
|
}
|
|
@@ -168,33 +118,6 @@ export function ControlModes() {
|
|
|
168
118
|
} else if (id === 'box-select') {
|
|
169
119
|
setMode('select')
|
|
170
120
|
setSelectionTool('marquee')
|
|
171
|
-
} else if (id === 'build') {
|
|
172
|
-
// Toggle: if already in structure build, go back to select
|
|
173
|
-
if (getIsActive('build')) {
|
|
174
|
-
setMode('select')
|
|
175
|
-
} else {
|
|
176
|
-
setPhase('structure')
|
|
177
|
-
setStructureLayer('elements')
|
|
178
|
-
setMode('build')
|
|
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
|
-
}
|
|
189
|
-
} else if (id === 'furnish') {
|
|
190
|
-
if (getIsActive('furnish')) {
|
|
191
|
-
setMode('select')
|
|
192
|
-
} else {
|
|
193
|
-
setPhase('furnish')
|
|
194
|
-
setMode('build')
|
|
195
|
-
// Auto-switch sidebar to the items panel so the user can pick furniture
|
|
196
|
-
useEditor.getState().setActiveSidebarPanel('items')
|
|
197
|
-
}
|
|
198
121
|
} else if (id === 'zone') {
|
|
199
122
|
if (getIsActive('zone')) {
|
|
200
123
|
setMode('select')
|
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useScene } from '@pascal-app/core'
|
|
4
3
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import {
|
|
6
|
-
import { useEffect, useMemo } from 'react'
|
|
7
|
-
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
|
4
|
+
import { motion } from 'motion/react'
|
|
8
5
|
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
|
9
6
|
import { useIsMobile } from './../../../hooks/use-mobile'
|
|
10
7
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
|
11
|
-
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
|
12
8
|
import { cn } from './../../../lib/utils'
|
|
13
9
|
import useEditor from './../../../store/use-editor'
|
|
14
10
|
import { CameraActions } from './camera-actions'
|
|
15
11
|
import { ControlModes } from './control-modes'
|
|
16
|
-
import { StructureTools } from './structure-tools'
|
|
17
12
|
import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
|
18
13
|
|
|
19
14
|
// Mobile bottom offset matches the viewer's overlap behind the sheet's
|
|
@@ -21,47 +16,7 @@ import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
|
|
21
16
|
// just above that strip instead of inside it.
|
|
22
17
|
const MOBILE_BOTTOM_OFFSET = 24
|
|
23
18
|
|
|
24
|
-
function PaintMaterialTray() {
|
|
25
|
-
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
26
|
-
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
27
|
-
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
|
28
|
-
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
29
|
-
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
30
|
-
const nodes = useScene((state) => state.nodes)
|
|
31
|
-
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
const selectedPaintTarget = resolvePaintTargetFromSelection({
|
|
35
|
-
nodes,
|
|
36
|
-
selectedId,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
if (selectedPaintTarget) {
|
|
40
|
-
setActivePaintTarget(selectedPaintTarget)
|
|
41
|
-
}
|
|
42
|
-
}, [nodes, selectedId, setActivePaintTarget])
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
|
|
46
|
-
<MaterialPicker
|
|
47
|
-
onChange={(material) => {
|
|
48
|
-
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
|
49
|
-
}}
|
|
50
|
-
onSelectMaterialPreset={(materialPreset) => {
|
|
51
|
-
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
|
52
|
-
}}
|
|
53
|
-
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
|
54
|
-
value={activePaintMaterial?.material}
|
|
55
|
-
/>
|
|
56
|
-
</div>
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
19
|
export function ActionMenu({ className }: { className?: string }) {
|
|
61
|
-
const phase = useEditor((state) => state.phase)
|
|
62
|
-
const mode = useEditor((state) => state.mode)
|
|
63
|
-
const tool = useEditor((state) => state.tool)
|
|
64
|
-
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
65
20
|
const isMobile = useIsMobile()
|
|
66
21
|
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
|
67
22
|
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
|
@@ -70,7 +25,6 @@ export function ActionMenu({ className }: { className?: string }) {
|
|
|
70
25
|
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
|
71
26
|
)
|
|
72
27
|
const reducedMotion = useReducedMotion()
|
|
73
|
-
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
|
74
28
|
|
|
75
29
|
// On mobile, defer the bottom rail to the selection bar when something
|
|
76
30
|
// is selected — the contextual actions take priority over mode controls.
|
|
@@ -97,72 +51,6 @@ export function ActionMenu({ className }: { className?: string }) {
|
|
|
97
51
|
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
|
|
98
52
|
transition={transition}
|
|
99
53
|
>
|
|
100
|
-
{/* Structure Tools Row - Animated */}
|
|
101
|
-
<AnimatePresence>
|
|
102
|
-
{phase === 'structure' && mode === 'build' && (
|
|
103
|
-
<motion.div
|
|
104
|
-
animate={{
|
|
105
|
-
opacity: 1,
|
|
106
|
-
maxHeight: 80,
|
|
107
|
-
paddingTop: 8,
|
|
108
|
-
paddingBottom: 8,
|
|
109
|
-
borderBottomWidth: 1,
|
|
110
|
-
}}
|
|
111
|
-
className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
|
|
112
|
-
exit={{
|
|
113
|
-
opacity: 0,
|
|
114
|
-
maxHeight: 0,
|
|
115
|
-
paddingTop: 0,
|
|
116
|
-
paddingBottom: 0,
|
|
117
|
-
borderBottomWidth: 0,
|
|
118
|
-
}}
|
|
119
|
-
initial={{
|
|
120
|
-
opacity: 0,
|
|
121
|
-
maxHeight: 0,
|
|
122
|
-
paddingTop: 0,
|
|
123
|
-
paddingBottom: 0,
|
|
124
|
-
borderBottomWidth: 0,
|
|
125
|
-
}}
|
|
126
|
-
transition={transition}
|
|
127
|
-
>
|
|
128
|
-
<div className="w-max">
|
|
129
|
-
<StructureTools />
|
|
130
|
-
</div>
|
|
131
|
-
</motion.div>
|
|
132
|
-
)}
|
|
133
|
-
</AnimatePresence>
|
|
134
|
-
|
|
135
|
-
<AnimatePresence>
|
|
136
|
-
{showPaintTray && (
|
|
137
|
-
<motion.div
|
|
138
|
-
animate={{
|
|
139
|
-
opacity: 1,
|
|
140
|
-
maxHeight: 96,
|
|
141
|
-
paddingTop: 8,
|
|
142
|
-
paddingBottom: 8,
|
|
143
|
-
borderBottomWidth: 1,
|
|
144
|
-
}}
|
|
145
|
-
className={cn('overflow-hidden border-border border-b px-3')}
|
|
146
|
-
exit={{
|
|
147
|
-
opacity: 0,
|
|
148
|
-
maxHeight: 0,
|
|
149
|
-
paddingTop: 0,
|
|
150
|
-
paddingBottom: 0,
|
|
151
|
-
borderBottomWidth: 0,
|
|
152
|
-
}}
|
|
153
|
-
initial={{
|
|
154
|
-
opacity: 0,
|
|
155
|
-
maxHeight: 0,
|
|
156
|
-
paddingTop: 0,
|
|
157
|
-
paddingBottom: 0,
|
|
158
|
-
borderBottomWidth: 0,
|
|
159
|
-
}}
|
|
160
|
-
transition={transition}
|
|
161
|
-
>
|
|
162
|
-
<PaintMaterialTray />
|
|
163
|
-
</motion.div>
|
|
164
|
-
)}
|
|
165
|
-
</AnimatePresence>
|
|
166
54
|
{isMobile ? (
|
|
167
55
|
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
|
|
168
56
|
{/* Row 1: control modes only */}
|