@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.
Files changed (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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
+ }
@@ -1,26 +1,90 @@
1
1
  import { Howl } from 'howler'
2
2
  import useAudio from '../store/use-audio'
3
3
 
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, enough to kill the
6
+ // machine-gun feeling when the same SFX fires in rapid succession.
7
+ type SFXConfig = {
8
+ src: string
9
+ // Random playback-rate range applied per play (1 = unchanged).
10
+ rateRange?: [number, number]
11
+ // Random volume multiplier range applied per play (1 = unchanged).
12
+ volumeRange?: [number, number]
13
+ // Minimum gap between two plays of this SFX. Triggers within this window
14
+ // are silently dropped so bursty sequences don't phase-stack into noise.
15
+ minIntervalMs?: number
16
+ // Random stereo pan per play, max absolute offset (0 = center, 1 = hard
17
+ // right). A small value like 0.15 keeps things centered but adds just enough
18
+ // spread to stop repeats from stacking on the same point in the field.
19
+ panJitter?: number
20
+ }
21
+
22
+ const DEFAULT_MIN_INTERVAL_MS = 30
23
+
4
24
  // SFX sound definitions
5
- export const SFX = {
6
- gridSnap: '/audios/sfx/grid_snap.mp3',
7
- itemDelete: '/audios/sfx/item_delete.mp3',
8
- itemPick: '/audios/sfx/item_pick.mp3',
9
- itemPlace: '/audios/sfx/item_place.mp3',
10
- itemRotate: '/audios/sfx/item_rotate.mp3',
11
- structureBuild: '/audios/sfx/structure_build.mp3',
12
- structureDelete: '/audios/sfx/structure_delete.mp3',
25
+ export const SFX: Record<string, SFXConfig> = {
26
+ gridSnap: {
27
+ src: '/audios/sfx/grid_snap.mp3',
28
+ rateRange: [0.94, 1.06],
29
+ volumeRange: [0.92, 1.0],
30
+ panJitter: 0.15,
31
+ },
32
+ itemDelete: {
33
+ src: '/audios/sfx/item_delete.mp3',
34
+ rateRange: [0.9, 1.1],
35
+ volumeRange: [0.9, 1.0],
36
+ panJitter: 0.15,
37
+ },
38
+ itemPick: {
39
+ src: '/audios/sfx/item_pick.mp3',
40
+ rateRange: [0.92, 1.08],
41
+ volumeRange: [0.92, 1.0],
42
+ panJitter: 0.15,
43
+ },
44
+ itemPlace: {
45
+ src: '/audios/sfx/item_place.mp3',
46
+ rateRange: [0.98, 1.06],
47
+ volumeRange: [0.9, 1.0],
48
+ panJitter: 0.15,
49
+ },
50
+ itemRotate: {
51
+ src: '/audios/sfx/item_rotate.mp3',
52
+ rateRange: [0.94, 1.06],
53
+ volumeRange: [0.92, 1.0],
54
+ panJitter: 0.15,
55
+ },
56
+ structureBuild: {
57
+ src: '/audios/sfx/structure_build.mp3',
58
+ rateRange: [0.95, 1.05],
59
+ volumeRange: [0.88, 1.0],
60
+ panJitter: 0.15,
61
+ },
62
+ structureDelete: {
63
+ src: '/audios/sfx/structure_delete.mp3',
64
+ rateRange: [0.9, 1.1],
65
+ volumeRange: [0.9, 1.0],
66
+ panJitter: 0.15,
67
+ },
68
+ snapshotCapture: {
69
+ // Shutter should sound consistent, no variation.
70
+ src: '/audios/sfx/snapshot_capture.mp3',
71
+ },
13
72
  } as const
14
73
 
15
74
  export type SFXName = keyof typeof SFX
16
75
 
76
+ function randomInRange([min, max]: [number, number]): number {
77
+ return min + Math.random() * (max - min)
78
+ }
79
+
17
80
  // Preload all SFX sounds
18
81
  const sfxCache = new Map<SFXName, Howl>()
82
+ const lastPlayedAt = new Map<SFXName, number>()
19
83
 
20
84
  // Initialize all sounds
21
- Object.entries(SFX).forEach(([name, path]) => {
85
+ Object.entries(SFX).forEach(([name, config]) => {
22
86
  const sound = new Howl({
23
- src: [path],
87
+ src: [config.src],
24
88
  preload: true,
25
89
  volume: 0.5, // Will be adjusted by the bus
26
90
  })
@@ -36,15 +100,34 @@ export function playSFX(name: SFXName) {
36
100
  console.warn(`SFX not found: ${name}`)
37
101
  return
38
102
  }
103
+ const config = SFX[name]!
104
+
105
+ // Drop rapid repeats, two plays of the same SFX within minIntervalMs just
106
+ // smear into noise, they don't add useful information.
107
+ const now = performance.now()
108
+ const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
109
+ const last = lastPlayedAt.get(name)
110
+ if (last !== undefined && now - last < minInterval) return
111
+ lastPlayedAt.set(name, now)
39
112
 
40
113
  const { masterVolume, sfxVolume, muted } = useAudio.getState()
41
114
 
42
115
  if (muted) return
43
116
 
44
117
  // Calculate final volume (masterVolume and sfxVolume are 0-100)
45
- const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
46
- sound.volume(finalVolume)
47
- sound.play()
118
+ const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
119
+ const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
120
+ const rate = config.rateRange ? randomInRange(config.rateRange) : 1
121
+
122
+ // Apply per-play variation using the returned sound id so overlapping plays
123
+ // don't fight over shared properties on the Howl.
124
+ const id = sound.play()
125
+ sound.volume(baseVolume * volumeJitter, id)
126
+ if (rate !== 1) sound.rate(rate, id)
127
+ if (config.panJitter) {
128
+ const pan = (Math.random() * 2 - 1) * config.panJitter
129
+ sound.stereo(pan, id)
130
+ }
48
131
  }
49
132
 
50
133
  /**
@@ -0,0 +1,126 @@
1
+ import {
2
+ generateId,
3
+ type AnyNodeId,
4
+ sceneRegistry,
5
+ type StairNode,
6
+ StairNode as StairNodeSchema,
7
+ type StairSegmentNode,
8
+ StairSegmentNode as StairSegmentNodeSchema,
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
+ }