@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,302 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type {
3
+ ViewerPresentationConfiguration,
4
+ ViewerPresentationContribution,
5
+ } from '@pascal-app/viewer'
6
+ import {
7
+ createLocalProjectPresentationPersistence,
8
+ getLocalProjectPresentationStorageKey,
9
+ } from './local-project-presentation-persistence'
10
+
11
+ const CONTRIBUTION_ID = 'test:environment:presentation'
12
+
13
+ type TestSnapshot = {
14
+ version: 1
15
+ value: string
16
+ }
17
+
18
+ class MemoryStorage {
19
+ readonly values = new Map<string, string>()
20
+
21
+ getItem(key: string): string | null {
22
+ return this.values.get(key) ?? null
23
+ }
24
+
25
+ setItem(key: string, value: string): void {
26
+ this.values.set(key, value)
27
+ }
28
+ }
29
+
30
+ class TestConfiguration implements ViewerPresentationConfiguration {
31
+ private readonly listeners = new Set<() => void>()
32
+ value = 'default'
33
+
34
+ getSnapshot = (): TestSnapshot => ({ version: 1, value: this.value })
35
+
36
+ restore = (snapshot: unknown): void => {
37
+ if (
38
+ typeof snapshot !== 'object' ||
39
+ snapshot === null ||
40
+ !('version' in snapshot) ||
41
+ snapshot.version !== 1 ||
42
+ !('value' in snapshot) ||
43
+ typeof snapshot.value !== 'string'
44
+ ) {
45
+ throw new Error('Invalid test presentation snapshot')
46
+ }
47
+ this.value = snapshot.value
48
+ }
49
+
50
+ reset = (): void => {
51
+ this.value = 'default'
52
+ }
53
+
54
+ subscribe = (onChange: () => void): (() => void) => {
55
+ this.listeners.add(onChange)
56
+ return () => this.listeners.delete(onChange)
57
+ }
58
+
59
+ setValue(value: string): void {
60
+ this.value = value
61
+ for (const listener of this.listeners) listener()
62
+ }
63
+ }
64
+
65
+ class TestRegistry {
66
+ private readonly listeners = new Set<() => void>()
67
+ private contributions: ViewerPresentationContribution[] = []
68
+
69
+ getSnapshot = (): readonly ViewerPresentationContribution[] => this.contributions
70
+
71
+ subscribe = (onChange: () => void): (() => void) => {
72
+ this.listeners.add(onChange)
73
+ return () => this.listeners.delete(onChange)
74
+ }
75
+
76
+ register(configuration: ViewerPresentationConfiguration): void {
77
+ this.contributions = [
78
+ {
79
+ id: CONTRIBUTION_ID,
80
+ component: async () => ({ default: () => null }),
81
+ configuration,
82
+ },
83
+ ]
84
+ for (const listener of this.listeners) listener()
85
+ }
86
+
87
+ uninstall(): void {
88
+ this.contributions = []
89
+ for (const listener of this.listeners) listener()
90
+ }
91
+ }
92
+
93
+ function readStoredValue(storage: MemoryStorage, projectId: string): string {
94
+ const raw = storage.getItem(getLocalProjectPresentationStorageKey(projectId))
95
+ if (!raw) throw new Error(`Missing presentation sidecar for ${projectId}`)
96
+ const sidecar = JSON.parse(raw) as {
97
+ contributions: Record<string, TestSnapshot>
98
+ }
99
+ const snapshot = sidecar.contributions[CONTRIBUTION_ID]
100
+ if (!snapshot) throw new Error(`Missing ${CONTRIBUTION_ID} snapshot`)
101
+ return snapshot.value
102
+ }
103
+
104
+ describe('local project presentation persistence', () => {
105
+ test('persists anonymous settings when assigning a new first project id', () => {
106
+ const storage = new MemoryStorage()
107
+ const registry = new TestRegistry()
108
+ const configuration = new TestConfiguration()
109
+ registry.register(configuration)
110
+ const persistence = createLocalProjectPresentationPersistence({
111
+ registry,
112
+ storage,
113
+ pageHideTarget: null,
114
+ flushDelayMs: 60_000,
115
+ })
116
+
117
+ persistence.switchProject(null)
118
+ configuration.setValue('anonymous atmosphere')
119
+ persistence.switchProject('first-project')
120
+
121
+ expect(configuration.value).toBe('anonymous atmosphere')
122
+ persistence.flush()
123
+ expect(readStoredValue(storage, 'first-project')).toBe('anonymous atmosphere')
124
+ persistence.dispose()
125
+
126
+ const reloadedRegistry = new TestRegistry()
127
+ const reloadedConfiguration = new TestConfiguration()
128
+ reloadedRegistry.register(reloadedConfiguration)
129
+ const reloadedPersistence = createLocalProjectPresentationPersistence({
130
+ registry: reloadedRegistry,
131
+ storage,
132
+ pageHideTarget: null,
133
+ })
134
+ reloadedPersistence.switchProject('first-project')
135
+
136
+ expect(reloadedConfiguration.value).toBe('anonymous atmosphere')
137
+ reloadedPersistence.dispose()
138
+ })
139
+
140
+ test('restores a stored project instead of overwriting it with anonymous settings', () => {
141
+ const storage = new MemoryStorage()
142
+ const storedRegistry = new TestRegistry()
143
+ const storedConfiguration = new TestConfiguration()
144
+ storedRegistry.register(storedConfiguration)
145
+ const storedPersistence = createLocalProjectPresentationPersistence({
146
+ registry: storedRegistry,
147
+ storage,
148
+ pageHideTarget: null,
149
+ flushDelayMs: 60_000,
150
+ })
151
+ storedPersistence.switchProject('existing-project')
152
+ storedConfiguration.setValue('stored atmosphere')
153
+ storedPersistence.flush()
154
+ storedPersistence.dispose()
155
+
156
+ const registry = new TestRegistry()
157
+ const configuration = new TestConfiguration()
158
+ registry.register(configuration)
159
+ const persistence = createLocalProjectPresentationPersistence({
160
+ registry,
161
+ storage,
162
+ pageHideTarget: null,
163
+ flushDelayMs: 60_000,
164
+ })
165
+ persistence.switchProject(null)
166
+ configuration.setValue('anonymous atmosphere')
167
+ persistence.switchProject('existing-project')
168
+
169
+ expect(configuration.value).toBe('stored atmosphere')
170
+ persistence.flush()
171
+ expect(readStoredValue(storage, 'existing-project')).toBe('stored atmosphere')
172
+ persistence.dispose()
173
+ })
174
+
175
+ test('flushes the old project before restoring defaults or the next project', () => {
176
+ const storage = new MemoryStorage()
177
+ const registry = new TestRegistry()
178
+ const configuration = new TestConfiguration()
179
+ registry.register(configuration)
180
+ const persistence = createLocalProjectPresentationPersistence({
181
+ registry,
182
+ storage,
183
+ pageHideTarget: null,
184
+ flushDelayMs: 60_000,
185
+ })
186
+
187
+ persistence.switchProject('project-a')
188
+ configuration.setValue('project A atmosphere')
189
+ persistence.switchProject('project-b')
190
+
191
+ expect(readStoredValue(storage, 'project-a')).toBe('project A atmosphere')
192
+ expect(configuration.value).toBe('default')
193
+
194
+ configuration.setValue('project B atmosphere')
195
+ persistence.switchProject('project-a')
196
+
197
+ expect(readStoredValue(storage, 'project-b')).toBe('project B atmosphere')
198
+ expect(configuration.value).toBe('project A atmosphere')
199
+ persistence.dispose()
200
+ })
201
+
202
+ test('restores a project after reload and flushes pending changes on dispose', () => {
203
+ const storage = new MemoryStorage()
204
+ const firstRegistry = new TestRegistry()
205
+ const firstConfiguration = new TestConfiguration()
206
+ firstRegistry.register(firstConfiguration)
207
+ const firstPersistence = createLocalProjectPresentationPersistence({
208
+ registry: firstRegistry,
209
+ storage,
210
+ pageHideTarget: null,
211
+ flushDelayMs: 60_000,
212
+ })
213
+
214
+ firstPersistence.switchProject('reloadable')
215
+ firstConfiguration.setValue('saved sky')
216
+ firstPersistence.dispose()
217
+
218
+ const reloadedRegistry = new TestRegistry()
219
+ const reloadedConfiguration = new TestConfiguration()
220
+ reloadedRegistry.register(reloadedConfiguration)
221
+ const reloadedPersistence = createLocalProjectPresentationPersistence({
222
+ registry: reloadedRegistry,
223
+ storage,
224
+ pageHideTarget: null,
225
+ })
226
+ reloadedPersistence.switchProject('reloadable')
227
+
228
+ expect(reloadedConfiguration.value).toBe('saved sky')
229
+ reloadedPersistence.dispose()
230
+ })
231
+
232
+ test('recovers corrupted presentation data without touching scene storage', () => {
233
+ const storage = new MemoryStorage()
234
+ const sceneStorageKey = 'pascal-editor-scene'
235
+ const sceneData = '{"nodes":{"site":{"type":"site"}}}'
236
+ storage.setItem(sceneStorageKey, sceneData)
237
+ storage.setItem(getLocalProjectPresentationStorageKey('corrupted'), '{not-json')
238
+
239
+ const registry = new TestRegistry()
240
+ const configuration = new TestConfiguration()
241
+ configuration.value = 'leaked from another project'
242
+ registry.register(configuration)
243
+ const persistence = createLocalProjectPresentationPersistence({
244
+ registry,
245
+ storage,
246
+ pageHideTarget: null,
247
+ flushDelayMs: 60_000,
248
+ })
249
+
250
+ persistence.switchProject('corrupted')
251
+ expect(configuration.value).toBe('default')
252
+ persistence.flush()
253
+
254
+ expect(readStoredValue(storage, 'corrupted')).toBe('default')
255
+ expect(storage.getItem(sceneStorageKey)).toBe(sceneData)
256
+ persistence.dispose()
257
+ })
258
+
259
+ test('retains configuration while a contribution is unregistered and registered again', () => {
260
+ const storage = new MemoryStorage()
261
+ const registry = new TestRegistry()
262
+ const firstConfiguration = new TestConfiguration()
263
+ registry.register(firstConfiguration)
264
+ const persistence = createLocalProjectPresentationPersistence({
265
+ registry,
266
+ storage,
267
+ pageHideTarget: null,
268
+ flushDelayMs: 60_000,
269
+ })
270
+
271
+ persistence.switchProject('plugin-cycle')
272
+ firstConfiguration.setValue('retained atmosphere')
273
+ registry.uninstall()
274
+
275
+ const reinstalledConfiguration = new TestConfiguration()
276
+ registry.register(reinstalledConfiguration)
277
+
278
+ expect(reinstalledConfiguration.value).toBe('retained atmosphere')
279
+ persistence.dispose()
280
+ })
281
+
282
+ test('flushes pending changes on pagehide', () => {
283
+ const storage = new MemoryStorage()
284
+ const registry = new TestRegistry()
285
+ const configuration = new TestConfiguration()
286
+ const pageHideTarget = new EventTarget()
287
+ registry.register(configuration)
288
+ const persistence = createLocalProjectPresentationPersistence({
289
+ registry,
290
+ storage,
291
+ pageHideTarget,
292
+ flushDelayMs: 60_000,
293
+ })
294
+
295
+ persistence.switchProject('pagehide')
296
+ configuration.setValue('last slider value')
297
+ pageHideTarget.dispatchEvent(new Event('pagehide'))
298
+
299
+ expect(readStoredValue(storage, 'pagehide')).toBe('last slider value')
300
+ persistence.dispose()
301
+ })
302
+ })
@@ -0,0 +1,335 @@
1
+ 'use client'
2
+
3
+ import { type ViewerPresentationContribution, viewerPresentationRegistry } from '@pascal-app/viewer'
4
+ import { z } from 'zod'
5
+
6
+ export const LOCAL_PROJECT_PRESENTATION_STORAGE_KEY_PREFIX = 'pascal:project-presentation:v1:'
7
+ const LOCAL_PROJECT_PRESENTATION_VERSION = 1 as const
8
+ const DEFAULT_FLUSH_DELAY_MS = 250
9
+
10
+ type PresentationConfiguration = NonNullable<ViewerPresentationContribution['configuration']>
11
+
12
+ type PresentationRegistry = {
13
+ getSnapshot: () => readonly ViewerPresentationContribution[]
14
+ subscribe: (onChange: () => void) => () => void
15
+ }
16
+
17
+ type PresentationStorage = {
18
+ getItem: (key: string) => string | null
19
+ setItem: (key: string, value: string) => void
20
+ }
21
+
22
+ type PageHideTarget = Pick<EventTarget, 'addEventListener' | 'removeEventListener'>
23
+
24
+ const LocalProjectPresentationSidecarSchema = z
25
+ .object({
26
+ version: z.literal(LOCAL_PROJECT_PRESENTATION_VERSION),
27
+ projectId: z.string(),
28
+ contributions: z.record(z.string(), z.unknown()),
29
+ })
30
+ .strict()
31
+ type LocalProjectPresentationSidecar = z.infer<typeof LocalProjectPresentationSidecarSchema>
32
+
33
+ type StoredSidecar = {
34
+ contributions: Record<string, unknown>
35
+ malformed: boolean
36
+ source: 'missing' | 'stored' | 'unavailable'
37
+ }
38
+
39
+ export type LocalProjectPresentationPersistence = {
40
+ switchProject: (projectId: string | null) => void
41
+ flush: () => void
42
+ dispose: () => void
43
+ }
44
+
45
+ export type LocalProjectPresentationPersistenceOptions = {
46
+ registry?: PresentationRegistry
47
+ storage?: PresentationStorage | null
48
+ pageHideTarget?: PageHideTarget | null
49
+ flushDelayMs?: number
50
+ }
51
+
52
+ export function getLocalProjectPresentationStorageKey(projectId: string): string {
53
+ return `${LOCAL_PROJECT_PRESENTATION_STORAGE_KEY_PREFIX}${encodeURIComponent(projectId)}`
54
+ }
55
+
56
+ function emptyContributions(): Record<string, unknown> {
57
+ return Object.create(null) as Record<string, unknown>
58
+ }
59
+
60
+ function getBrowserStorage(): PresentationStorage | null {
61
+ if (typeof window === 'undefined') return null
62
+ try {
63
+ return window.localStorage
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ function readSidecar(storage: PresentationStorage | null, projectId: string): StoredSidecar {
70
+ if (!storage) {
71
+ return {
72
+ contributions: emptyContributions(),
73
+ malformed: false,
74
+ source: 'unavailable',
75
+ }
76
+ }
77
+
78
+ let raw: string | null
79
+ try {
80
+ raw = storage.getItem(getLocalProjectPresentationStorageKey(projectId))
81
+ } catch {
82
+ return {
83
+ contributions: emptyContributions(),
84
+ malformed: false,
85
+ source: 'unavailable',
86
+ }
87
+ }
88
+ if (raw === null) {
89
+ return {
90
+ contributions: emptyContributions(),
91
+ malformed: false,
92
+ source: 'missing',
93
+ }
94
+ }
95
+
96
+ try {
97
+ const parsed = LocalProjectPresentationSidecarSchema.safeParse(JSON.parse(raw))
98
+ if (!parsed.success || parsed.data.projectId !== projectId) {
99
+ return {
100
+ contributions: emptyContributions(),
101
+ malformed: true,
102
+ source: 'stored',
103
+ }
104
+ }
105
+ return {
106
+ contributions: Object.assign(emptyContributions(), parsed.data.contributions),
107
+ malformed: false,
108
+ source: 'stored',
109
+ }
110
+ } catch {
111
+ return {
112
+ contributions: emptyContributions(),
113
+ malformed: true,
114
+ source: 'stored',
115
+ }
116
+ }
117
+ }
118
+
119
+ class LocalProjectPresentationPersistenceImpl implements LocalProjectPresentationPersistence {
120
+ private projectId: string | null = null
121
+ private projectInitialized = false
122
+ private contributions = emptyContributions()
123
+ private readonly configurations = new Map<
124
+ string,
125
+ { configuration: PresentationConfiguration; unsubscribe: () => void }
126
+ >()
127
+ private readonly initializedConfigurationIds = new Set<string>()
128
+ private readonly registry: PresentationRegistry
129
+ private readonly storage: PresentationStorage | null
130
+ private readonly pageHideTarget: PageHideTarget | null
131
+ private readonly flushDelayMs: number
132
+ private readonly unsubscribeRegistry: () => void
133
+ private flushTimer: ReturnType<typeof setTimeout> | undefined
134
+ private dirty = false
135
+ private suppressWrites = false
136
+ private disposed = false
137
+
138
+ constructor(options: LocalProjectPresentationPersistenceOptions) {
139
+ this.registry = options.registry ?? viewerPresentationRegistry
140
+ this.storage = options.storage === undefined ? getBrowserStorage() : options.storage
141
+ this.pageHideTarget =
142
+ options.pageHideTarget === undefined
143
+ ? typeof window === 'undefined'
144
+ ? null
145
+ : window
146
+ : options.pageHideTarget
147
+ this.flushDelayMs = options.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS
148
+ this.unsubscribeRegistry = this.registry.subscribe(this.reconcileRegistry)
149
+ this.pageHideTarget?.addEventListener('pagehide', this.flushOnPageHide)
150
+ this.reconcileRegistry()
151
+ }
152
+
153
+ switchProject(projectId: string | null): void {
154
+ const nextProjectId = projectId && projectId.length > 0 ? projectId : null
155
+ if (this.projectInitialized && nextProjectId === this.projectId) return
156
+
157
+ const assigningAnonymousPresentation =
158
+ this.projectInitialized && this.projectId === null && nextProjectId !== null
159
+
160
+ this.flush()
161
+ this.projectId = nextProjectId
162
+ this.projectInitialized = true
163
+ this.initializedConfigurationIds.clear()
164
+
165
+ const stored = nextProjectId
166
+ ? readSidecar(this.storage, nextProjectId)
167
+ : {
168
+ contributions: emptyContributions(),
169
+ malformed: false,
170
+ source: 'missing' as const,
171
+ }
172
+ this.contributions = stored.contributions
173
+
174
+ if (assigningAnonymousPresentation && stored.source === 'missing') {
175
+ let capturedConfiguration = false
176
+ for (const [id, entry] of this.configurations) {
177
+ this.initializedConfigurationIds.add(id)
178
+ try {
179
+ this.contributions[id] = entry.configuration.getSnapshot()
180
+ capturedConfiguration = true
181
+ } catch {
182
+ // Leave the live configuration intact when an optional adapter cannot export.
183
+ }
184
+ }
185
+ if (capturedConfiguration) this.markDirty()
186
+ return
187
+ }
188
+
189
+ let recoveredInvalidConfiguration = stored.malformed
190
+ for (const [id, entry] of this.configurations) {
191
+ if (!this.restoreConfiguration(id, entry.configuration)) {
192
+ recoveredInvalidConfiguration = true
193
+ }
194
+ }
195
+
196
+ if (recoveredInvalidConfiguration) this.markDirty()
197
+ }
198
+
199
+ flush = (): void => {
200
+ if (this.flushTimer !== undefined) {
201
+ clearTimeout(this.flushTimer)
202
+ this.flushTimer = undefined
203
+ }
204
+ const projectId = this.projectId
205
+ if (!this.dirty || !projectId || !this.storage) return
206
+
207
+ const contributions = Object.assign(emptyContributions(), this.contributions)
208
+ for (const [id, entry] of this.configurations) {
209
+ if (!this.initializedConfigurationIds.has(id)) continue
210
+ try {
211
+ contributions[id] = entry.configuration.getSnapshot()
212
+ } catch {
213
+ // Retain the last valid snapshot when one contribution cannot export.
214
+ }
215
+ }
216
+
217
+ const sidecar: LocalProjectPresentationSidecar = {
218
+ version: LOCAL_PROJECT_PRESENTATION_VERSION,
219
+ projectId,
220
+ contributions,
221
+ }
222
+ try {
223
+ this.storage.setItem(
224
+ getLocalProjectPresentationStorageKey(projectId),
225
+ JSON.stringify(sidecar),
226
+ )
227
+ this.contributions = contributions
228
+ this.dirty = false
229
+ } catch {
230
+ // Storage denial, quota exhaustion, and non-serializable plugin data are non-fatal.
231
+ }
232
+ }
233
+
234
+ dispose(): void {
235
+ if (this.disposed) return
236
+ this.disposed = true
237
+ this.flush()
238
+ this.unsubscribeRegistry()
239
+ this.pageHideTarget?.removeEventListener('pagehide', this.flushOnPageHide)
240
+ for (const entry of this.configurations.values()) entry.unsubscribe()
241
+ this.configurations.clear()
242
+ this.initializedConfigurationIds.clear()
243
+ }
244
+
245
+ private readonly flushOnPageHide = (): void => {
246
+ this.flush()
247
+ }
248
+
249
+ private readonly reconcileRegistry = (): void => {
250
+ if (this.disposed) return
251
+
252
+ const current = new Map<string, PresentationConfiguration>()
253
+ for (const contribution of this.registry.getSnapshot()) {
254
+ if (contribution.configuration) {
255
+ current.set(contribution.id, contribution.configuration)
256
+ }
257
+ }
258
+
259
+ for (const [id, entry] of this.configurations) {
260
+ if (current.get(id) === entry.configuration) continue
261
+ if (this.initializedConfigurationIds.has(id)) {
262
+ this.captureConfiguration(id, entry.configuration)
263
+ }
264
+ entry.unsubscribe()
265
+ this.configurations.delete(id)
266
+ this.initializedConfigurationIds.delete(id)
267
+ }
268
+
269
+ for (const [id, configuration] of current) {
270
+ if (this.configurations.has(id)) continue
271
+
272
+ let unsubscribe: () => void = () => undefined
273
+ try {
274
+ unsubscribe = configuration.subscribe(() => {
275
+ if (!this.suppressWrites) this.markDirty()
276
+ })
277
+ } catch {
278
+ // A broken optional adapter must not affect the editor or other contributions.
279
+ }
280
+ this.configurations.set(id, { configuration, unsubscribe })
281
+
282
+ if (this.projectInitialized && !this.restoreConfiguration(id, configuration)) {
283
+ this.markDirty()
284
+ }
285
+ }
286
+ }
287
+
288
+ private restoreConfiguration(id: string, configuration: PresentationConfiguration): boolean {
289
+ let valid = true
290
+ this.suppressWrites = true
291
+ try {
292
+ if (Object.hasOwn(this.contributions, id)) {
293
+ try {
294
+ configuration.restore(this.contributions[id])
295
+ } catch {
296
+ valid = false
297
+ delete this.contributions[id]
298
+ try {
299
+ configuration.reset()
300
+ } catch {}
301
+ }
302
+ } else {
303
+ try {
304
+ configuration.reset()
305
+ } catch {}
306
+ }
307
+ } finally {
308
+ this.suppressWrites = false
309
+ this.initializedConfigurationIds.add(id)
310
+ }
311
+ return valid
312
+ }
313
+
314
+ private captureConfiguration(id: string, configuration: PresentationConfiguration): void {
315
+ try {
316
+ this.contributions[id] = configuration.getSnapshot()
317
+ this.markDirty()
318
+ } catch {
319
+ // Preserve the prior snapshot if the retiring adapter cannot export.
320
+ }
321
+ }
322
+
323
+ private markDirty(): void {
324
+ if (!this.projectId || this.suppressWrites || this.disposed) return
325
+ this.dirty = true
326
+ if (this.flushTimer !== undefined) clearTimeout(this.flushTimer)
327
+ this.flushTimer = setTimeout(this.flush, this.flushDelayMs)
328
+ }
329
+ }
330
+
331
+ export function createLocalProjectPresentationPersistence(
332
+ options: LocalProjectPresentationPersistenceOptions = {},
333
+ ): LocalProjectPresentationPersistence {
334
+ return new LocalProjectPresentationPersistenceImpl(options)
335
+ }
@@ -47,6 +47,8 @@ export type PaintableMaterialTarget =
47
47
  | 'turbine-vent'
48
48
  | 'cupola'
49
49
  | 'eyebrow-vent'
50
+ | 'gutter'
51
+ | 'downspout'
50
52
  >
51
53
  | 'item'
52
54
 
@@ -0,0 +1,29 @@
1
+ import type { GlbExportOptions } from './glb-export'
2
+
3
+ export type ModelExportFormat = 'glb' | 'usdz' | 'stl' | 'obj' | 'print-stl' | 'print-3mf'
4
+
5
+ export type ModelExportOptions = Pick<
6
+ GlbExportOptions,
7
+ 'onlyVisible' | 'excludedNodeTypes' | 'includedPresentationIds'
8
+ > & {
9
+ download?: boolean
10
+ printScale?: number
11
+ printScope?: 'whole' | 'levels'
12
+ printContent?: 'structure' | 'everything'
13
+ printBase?: 'none' | 'plinth'
14
+ printMinimumFeatureMm?: number
15
+ printPlinthMarginMm?: number
16
+ printPlinthThicknessMm?: number
17
+ }
18
+
19
+ export type ModelExportArtifact = {
20
+ blob: Blob
21
+ filename: string
22
+ metadata?: unknown
23
+ warnings?: readonly string[]
24
+ }
25
+
26
+ export type ModelExport = (
27
+ format?: ModelExportFormat,
28
+ options?: ModelExportOptions,
29
+ ) => Promise<ModelExportArtifact | null>