@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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core/schema'
|
|
3
|
+
import { computeSceneBoundsXZ } from './scene-bounds'
|
|
4
|
+
|
|
5
|
+
function makeWall(start: [number, number], end: [number, number]): AnyNode {
|
|
6
|
+
return {
|
|
7
|
+
object: 'node',
|
|
8
|
+
id: `wall_${start.join('_')}_${end.join('_')}`,
|
|
9
|
+
type: 'wall',
|
|
10
|
+
parentId: null,
|
|
11
|
+
visible: true,
|
|
12
|
+
metadata: {},
|
|
13
|
+
children: [],
|
|
14
|
+
start,
|
|
15
|
+
end,
|
|
16
|
+
frontSide: 'unknown',
|
|
17
|
+
backSide: 'unknown',
|
|
18
|
+
} as unknown as AnyNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeZone(polygon: [number, number][]): AnyNode {
|
|
22
|
+
return {
|
|
23
|
+
object: 'node',
|
|
24
|
+
id: `zone_${polygon.length}_${polygon[0]?.[0] ?? 0}`,
|
|
25
|
+
type: 'zone',
|
|
26
|
+
parentId: null,
|
|
27
|
+
visible: true,
|
|
28
|
+
metadata: {},
|
|
29
|
+
name: 'Zone',
|
|
30
|
+
polygon,
|
|
31
|
+
color: '#000000',
|
|
32
|
+
} as unknown as AnyNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeSite(points: [number, number][]): AnyNode {
|
|
36
|
+
return {
|
|
37
|
+
object: 'node',
|
|
38
|
+
id: 'site_test',
|
|
39
|
+
type: 'site',
|
|
40
|
+
parentId: null,
|
|
41
|
+
visible: true,
|
|
42
|
+
metadata: {},
|
|
43
|
+
polygon: { type: 'polygon', points },
|
|
44
|
+
children: [],
|
|
45
|
+
} as unknown as AnyNode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('computeSceneBoundsXZ', () => {
|
|
49
|
+
test('returns null when given an empty array', () => {
|
|
50
|
+
expect(computeSceneBoundsXZ([])).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('returns null when no geometry is found on any node', () => {
|
|
54
|
+
const barren = [
|
|
55
|
+
{
|
|
56
|
+
object: 'node',
|
|
57
|
+
id: 'building_1',
|
|
58
|
+
type: 'building',
|
|
59
|
+
parentId: null,
|
|
60
|
+
visible: true,
|
|
61
|
+
metadata: {},
|
|
62
|
+
children: [],
|
|
63
|
+
} as unknown as AnyNode,
|
|
64
|
+
]
|
|
65
|
+
expect(computeSceneBoundsXZ(barren)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('computes bounds from wall endpoints', () => {
|
|
69
|
+
const nodes: AnyNode[] = [makeWall([0, 0], [4, 0]), makeWall([4, 0], [4, 3])]
|
|
70
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
71
|
+
expect(bounds).not.toBeNull()
|
|
72
|
+
expect(bounds!.min).toEqual([0, 0])
|
|
73
|
+
expect(bounds!.max).toEqual([4, 3])
|
|
74
|
+
expect(bounds!.size).toEqual([4, 3])
|
|
75
|
+
expect(bounds!.center).toEqual([2, 1.5])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('includes zone polygons', () => {
|
|
79
|
+
const nodes: AnyNode[] = [
|
|
80
|
+
makeZone([
|
|
81
|
+
[-10, -5],
|
|
82
|
+
[10, -5],
|
|
83
|
+
[10, 5],
|
|
84
|
+
[-10, 5],
|
|
85
|
+
]),
|
|
86
|
+
]
|
|
87
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
88
|
+
expect(bounds).not.toBeNull()
|
|
89
|
+
expect(bounds!.min).toEqual([-10, -5])
|
|
90
|
+
expect(bounds!.max).toEqual([10, 5])
|
|
91
|
+
expect(bounds!.size).toEqual([20, 10])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('ignores the default 30×30 site bootstrap polygon', () => {
|
|
95
|
+
const nodes: AnyNode[] = [
|
|
96
|
+
makeSite([
|
|
97
|
+
[-15, -15],
|
|
98
|
+
[15, -15],
|
|
99
|
+
[15, 15],
|
|
100
|
+
[-15, 15],
|
|
101
|
+
]),
|
|
102
|
+
makeWall([1, 1], [2, 2]),
|
|
103
|
+
]
|
|
104
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
105
|
+
expect(bounds).not.toBeNull()
|
|
106
|
+
// Only the wall should count — the default site polygon is skipped.
|
|
107
|
+
expect(bounds!.min).toEqual([1, 1])
|
|
108
|
+
expect(bounds!.max).toEqual([2, 2])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('honours a non-default site polygon', () => {
|
|
112
|
+
const nodes: AnyNode[] = [
|
|
113
|
+
makeSite([
|
|
114
|
+
[-25, -20],
|
|
115
|
+
[25, -20],
|
|
116
|
+
[25, 20],
|
|
117
|
+
[-25, 20],
|
|
118
|
+
]),
|
|
119
|
+
]
|
|
120
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
121
|
+
expect(bounds).not.toBeNull()
|
|
122
|
+
expect(bounds!.min).toEqual([-25, -20])
|
|
123
|
+
expect(bounds!.max).toEqual([25, 20])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('combines walls, zones and positions across the flat dict', () => {
|
|
127
|
+
const nodes: Record<string, AnyNode> = {
|
|
128
|
+
wallA: makeWall([-8, -3], [4, -3]),
|
|
129
|
+
wallB: makeWall([4, -3], [4, 6]),
|
|
130
|
+
zoneA: makeZone([
|
|
131
|
+
[-8, -3],
|
|
132
|
+
[4, -3],
|
|
133
|
+
[4, 6],
|
|
134
|
+
[-8, 6],
|
|
135
|
+
]),
|
|
136
|
+
item1: {
|
|
137
|
+
object: 'node',
|
|
138
|
+
id: 'item_1',
|
|
139
|
+
type: 'item',
|
|
140
|
+
parentId: null,
|
|
141
|
+
visible: true,
|
|
142
|
+
metadata: {},
|
|
143
|
+
position: [7, 0, 8],
|
|
144
|
+
rotation: [0, 0, 0],
|
|
145
|
+
scale: [1, 1, 1],
|
|
146
|
+
children: [],
|
|
147
|
+
asset: {
|
|
148
|
+
id: 'a',
|
|
149
|
+
category: 'furniture',
|
|
150
|
+
name: 'Chair',
|
|
151
|
+
thumbnail: '',
|
|
152
|
+
src: '',
|
|
153
|
+
dimensions: [1, 1, 1],
|
|
154
|
+
offset: [0, 0, 0],
|
|
155
|
+
rotation: [0, 0, 0],
|
|
156
|
+
scale: [1, 1, 1],
|
|
157
|
+
},
|
|
158
|
+
} as unknown as AnyNode,
|
|
159
|
+
}
|
|
160
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
161
|
+
expect(bounds).not.toBeNull()
|
|
162
|
+
expect(bounds!.min).toEqual([-8, -3])
|
|
163
|
+
expect(bounds!.max).toEqual([7, 8])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('handles a single degenerate point with a minimum extent', () => {
|
|
167
|
+
const nodes: AnyNode[] = [makeWall([2, 2], [2, 2])]
|
|
168
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
169
|
+
expect(bounds).not.toBeNull()
|
|
170
|
+
expect(bounds!.size[0]).toBeGreaterThan(0)
|
|
171
|
+
expect(bounds!.size[1]).toBeGreaterThan(0)
|
|
172
|
+
expect(bounds!.center).toEqual([2, 2])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('skips non-finite coordinates', () => {
|
|
176
|
+
const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
|
|
177
|
+
const bounds = computeSceneBoundsXZ(nodes)
|
|
178
|
+
expect(bounds).not.toBeNull()
|
|
179
|
+
// NaN should be ignored; the usable points are (4,2), (0,0), (1,1).
|
|
180
|
+
expect(bounds!.min).toEqual([0, 0])
|
|
181
|
+
expect(bounds!.max).toEqual([4, 2])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene bounds in the X/Z plane.
|
|
3
|
+
*
|
|
4
|
+
* Used by the auto-frame hook to fit the camera onto a freshly loaded scene
|
|
5
|
+
* (see `../hooks/use-auto-frame`). The hook subscribes to the core scene
|
|
6
|
+
* store and, when `nodes` transitions from empty → non-empty, fires a
|
|
7
|
+
* `camera-controls:fit-scene` event on the core event bus carrying the
|
|
8
|
+
* computed bounds.
|
|
9
|
+
*
|
|
10
|
+
* This module contains no rendering code: it only walks the flat-dict node
|
|
11
|
+
* tree and derives an axis-aligned bounding box on the XZ (plan) plane.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AnyNode } from '@pascal-app/core/schema'
|
|
15
|
+
|
|
16
|
+
export type SceneBoundsXZ = {
|
|
17
|
+
/** Min [x, z] in world units (meters). */
|
|
18
|
+
min: [number, number]
|
|
19
|
+
/** Max [x, z] in world units (meters). */
|
|
20
|
+
max: [number, number]
|
|
21
|
+
/** Center [x, z] = (min + max) / 2. */
|
|
22
|
+
center: [number, number]
|
|
23
|
+
/** Size [w, d] = max - min. */
|
|
24
|
+
size: [number, number]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A very small guard against degenerate bounds (e.g. a single wall of zero length).
|
|
28
|
+
const MIN_BOUNDS_EXTENT = 0.0001
|
|
29
|
+
|
|
30
|
+
function extendPoint(
|
|
31
|
+
acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean },
|
|
32
|
+
x: unknown,
|
|
33
|
+
z: unknown,
|
|
34
|
+
): void {
|
|
35
|
+
if (typeof x !== 'number' || typeof z !== 'number') return
|
|
36
|
+
if (!(Number.isFinite(x) && Number.isFinite(z))) return
|
|
37
|
+
if (x < acc.minX) acc.minX = x
|
|
38
|
+
if (x > acc.maxX) acc.maxX = x
|
|
39
|
+
if (z < acc.minZ) acc.minZ = z
|
|
40
|
+
if (z > acc.maxZ) acc.maxZ = z
|
|
41
|
+
acc.hasPoint = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute the axis-aligned XZ bounds of a scene.
|
|
46
|
+
*
|
|
47
|
+
* Walks every node and extracts 2D footprint points from the fields most
|
|
48
|
+
* nodes carry:
|
|
49
|
+
* - `start`/`end` → wall and fence endpoints in level coordinates.
|
|
50
|
+
* - `polygon` → zone, slab, site-boundary polygons.
|
|
51
|
+
* - `position` → building/item/door/window position; uses [x, z] only.
|
|
52
|
+
*
|
|
53
|
+
* Site-node polygons are intentionally excluded when they are the default
|
|
54
|
+
* 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
|
|
55
|
+
* an empty square around the origin. We still include site polygons that
|
|
56
|
+
* look intentional (> 4 points, or any point outside the ±15 m default).
|
|
57
|
+
*
|
|
58
|
+
* Returns `null` if no usable geometry was found.
|
|
59
|
+
*/
|
|
60
|
+
export function computeSceneBoundsXZ(
|
|
61
|
+
nodes: AnyNode[] | Record<string, AnyNode>,
|
|
62
|
+
): SceneBoundsXZ | null {
|
|
63
|
+
const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
|
|
64
|
+
if (list.length === 0) return null
|
|
65
|
+
|
|
66
|
+
const acc = {
|
|
67
|
+
minX: Number.POSITIVE_INFINITY,
|
|
68
|
+
minZ: Number.POSITIVE_INFINITY,
|
|
69
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
70
|
+
maxZ: Number.NEGATIVE_INFINITY,
|
|
71
|
+
hasPoint: false,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const node of list) {
|
|
75
|
+
if (!node || typeof node !== 'object') continue
|
|
76
|
+
const anyNode = node as unknown as Record<string, unknown>
|
|
77
|
+
|
|
78
|
+
// Wall / fence endpoints in level coordinates.
|
|
79
|
+
const start = anyNode.start as unknown
|
|
80
|
+
const end = anyNode.end as unknown
|
|
81
|
+
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
|
|
82
|
+
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
|
|
83
|
+
|
|
84
|
+
// Zone / slab polygons (and explicit polygon-shaped site boundaries).
|
|
85
|
+
const polygon = anyNode.polygon as unknown
|
|
86
|
+
if (Array.isArray(polygon)) {
|
|
87
|
+
// Zones/slabs expose a plain array of [x,z] tuples. Site nodes nest the
|
|
88
|
+
// points under `polygon.points` (a discriminated PropertyLineData shape).
|
|
89
|
+
for (const point of polygon) {
|
|
90
|
+
if (Array.isArray(point) && point.length >= 2) {
|
|
91
|
+
extendPoint(acc, point[0], point[1])
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else if (
|
|
95
|
+
polygon &&
|
|
96
|
+
typeof polygon === 'object' &&
|
|
97
|
+
Array.isArray((polygon as { points?: unknown }).points)
|
|
98
|
+
) {
|
|
99
|
+
// Site nodes only: skip the default bootstrap square so a blank scene
|
|
100
|
+
// isn't auto-framed around an empty ±15 m box. Include any other site
|
|
101
|
+
// polygon (more than 4 points, or any coordinate beyond the default).
|
|
102
|
+
const points = (polygon as { points: unknown[] }).points
|
|
103
|
+
if (node.type === 'site' && isDefaultSitePolygon(points)) {
|
|
104
|
+
// Skip — default bootstrap polygon.
|
|
105
|
+
} else {
|
|
106
|
+
for (const point of points) {
|
|
107
|
+
if (Array.isArray(point) && point.length >= 2) {
|
|
108
|
+
extendPoint(acc, point[0], point[1])
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Position on the XZ plane (3D position = [x, y, z]).
|
|
115
|
+
const position = anyNode.position as unknown
|
|
116
|
+
if (Array.isArray(position) && position.length >= 3) {
|
|
117
|
+
extendPoint(acc, position[0], position[2])
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!acc.hasPoint) return null
|
|
122
|
+
|
|
123
|
+
// Ensure a minimum extent so a single-point scene still yields a box.
|
|
124
|
+
let minX = acc.minX
|
|
125
|
+
let minZ = acc.minZ
|
|
126
|
+
let maxX = acc.maxX
|
|
127
|
+
let maxZ = acc.maxZ
|
|
128
|
+
if (maxX - minX < MIN_BOUNDS_EXTENT) {
|
|
129
|
+
const cx = (minX + maxX) / 2
|
|
130
|
+
minX = cx - MIN_BOUNDS_EXTENT / 2
|
|
131
|
+
maxX = cx + MIN_BOUNDS_EXTENT / 2
|
|
132
|
+
}
|
|
133
|
+
if (maxZ - minZ < MIN_BOUNDS_EXTENT) {
|
|
134
|
+
const cz = (minZ + maxZ) / 2
|
|
135
|
+
minZ = cz - MIN_BOUNDS_EXTENT / 2
|
|
136
|
+
maxZ = cz + MIN_BOUNDS_EXTENT / 2
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const centerX = (minX + maxX) / 2
|
|
140
|
+
const centerZ = (minZ + maxZ) / 2
|
|
141
|
+
return {
|
|
142
|
+
min: [minX, minZ],
|
|
143
|
+
max: [maxX, maxZ],
|
|
144
|
+
center: [centerX, centerZ],
|
|
145
|
+
size: [maxX - minX, maxZ - minZ],
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Matches the `SiteNode` bootstrap polygon defined in
|
|
151
|
+
* `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
|
|
152
|
+
* We ignore it so the default scene doesn't "auto-frame" onto an empty box.
|
|
153
|
+
*/
|
|
154
|
+
function isDefaultSitePolygon(points: unknown[]): boolean {
|
|
155
|
+
if (points.length !== 4) return false
|
|
156
|
+
const expected: [number, number][] = [
|
|
157
|
+
[-15, -15],
|
|
158
|
+
[15, -15],
|
|
159
|
+
[15, 15],
|
|
160
|
+
[-15, 15],
|
|
161
|
+
]
|
|
162
|
+
for (let i = 0; i < 4; i++) {
|
|
163
|
+
const p = points[i]
|
|
164
|
+
const e = expected[i]!
|
|
165
|
+
if (!Array.isArray(p) || p.length < 2) return false
|
|
166
|
+
if (p[0] !== e[0] || p[1] !== e[1]) return false
|
|
167
|
+
}
|
|
168
|
+
return true
|
|
169
|
+
}
|
package/src/lib/scene.ts
CHANGED
|
File without changes
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -12,6 +12,7 @@ type SFXEvents = {
|
|
|
12
12
|
'sfx:item-rotate': undefined
|
|
13
13
|
'sfx:structure-build': undefined
|
|
14
14
|
'sfx:structure-delete': undefined
|
|
15
|
+
'sfx:snapshot-capture': undefined
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -37,6 +38,7 @@ export function initSFXBus() {
|
|
|
37
38
|
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
|
38
39
|
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
|
39
40
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
|
41
|
+
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Howl } from 'howler'
|
|
|
2
2
|
import useAudio from '../store/use-audio'
|
|
3
3
|
|
|
4
4
|
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
|
5
|
-
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones
|
|
5
|
+
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
|
|
6
6
|
// machine-gun feeling when the same SFX fires in rapid succession.
|
|
7
7
|
type SFXConfig = {
|
|
8
8
|
src: string
|
|
@@ -13,8 +13,8 @@ type SFXConfig = {
|
|
|
13
13
|
// Minimum gap between two plays of this SFX. Triggers within this window
|
|
14
14
|
// are silently dropped so bursty sequences don't phase-stack into noise.
|
|
15
15
|
minIntervalMs?: number
|
|
16
|
-
// Random stereo pan per play
|
|
17
|
-
// right). A small value like 0.15 keeps things
|
|
16
|
+
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
|
17
|
+
// right). A small value like 0.15 keeps things centred but adds just enough
|
|
18
18
|
// spread to stop repeats from stacking on the same point in the field.
|
|
19
19
|
panJitter?: number
|
|
20
20
|
}
|
|
@@ -66,7 +66,7 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
66
66
|
panJitter: 0.15,
|
|
67
67
|
},
|
|
68
68
|
snapshotCapture: {
|
|
69
|
-
// Shutter should sound consistent
|
|
69
|
+
// Shutter should sound consistent — no variation.
|
|
70
70
|
src: '/audios/sfx/snapshot_capture.mp3',
|
|
71
71
|
},
|
|
72
72
|
} as const
|
|
@@ -102,7 +102,7 @@ export function playSFX(name: SFXName) {
|
|
|
102
102
|
}
|
|
103
103
|
const config = SFX[name]!
|
|
104
104
|
|
|
105
|
-
// Drop rapid repeats
|
|
105
|
+
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
106
106
|
// smear into noise, they don't add useful information.
|
|
107
107
|
const now = performance.now()
|
|
108
108
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
generateId,
|
|
4
|
+
type StairNode,
|
|
5
|
+
StairNode as StairNodeSchema,
|
|
6
|
+
type StairSegmentNode,
|
|
7
|
+
StairSegmentNode as StairSegmentNodeSchema,
|
|
8
|
+
sceneRegistry,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import useEditor from '../store/use-editor'
|
|
13
|
+
|
|
14
|
+
type DuplicateStairOptions = {
|
|
15
|
+
mode?: 'select' | 'move'
|
|
16
|
+
offset?: [number, number, number]
|
|
17
|
+
parentId?: AnyNodeId
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type DuplicateStairResult = {
|
|
21
|
+
stair: StairNode
|
|
22
|
+
segmentIds: StairSegmentNode['id'][]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MOVE_REGISTRY_RETRY_LIMIT = 12
|
|
26
|
+
|
|
27
|
+
function stripDuplicateFlags(metadata: unknown) {
|
|
28
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
29
|
+
return metadata
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
|
33
|
+
delete nextMeta.isNew
|
|
34
|
+
delete nextMeta.isTransient
|
|
35
|
+
return nextMeta
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
|
|
39
|
+
const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
|
|
40
|
+
if (!latestStair || latestStair.type !== 'stair') {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (sceneRegistry.nodes.has(stairId)) {
|
|
45
|
+
useEditor.getState().setMovingNode(latestStair)
|
|
46
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
|
|
51
|
+
console.warn(`Duplicated stair "${stairId}" did not register before move mode started`)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
requestAnimationFrame(() => moveStairWhenRegistered(stairId, attempt + 1))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function duplicateStairSubtree(
|
|
59
|
+
sourceStairId: AnyNodeId,
|
|
60
|
+
options: DuplicateStairOptions = {},
|
|
61
|
+
): DuplicateStairResult {
|
|
62
|
+
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
|
|
63
|
+
|
|
64
|
+
const scene = useScene.getState()
|
|
65
|
+
const sourceStair = scene.nodes[sourceStairId]
|
|
66
|
+
|
|
67
|
+
if (!sourceStair || sourceStair.type !== 'stair') {
|
|
68
|
+
throw new Error(`Node "${sourceStairId}" is not a stair`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parentId = explicitParentId ?? (sourceStair.parentId as AnyNodeId | null)
|
|
72
|
+
if (!parentId) {
|
|
73
|
+
throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stairClone = StairNodeSchema.parse({
|
|
77
|
+
...structuredClone(sourceStair),
|
|
78
|
+
id: generateId('stair'),
|
|
79
|
+
parentId,
|
|
80
|
+
children: [],
|
|
81
|
+
position: [
|
|
82
|
+
sourceStair.position[0] + offset[0],
|
|
83
|
+
sourceStair.position[1] + offset[1],
|
|
84
|
+
sourceStair.position[2] + offset[2],
|
|
85
|
+
] as StairNode['position'],
|
|
86
|
+
metadata: stripDuplicateFlags(sourceStair.metadata),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const segmentClones: StairSegmentNode[] = []
|
|
90
|
+
for (const childId of sourceStair.children ?? []) {
|
|
91
|
+
const childNode = scene.nodes[childId as AnyNodeId]
|
|
92
|
+
if (!childNode || childNode.type !== 'stair-segment') {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const childClone = StairSegmentNodeSchema.parse({
|
|
97
|
+
...structuredClone(childNode),
|
|
98
|
+
id: generateId('sseg'),
|
|
99
|
+
parentId: stairClone.id,
|
|
100
|
+
metadata: stripDuplicateFlags(childNode.metadata),
|
|
101
|
+
})
|
|
102
|
+
segmentClones.push(childClone)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
scene.createNodes([
|
|
106
|
+
{ node: stairClone, parentId },
|
|
107
|
+
...segmentClones.map((segment) => ({ node: segment, parentId: stairClone.id as AnyNodeId })),
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
|
|
111
|
+
if (!createdStair || createdStair.type !== 'stair') {
|
|
112
|
+
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (mode === 'select') {
|
|
116
|
+
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
|
|
117
|
+
} else {
|
|
118
|
+
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
|
|
119
|
+
requestAnimationFrame(() => moveStairWhenRegistered(createdStair.id))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
stair: createdStair,
|
|
124
|
+
segmentIds: segmentClones.map((segment) => segment.id),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
useInteractive,
|
|
4
|
+
useScene,
|
|
5
|
+
type WindowInteractiveState,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
|
|
8
|
+
export const WINDOW_TOGGLE_ANIMATION_MS = 520
|
|
9
|
+
|
|
10
|
+
type WindowOpenAnimationOptions = {
|
|
11
|
+
persist?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isOperableWindowType(windowType: string | undefined) {
|
|
15
|
+
return (
|
|
16
|
+
windowType === 'sliding' ||
|
|
17
|
+
windowType === 'casement' ||
|
|
18
|
+
windowType === 'awning' ||
|
|
19
|
+
windowType === 'hopper' ||
|
|
20
|
+
windowType === 'single-hung' ||
|
|
21
|
+
windowType === 'double-hung' ||
|
|
22
|
+
windowType === 'louvered'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
|
27
|
+
const interactive = useInteractive.getState()
|
|
28
|
+
const runtimeValue = interactive.windows[windowId]?.operationState
|
|
29
|
+
if (runtimeValue !== undefined) return runtimeValue
|
|
30
|
+
|
|
31
|
+
const queuedValue = interactive.windowAnimations[windowId]?.from
|
|
32
|
+
if (queuedValue !== undefined) return queuedValue
|
|
33
|
+
|
|
34
|
+
return nodeValue ?? 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function startWindowOpenAnimation(
|
|
38
|
+
windowId: AnyNodeId,
|
|
39
|
+
field: keyof WindowInteractiveState,
|
|
40
|
+
from: number,
|
|
41
|
+
to: number,
|
|
42
|
+
options?: WindowOpenAnimationOptions,
|
|
43
|
+
) {
|
|
44
|
+
useInteractive.getState().startWindowAnimation(windowId, {
|
|
45
|
+
field,
|
|
46
|
+
from,
|
|
47
|
+
to,
|
|
48
|
+
startedAt: null,
|
|
49
|
+
durationMs: WINDOW_TOGGLE_ANIMATION_MS,
|
|
50
|
+
persist: options?.persist ?? true,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function toggleWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) {
|
|
55
|
+
const node = useScene.getState().nodes[windowId]
|
|
56
|
+
if (
|
|
57
|
+
node?.type !== 'window' ||
|
|
58
|
+
node.openingKind === 'opening' ||
|
|
59
|
+
!isOperableWindowType(node.windowType)
|
|
60
|
+
) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
|
|
65
|
+
startWindowOpenAnimation(
|
|
66
|
+
windowId,
|
|
67
|
+
'operationState',
|
|
68
|
+
currentOpenAmount,
|
|
69
|
+
currentOpenAmount >= 0.5 ? 0 : 1,
|
|
70
|
+
options,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function closeWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) {
|
|
75
|
+
const node = useScene.getState().nodes[windowId]
|
|
76
|
+
if (
|
|
77
|
+
node?.type !== 'window' ||
|
|
78
|
+
node.openingKind === 'opening' ||
|
|
79
|
+
!isOperableWindowType(node.windowType)
|
|
80
|
+
) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
|
|
85
|
+
startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 0, options)
|
|
86
|
+
}
|