@pascal-app/viewer 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +20 -5
  2. package/dist/components/error-boundary.d.ts +6 -0
  3. package/dist/components/error-boundary.d.ts.map +1 -1
  4. package/dist/components/error-boundary.js +6 -0
  5. package/dist/components/renderers/node-renderer.d.ts +5 -1
  6. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/node-renderer.js +5 -2
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  10. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  11. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  12. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  13. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  14. package/dist/components/viewer/frame-limiter.js +59 -4
  15. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  16. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  17. package/dist/components/viewer/glb-interactive.js +394 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  19. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  20. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  22. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  23. package/dist/components/viewer/glb-replace-instances.js +61 -0
  24. package/dist/components/viewer/glb-scene.d.ts +54 -0
  25. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  26. package/dist/components/viewer/glb-scene.js +920 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
  30. package/dist/components/viewer/index.d.ts +19 -2
  31. package/dist/components/viewer/index.d.ts.map +1 -1
  32. package/dist/components/viewer/index.js +113 -12
  33. package/dist/components/viewer/lights.d.ts.map +1 -1
  34. package/dist/components/viewer/lights.js +36 -5
  35. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  36. package/dist/components/viewer/perf-monitor.js +17 -2
  37. package/dist/components/viewer/post-processing.d.ts +7 -1
  38. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  39. package/dist/components/viewer/post-processing.js +180 -24
  40. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  41. package/dist/components/viewer/registered-systems.js +6 -4
  42. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  43. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-bvh.js +3 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  46. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  47. package/dist/components/viewer/scene-bvh.test.js +13 -0
  48. package/dist/components/viewer/scene-environment.d.ts +2 -0
  49. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  50. package/dist/components/viewer/scene-environment.js +79 -0
  51. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  52. package/dist/components/viewer/selection-manager.js +23 -12
  53. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  54. package/dist/hooks/use-gltf-ktx2.js +2 -24
  55. package/dist/hooks/use-node-events.d.ts +1 -1
  56. package/dist/hooks/use-node-events.d.ts.map +1 -1
  57. package/dist/hooks/use-node-events.js +22 -14
  58. package/dist/index.d.ts +24 -10
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +29 -13
  61. package/dist/lib/backdrop.d.ts +33 -0
  62. package/dist/lib/backdrop.d.ts.map +1 -0
  63. package/dist/lib/backdrop.js +92 -0
  64. package/dist/lib/box-uv.d.ts +13 -0
  65. package/dist/lib/box-uv.d.ts.map +1 -0
  66. package/dist/lib/box-uv.js +32 -0
  67. package/dist/lib/csg-utils.d.ts +3 -9
  68. package/dist/lib/csg-utils.d.ts.map +1 -1
  69. package/dist/lib/csg-utils.js +65 -5
  70. package/dist/lib/csg-utils.test.d.ts +2 -0
  71. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  72. package/dist/lib/csg-utils.test.js +45 -0
  73. package/dist/lib/edge-style.d.ts +1 -0
  74. package/dist/lib/edge-style.d.ts.map +1 -1
  75. package/dist/lib/edge-style.js +26 -6
  76. package/dist/lib/hero-pose.d.ts +47 -0
  77. package/dist/lib/hero-pose.d.ts.map +1 -0
  78. package/dist/lib/hero-pose.js +227 -0
  79. package/dist/lib/ink-edges.d.ts +1 -1
  80. package/dist/lib/ink-edges.d.ts.map +1 -1
  81. package/dist/lib/ink-edges.js +13 -7
  82. package/dist/lib/ktx2-loader.d.ts +46 -0
  83. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  84. package/dist/lib/ktx2-loader.js +142 -0
  85. package/dist/lib/layers.d.ts +10 -0
  86. package/dist/lib/layers.d.ts.map +1 -1
  87. package/dist/lib/layers.js +10 -0
  88. package/dist/lib/materials.d.ts +16 -1
  89. package/dist/lib/materials.d.ts.map +1 -1
  90. package/dist/lib/materials.js +158 -34
  91. package/dist/lib/materials.test.d.ts +2 -0
  92. package/dist/lib/materials.test.d.ts.map +1 -0
  93. package/dist/lib/materials.test.js +25 -0
  94. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  95. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  96. package/dist/lib/merged-outline-node.test.js +22 -0
  97. package/dist/lib/scene-themes.d.ts +1 -0
  98. package/dist/lib/scene-themes.d.ts.map +1 -1
  99. package/dist/lib/scene-themes.js +25 -16
  100. package/dist/lib/shadow-only.d.ts +4 -0
  101. package/dist/lib/shadow-only.d.ts.map +1 -0
  102. package/dist/lib/shadow-only.js +36 -0
  103. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  104. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  105. package/dist/lib/snapshot-pipeline.js +254 -0
  106. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  107. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  108. package/dist/lib/terrain-perimeter-fill.js +75 -0
  109. package/dist/lib/texture-node-guard.d.ts +13 -0
  110. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  111. package/dist/lib/texture-node-guard.js +39 -0
  112. package/dist/lib/texture-reference.d.ts +31 -0
  113. package/dist/lib/texture-reference.d.ts.map +1 -0
  114. package/dist/lib/texture-reference.js +139 -0
  115. package/dist/lib/texture-reference.test.d.ts +2 -0
  116. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  117. package/dist/lib/texture-reference.test.js +86 -0
  118. package/dist/lib/tsl-compat.d.ts +12 -0
  119. package/dist/lib/tsl-compat.d.ts.map +1 -0
  120. package/dist/lib/tsl-compat.js +13 -0
  121. package/dist/store/use-viewer.d.ts +73 -18
  122. package/dist/store/use-viewer.d.ts.map +1 -1
  123. package/dist/store/use-viewer.js +191 -4
  124. package/dist/store/use-viewer.test.d.ts +2 -0
  125. package/dist/store/use-viewer.test.d.ts.map +1 -0
  126. package/dist/store/use-viewer.test.js +61 -0
  127. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  128. package/dist/systems/ceiling/ceiling-system.js +12 -5
  129. package/dist/systems/door/door-animation.test.d.ts +2 -0
  130. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  131. package/dist/systems/door/door-animation.test.js +51 -0
  132. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  133. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  134. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  135. package/dist/systems/door/door-system.d.ts +24 -0
  136. package/dist/systems/door/door-system.d.ts.map +1 -1
  137. package/dist/systems/door/door-system.js +494 -161
  138. package/dist/systems/fence/fence-system.d.ts +17 -0
  139. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  140. package/dist/systems/fence/fence-system.js +221 -53
  141. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  142. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  143. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  144. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  145. package/dist/systems/geometry/geometry-system.js +95 -26
  146. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  147. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  148. package/dist/systems/geometry/geometry-system.test.js +30 -0
  149. package/dist/systems/interactive/control-widget.d.ts +10 -0
  150. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  151. package/dist/systems/interactive/control-widget.js +35 -0
  152. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  153. package/dist/systems/interactive/interactive-system.js +55 -56
  154. package/dist/systems/item/item-system.d.ts.map +1 -1
  155. package/dist/systems/item/item-system.js +20 -6
  156. package/dist/systems/level/level-system.d.ts.map +1 -1
  157. package/dist/systems/level/level-system.js +34 -9
  158. package/dist/systems/level/level-utils.d.ts +0 -3
  159. package/dist/systems/level/level-utils.d.ts.map +1 -1
  160. package/dist/systems/level/level-utils.js +7 -45
  161. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  162. package/dist/systems/roof/roof-materials.js +29 -8
  163. package/dist/systems/roof/roof-system.d.ts +21 -8
  164. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  165. package/dist/systems/roof/roof-system.js +1079 -221
  166. package/dist/systems/slab/slab-system.d.ts +7 -4
  167. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  168. package/dist/systems/slab/slab-system.js +31 -83
  169. package/dist/systems/slab/slab-system.test.js +54 -14
  170. package/dist/systems/stair/stair-system.js +7 -4
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  173. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  174. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  175. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  176. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  177. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  178. package/dist/systems/wall/wall-cutout.js +49 -17
  179. package/dist/systems/wall/wall-materials.d.ts +11 -7
  180. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  181. package/dist/systems/wall/wall-materials.js +244 -35
  182. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  183. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  184. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  185. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  186. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  187. package/dist/systems/wall/wall-support-extension.test.js +116 -0
  188. package/dist/systems/wall/wall-system.d.ts +4 -8
  189. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  190. package/dist/systems/wall/wall-system.js +397 -177
  191. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  192. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  194. package/dist/systems/window/window-animation-system.d.ts +12 -0
  195. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  196. package/dist/systems/window/window-animation-system.js +16 -5
  197. package/dist/systems/window/window-system.d.ts +7 -0
  198. package/dist/systems/window/window-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-system.js +240 -14
  200. package/package.json +4 -4
  201. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  202. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  203. package/dist/components/viewer/ground-occluder.js +0 -67
  204. package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
  205. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  206. package/dist/components/viewer/walkthrough-controls.js +0 -131
@@ -1,10 +1,137 @@
1
1
  'use client';
2
2
  import { create } from 'zustand';
3
3
  import { persist } from 'zustand/middleware';
4
+ import { SCENE_THEME_IDS } from '../lib/scene-themes';
5
+ const CAMERA_MODES = ['perspective', 'orthographic'];
6
+ const RENDER_SHADINGS = ['solid', 'rendered'];
7
+ const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'];
8
+ const EDGE_MODES = ['off', 'soft', 'strong'];
9
+ const UNITS = ['metric', 'imperial'];
10
+ const METRIC_NOTATIONS = ['meters', 'millimeters'];
11
+ const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'];
12
+ const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'];
13
+ // Countries still on imperial/US customary units: United States, Liberia, Myanmar.
14
+ const IMPERIAL_REGIONS = ['US', 'LR', 'MM'];
15
+ // IANA zones for those countries. The timezone tracks the OS clock (actual
16
+ // location), unlike navigator.language where en-US is a common default for
17
+ // users far outside the US.
18
+ const IMPERIAL_TIMEZONES = new Set([
19
+ 'America/New_York',
20
+ 'America/Detroit',
21
+ 'America/Kentucky/Louisville',
22
+ 'America/Kentucky/Monticello',
23
+ 'America/Indiana/Indianapolis',
24
+ 'America/Indiana/Vincennes',
25
+ 'America/Indiana/Winamac',
26
+ 'America/Indiana/Marengo',
27
+ 'America/Indiana/Petersburg',
28
+ 'America/Indiana/Vevay',
29
+ 'America/Indiana/Tell_City',
30
+ 'America/Indiana/Knox',
31
+ 'America/Chicago',
32
+ 'America/Menominee',
33
+ 'America/North_Dakota/Center',
34
+ 'America/North_Dakota/New_Salem',
35
+ 'America/North_Dakota/Beulah',
36
+ 'America/Denver',
37
+ 'America/Boise',
38
+ 'America/Phoenix',
39
+ 'America/Los_Angeles',
40
+ 'America/Anchorage',
41
+ 'America/Juneau',
42
+ 'America/Sitka',
43
+ 'America/Metlakatla',
44
+ 'America/Yakutat',
45
+ 'America/Nome',
46
+ 'America/Adak',
47
+ 'Pacific/Honolulu',
48
+ 'America/Puerto_Rico',
49
+ 'Pacific/Guam',
50
+ 'Africa/Monrovia', // Liberia
51
+ 'Asia/Yangon', // Myanmar
52
+ 'Asia/Rangoon', // Myanmar (legacy alias)
53
+ ]);
54
+ function detectDefaultUnit() {
55
+ if (typeof navigator === 'undefined')
56
+ return 'metric';
57
+ try {
58
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
59
+ if (timeZone)
60
+ return IMPERIAL_TIMEZONES.has(timeZone) ? 'imperial' : 'metric';
61
+ // No timezone available: fall back to an explicit locale region subtag
62
+ // only (never maximize() — it turns a bare "en" into region US).
63
+ const region = new Intl.Locale(navigator.language).region;
64
+ return region && IMPERIAL_REGIONS.includes(region) ? 'imperial' : 'metric';
65
+ }
66
+ catch {
67
+ return 'metric';
68
+ }
69
+ }
70
+ function pickString(value, allowed, fallback) {
71
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback;
72
+ }
73
+ function normalizeShadingByContext(value) {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ return {};
76
+ const next = {};
77
+ for (const [context, shading] of Object.entries(value)) {
78
+ if (context !== 'editor' && context !== 'viewer')
79
+ continue;
80
+ next[context] = pickString(shading, RENDER_SHADINGS, 'rendered');
81
+ }
82
+ return next;
83
+ }
84
+ function normalizeProjectPreferences(value) {
85
+ if (!value || typeof value !== 'object' || Array.isArray(value))
86
+ return {};
87
+ const next = {};
88
+ for (const [projectId, preferences] of Object.entries(value)) {
89
+ if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences))
90
+ continue;
91
+ const record = preferences;
92
+ next[projectId] = {
93
+ ...(typeof record.showScans === 'boolean' ? { showScans: record.showScans } : {}),
94
+ ...(typeof record.showGuides === 'boolean' ? { showGuides: record.showGuides } : {}),
95
+ ...(typeof record.showGrid === 'boolean' ? { showGrid: record.showGrid } : {}),
96
+ ...(typeof record.showMeasurements === 'boolean'
97
+ ? { showMeasurements: record.showMeasurements }
98
+ : {}),
99
+ };
100
+ }
101
+ return next;
102
+ }
103
+ function normalizePersistedViewerState(value) {
104
+ if (!value || typeof value !== 'object' || Array.isArray(value))
105
+ return {};
106
+ const state = value;
107
+ return {
108
+ cameraMode: pickString(state.cameraMode, CAMERA_MODES, 'perspective'),
109
+ sceneTheme: pickString(state.sceneTheme, SCENE_THEME_IDS, 'studio'),
110
+ shadingByContext: normalizeShadingByContext(state.shadingByContext),
111
+ textures: typeof state.textures === 'boolean' ? state.textures : true,
112
+ colorPreset: pickString(state.colorPreset, COLOR_PRESETS, 'clay'),
113
+ edges: pickString(state.edges, EDGE_MODES, 'soft'),
114
+ shadows: typeof state.shadows === 'boolean' ? state.shadows : true,
115
+ unit: pickString(state.unit, UNITS, detectDefaultUnit()),
116
+ metricNotation: pickString(state.metricNotation, METRIC_NOTATIONS, 'meters'),
117
+ unitExplicit: typeof state.unit === 'string' && UNITS.includes(state.unit),
118
+ levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'),
119
+ wallMode: pickString(state.wallMode, WALL_MODES, 'up'),
120
+ projectPreferences: normalizeProjectPreferences(state.projectPreferences),
121
+ };
122
+ }
4
123
  const useViewer = create()(persist((set) => ({
5
124
  selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
6
125
  previewSelectedIds: [],
7
126
  setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
127
+ externalSelectedIds: [],
128
+ setExternalSelectedIds: (ids) => set((state) => {
129
+ if (state.externalSelectedIds.length === ids.length &&
130
+ state.externalSelectedIds.every((id, index) => id === ids[index])) {
131
+ return state;
132
+ }
133
+ return { externalSelectedIds: ids };
134
+ }),
8
135
  hoverHighlightMode: 'default',
9
136
  setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
10
137
  hoveredId: null,
@@ -15,6 +142,21 @@ const useViewer = create()(persist((set) => ({
15
142
  setSceneTheme: (id) => set({ sceneTheme: id }),
16
143
  renderContext: 'editor',
17
144
  setRenderContext: (context) => set({ renderContext: context }),
145
+ isExporting: false,
146
+ setExporting: (value) => set({ isExporting: value }),
147
+ itemLoadFailures: {},
148
+ reportItemLoadFailure: (nodeId, url) => set((state) => state.itemLoadFailures[nodeId] === url
149
+ ? state
150
+ : { itemLoadFailures: { ...state.itemLoadFailures, [nodeId]: url } }),
151
+ clearItemLoadFailure: (nodeId) => set((state) => {
152
+ if (!(nodeId in state.itemLoadFailures))
153
+ return state;
154
+ const next = { ...state.itemLoadFailures };
155
+ delete next[nodeId];
156
+ return { itemLoadFailures: next };
157
+ }),
158
+ renderPaused: false,
159
+ setRenderPaused: (value) => set({ renderPaused: value }),
18
160
  shading: 'rendered',
19
161
  shadingByContext: {},
20
162
  setShading: (shading) => set((state) => ({
@@ -29,8 +171,11 @@ const useViewer = create()(persist((set) => ({
29
171
  setEdges: (edges) => set({ edges }),
30
172
  shadows: true,
31
173
  setShadows: (shadows) => set({ shadows }),
32
- unit: 'metric',
33
- setUnit: (unit) => set({ unit }),
174
+ unit: detectDefaultUnit(),
175
+ metricNotation: 'meters',
176
+ unitExplicit: false,
177
+ setUnit: (unit) => set({ unit, unitExplicit: true }),
178
+ setMetricNotation: (metricNotation) => set({ unit: 'metric', metricNotation, unitExplicit: true }),
34
179
  levelMode: 'stacked',
35
180
  setLevelMode: (mode) => set({ levelMode: mode }),
36
181
  wallMode: 'up',
@@ -68,6 +213,23 @@ const useViewer = create()(persist((set) => ({
68
213
  }
69
214
  return { showGrid: show, projectPreferences };
70
215
  }),
216
+ showMeasurements: true,
217
+ setShowMeasurements: (show) => set((state) => {
218
+ const projectPreferences = { ...(state.projectPreferences || {}) };
219
+ if (state.projectId) {
220
+ projectPreferences[state.projectId] = {
221
+ ...(projectPreferences[state.projectId] || {}),
222
+ showMeasurements: show,
223
+ };
224
+ }
225
+ return { showMeasurements: show, projectPreferences };
226
+ }),
227
+ showZones: true,
228
+ setShowZones: (show) => set({ showZones: show }),
229
+ transparentBackground: false,
230
+ setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
231
+ inkOpacity: null,
232
+ setInkOpacity: (opacity) => set({ inkOpacity: opacity }),
71
233
  projectId: null,
72
234
  setProjectId: (id) => set((state) => {
73
235
  if (!id)
@@ -78,6 +240,7 @@ const useViewer = create()(persist((set) => ({
78
240
  showScans: prefs.showScans ?? true,
79
241
  showGuides: prefs.showGuides ?? true,
80
242
  showGrid: prefs.showGrid ?? true,
243
+ showMeasurements: prefs.showMeasurements ?? true,
81
244
  };
82
245
  }),
83
246
  projectPreferences: {},
@@ -114,18 +277,26 @@ const useViewer = create()(persist((set) => ({
114
277
  previewSelectedIds: [],
115
278
  }),
116
279
  outliner: { selectedObjects: [], hoveredObjects: [] },
280
+ geometryRevision: 0,
281
+ bumpGeometryRevision: () => set((state) => ({ geometryRevision: state.geometryRevision + 1 })),
117
282
  exportScene: null,
118
283
  setExportScene: (fn) => set({ exportScene: fn }),
119
284
  debugColors: false,
120
285
  setDebugColors: (enabled) => set({ debugColors: enabled }),
121
286
  walkthroughMode: false,
122
- setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
287
+ setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }),
288
+ walkthroughSuspended: false,
289
+ setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }),
123
290
  cameraDragging: false,
124
291
  setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
125
292
  inputDragging: false,
126
293
  setInputDragging: (dragging) => set({ inputDragging: dragging }),
127
294
  }), {
128
295
  name: 'viewer-preferences',
296
+ merge: (persistedState, currentState) => ({
297
+ ...currentState,
298
+ ...normalizePersistedViewerState(persistedState),
299
+ }),
129
300
  partialize: (state) => ({
130
301
  cameraMode: state.cameraMode,
131
302
  sceneTheme: state.sceneTheme,
@@ -134,10 +305,26 @@ const useViewer = create()(persist((set) => ({
134
305
  colorPreset: state.colorPreset,
135
306
  edges: state.edges,
136
307
  shadows: state.shadows,
137
- unit: state.unit,
308
+ ...(state.unitExplicit ? { unit: state.unit } : {}),
309
+ metricNotation: state.metricNotation,
138
310
  levelMode: state.levelMode,
139
311
  wallMode: state.wallMode,
140
312
  projectPreferences: state.projectPreferences,
141
313
  }),
142
314
  }));
315
+ /** Apply an authoritative country code (e.g. IP-derived by the host app) as
316
+ * the unit default. Stronger signal than the timezone heuristic used at store
317
+ * creation, but still a default: it never overrides an explicit user choice
318
+ * and is not persisted (the unit only sticks once the user touches the
319
+ * toggle). */
320
+ export function applyCountryUnitDefault(country) {
321
+ if (!country)
322
+ return;
323
+ const state = useViewer.getState();
324
+ if (state.unitExplicit)
325
+ return;
326
+ const unit = IMPERIAL_REGIONS.includes(country.toUpperCase()) ? 'imperial' : 'metric';
327
+ if (state.unit !== unit)
328
+ useViewer.setState({ unit });
329
+ }
143
330
  export default useViewer;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-viewer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-viewer.test.d.ts","sourceRoot":"","sources":["../../src/store/use-viewer.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,61 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
+ // include Bun ambient types in its production declaration build.
3
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
4
+ import useViewer from './use-viewer';
5
+ const resetMeasurementPreferences = () => {
6
+ useViewer.setState({
7
+ externalSelectedIds: [],
8
+ projectId: null,
9
+ projectPreferences: {},
10
+ showMeasurements: true,
11
+ unit: 'metric',
12
+ metricNotation: 'meters',
13
+ });
14
+ };
15
+ beforeEach(resetMeasurementPreferences);
16
+ afterEach(resetMeasurementPreferences);
17
+ describe('measurement display preferences', () => {
18
+ test('stores global visibility per project and defaults new projects to visible', () => {
19
+ const viewer = useViewer.getState();
20
+ viewer.setProjectId('project-a');
21
+ viewer.setShowMeasurements(false);
22
+ expect(useViewer.getState()).toMatchObject({
23
+ projectId: 'project-a',
24
+ showMeasurements: false,
25
+ projectPreferences: {
26
+ 'project-a': { showMeasurements: false },
27
+ },
28
+ });
29
+ useViewer.getState().setProjectId('project-b');
30
+ expect(useViewer.getState().showMeasurements).toBe(true);
31
+ useViewer.getState().setProjectId('project-a');
32
+ expect(useViewer.getState().showMeasurements).toBe(false);
33
+ });
34
+ test('changes display units without changing measurement visibility preferences', () => {
35
+ const viewer = useViewer.getState();
36
+ viewer.setProjectId('project-a');
37
+ viewer.setShowMeasurements(false);
38
+ const preferences = useViewer.getState().projectPreferences;
39
+ useViewer.getState().setUnit('imperial');
40
+ expect(useViewer.getState().unit).toBe('imperial');
41
+ expect(useViewer.getState().projectPreferences).toEqual(preferences);
42
+ expect(useViewer.getState().showMeasurements).toBe(false);
43
+ });
44
+ test('selects millimeters as a metric display notation', () => {
45
+ useViewer.getState().setUnit('imperial');
46
+ useViewer.getState().setMetricNotation('millimeters');
47
+ expect(useViewer.getState()).toMatchObject({
48
+ unit: 'metric',
49
+ metricNotation: 'millimeters',
50
+ unitExplicit: true,
51
+ });
52
+ });
53
+ });
54
+ describe('external selection highlights', () => {
55
+ test('tracks host-owned highlights without changing the local selection', () => {
56
+ const localSelection = useViewer.getState().selection;
57
+ useViewer.getState().setExternalSelectedIds(['wall_remote']);
58
+ expect(useViewer.getState().externalSelectedIds).toEqual(['wall_remote']);
59
+ expect(useViewer.getState().selection).toBe(localSelection);
60
+ });
61
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"ceiling-system.d.ts","sourceRoot":"","sources":["../../../src/systems/ceiling/ceiling-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAMjB,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAgB9B,eAAO,MAAM,aAAa,YA4BzB,CAAA;AA2DD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,WAAW,EACxB,UAAU,GAAE,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAM,GAC9C,KAAK,CAAC,cAAc,CA0DtB"}
1
+ {"version":3,"file":"ceiling-system.d.ts","sourceRoot":"","sources":["../../../src/systems/ceiling/ceiling-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAOjB,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAgB9B,eAAO,MAAM,aAAa,YA4BzB,CAAA;AAgED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,WAAW,EACxB,UAAU,GAAE,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAM,GAC9C,KAAK,CAAC,cAAc,CA6DtB"}
@@ -1,4 +1,4 @@
1
- import { getEffectiveNode, nodeRegistry, sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core';
1
+ import { getEffectiveNode, nodeRegistry, resolveCeilingHeight, sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
4
  import { mergeSurfaceHolePolygons } from '../surface-hole-geometry';
@@ -21,7 +21,7 @@ export const CeilingSystem = () => {
21
21
  // Process dirty ceilings
22
22
  dirtyNodes.forEach((id) => {
23
23
  const node = nodes[id];
24
- if (!node || node.type !== 'ceiling')
24
+ if (node?.type !== 'ceiling')
25
25
  return;
26
26
  const mesh = sceneRegistry.nodes.get(id);
27
27
  if (mesh) {
@@ -30,7 +30,7 @@ export const CeilingSystem = () => {
30
30
  // the final value on commit. Mirrors WallSystem / GeometrySystem.
31
31
  const effective = getEffectiveNode(node);
32
32
  const itemHoles = collectCeilingHoles(effective, nodes);
33
- updateCeilingGeometry(effective, mesh, itemHoles);
33
+ updateCeilingGeometry(effective, mesh, itemHoles, nodes);
34
34
  clearDirty(id);
35
35
  }
36
36
  // If mesh not found, keep it dirty for next frame
@@ -64,7 +64,7 @@ function collectCeilingHoles(ceiling, nodes) {
64
64
  /**
65
65
  * Updates the geometry for a single ceiling
66
66
  */
67
- function updateCeilingGeometry(node, mesh, extraHoles = []) {
67
+ function updateCeilingGeometry(node, mesh, extraHoles = [], nodes = useScene.getState().nodes) {
68
68
  const newGeo = generateCeilingGeometry(node, extraHoles);
69
69
  mesh.geometry.dispose();
70
70
  mesh.geometry = newGeo;
@@ -81,7 +81,11 @@ function updateCeilingGeometry(node, mesh, extraHoles = []) {
81
81
  const liveTransform = useLiveTransforms.getState().get(node.id);
82
82
  mesh.position.x = liveTransform?.position[0] ?? 0;
83
83
  mesh.position.z = liveTransform?.position[2] ?? 0;
84
- mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0); // Slight offset to avoid z-fighting with upper-level slabs
84
+ // Resolved height: explicit when stored, else the level-top bound — so a
85
+ // follows-mode ceiling re-parks under the current plane on every rebuild
86
+ // (level-height edits / covering-slab changes dirty-mark ceilings).
87
+ // Slight offset to avoid z-fighting with upper-level slabs.
88
+ mesh.position.y = resolveCeilingHeight(node, nodes) - 0.01 + (liveTransform?.position[1] ?? 0);
85
89
  }
86
90
  /**
87
91
  * Generates flat ceiling geometry from polygon (no extrusion).
@@ -101,6 +105,9 @@ export function generateCeilingGeometry(ceilingNode, extraHoles = []) {
101
105
  // the whole command encoder.
102
106
  const degenerate = new THREE.BufferGeometry();
103
107
  degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
108
+ degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
109
+ degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
110
+ degenerate.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
104
111
  return degenerate;
105
112
  }
106
113
  // Create shape from polygon
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=door-animation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door-animation.test.d.ts","sourceRoot":"","sources":["../../../src/systems/door/door-animation.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,51 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
+ // depend on @types/bun so the import type is unresolved at compile time.
3
+ import { describe, expect, test } from 'bun:test';
4
+ import { DoorNode } from '@pascal-app/core';
5
+ import * as THREE from 'three';
6
+ import { buildDoorPreviewMesh, poseDoorMovingParts } from '../../index';
7
+ // The named moving group each operation-door builder emits at the closed pose.
8
+ const MOVING_GROUP = {
9
+ sliding: 'door-sliding-active',
10
+ pocket: 'door-pocket-leaf',
11
+ barn: 'door-barn-leaf',
12
+ 'garage-tiltup': 'door-tiltup-leaf',
13
+ folding: 'door-fold-0',
14
+ 'garage-sectional': 'door-sectional-0',
15
+ 'garage-rollup': 'door-rollup-curtain',
16
+ };
17
+ function worldPosition(object) {
18
+ object.updateMatrixWorld(true);
19
+ return object.getWorldPosition(new THREE.Vector3());
20
+ }
21
+ describe('operation door open kinematics', () => {
22
+ // Each operation door builds its moving parts in a named group at the closed
23
+ // pose; `poseDoorMovingParts` then drives the open motion (the single source
24
+ // of truth shared by the live system and the GLB exporter). This guards the
25
+ // build-once + pose-at-t contract: the group exists, rests closed, and moves.
26
+ for (const doorType of Object.keys(MOVING_GROUP)) {
27
+ test(`${doorType} builds a moving group that opens`, () => {
28
+ const node = DoorNode.parse({ id: `door_${doorType}`, doorType, operationState: 0 });
29
+ const mesh = buildDoorPreviewMesh(node);
30
+ const group = mesh.getObjectByName(MOVING_GROUP[doorType]);
31
+ expect(group).toBeDefined();
32
+ // Closed snapshot.
33
+ poseDoorMovingParts(node, mesh, 0);
34
+ const closedPos = worldPosition(group);
35
+ const closedScaleY = group.scale.y;
36
+ const closedRotX = group.rotation.x;
37
+ const closedRotY = group.rotation.y;
38
+ // Open snapshot — at least one of translation / rotation / scale changes.
39
+ poseDoorMovingParts(node, mesh, 1);
40
+ const openPos = worldPosition(group);
41
+ const moved = closedPos.distanceTo(openPos) > 0.05 ||
42
+ Math.abs(closedScaleY - group.scale.y) > 0.1 ||
43
+ Math.abs(closedRotX - group.rotation.x) > 0.05 ||
44
+ Math.abs(closedRotY - group.rotation.y) > 0.05;
45
+ expect(moved).toBe(true);
46
+ // Posing back to closed is the identity of the rest pose.
47
+ poseDoorMovingParts(node, mesh, 0);
48
+ expect(worldPosition(group).distanceTo(closedPos)).toBeLessThan(1e-6);
49
+ });
50
+ }
51
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=door-floor-alignment.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door-floor-alignment.test.d.ts","sourceRoot":"","sources":["../../../src/systems/door/door-floor-alignment.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
+ // depend on @types/bun so the import type is unresolved at compile time.
3
+ import { describe, expect, test } from 'bun:test';
4
+ import { DoorNode } from '@pascal-app/core';
5
+ import * as THREE from 'three';
6
+ import { buildDoorPreviewMesh } from '../../index';
7
+ const DOOR_TYPES = [
8
+ 'hinged',
9
+ 'double',
10
+ 'french',
11
+ 'folding',
12
+ 'pocket',
13
+ 'barn',
14
+ 'sliding',
15
+ 'garage-sectional',
16
+ 'garage-rollup',
17
+ 'garage-tiltup',
18
+ ];
19
+ function visibleBounds(mesh) {
20
+ mesh.updateMatrixWorld(true);
21
+ const bounds = new THREE.Box3();
22
+ for (const child of mesh.children) {
23
+ if (child.name === 'cutout')
24
+ continue;
25
+ bounds.expandByObject(child, true);
26
+ }
27
+ return bounds;
28
+ }
29
+ describe('door floor alignment', () => {
30
+ for (const doorType of DOOR_TYPES) {
31
+ test(`${doorType} does not extend below the opening floor`, () => {
32
+ const node = DoorNode.parse({
33
+ id: `door_floor-alignment-${doorType}`,
34
+ doorType,
35
+ operationState: 0,
36
+ threshold: true,
37
+ });
38
+ const mesh = buildDoorPreviewMesh(node);
39
+ const bounds = visibleBounds(mesh);
40
+ expect(bounds.min.y).toBeGreaterThanOrEqual(-node.height / 2 - 1e-6);
41
+ });
42
+ }
43
+ test('keeps the wall cutout bottom locked to the opening floor', () => {
44
+ const node = DoorNode.parse({ id: 'door_floor-alignment-cutout' });
45
+ const mesh = buildDoorPreviewMesh(node);
46
+ const cutout = mesh.getObjectByName('cutout');
47
+ cutout.geometry.computeBoundingBox();
48
+ expect(cutout.geometry.boundingBox?.min.y).toBeCloseTo(-node.height / 2, 6);
49
+ });
50
+ });
@@ -1,2 +1,26 @@
1
+ import { type DoorNode } from '@pascal-app/core';
2
+ import * as THREE from 'three';
1
3
  export declare const DoorSystem: () => null;
4
+ /**
5
+ * Pose an operation door's moving parts (sliding/pocket/barn leaf, tilt-up and
6
+ * sectional panels, folding chain, roll-up curtain) at `value` (0 = closed,
7
+ * 1 = open) by transforming the named groups its builder emitted at the closed
8
+ * pose. Returns true when the door type has a pose path and its groups exist.
9
+ *
10
+ * Mirrors `poseWindowMovingParts`: the live door system calls it after a
11
+ * (re)build so `operationState` is reflected, and the GLB exporter calls it on a
12
+ * clone to sample keyframes. Swing doors are not handled here — their leaf group
13
+ * carries a `pascalSwingLeaf` marker the exporter reads directly.
14
+ *
15
+ * Roll-up is the one type whose live geometry changes (slats vanish onto a
16
+ * drum), which a glTF clip can't express; the baked approximation scales the
17
+ * curtain up into the lintel. The live roll-up animation keeps its full-detail
18
+ * rebuild and is intentionally NOT routed through this scale.
19
+ */
20
+ export declare function poseDoorMovingParts(node: DoorNode, mesh: THREE.Object3D | undefined, value: number): boolean;
21
+ /**
22
+ * Build a fresh door mesh for preview/ghost rendering.
23
+ * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
24
+ */
25
+ export declare function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh;
2
26
  //# sourceMappingURL=door-system.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"door-system.d.ts","sourceRoot":"","sources":["../../../src/systems/door/door-system.tsx"],"names":[],"mappings":"AA8CA,eAAO,MAAM,UAAU,YAwFtB,CAAA"}
1
+ {"version":3,"file":"door-system.d.ts","sourceRoot":"","sources":["../../../src/systems/door/door-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,QAAQ,EAYd,MAAM,kBAAkB,CAAA;AAGzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA0D9B,eAAO,MAAM,UAAU,YA6GtB,CAAA;AAupCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,SAAS,EAChC,KAAK,EAAE,MAAM,GACZ,OAAO,CA6GT;AAyuCD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,QAAQ,GAAG,KAAK,CAAC,IAAI,CAI/D"}