@selvajs/ui 6.1.2 → 6.3.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 (32) hide show
  1. package/dist/components/ConfirmDialog.svelte +59 -0
  2. package/dist/components/ConfirmDialog.svelte.d.ts +13 -0
  3. package/dist/components/compute/ComputeApp.svelte +10 -0
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +3 -0
  5. package/dist/components/compute/ParameterPresetManager.svelte +3 -3
  6. package/dist/components/layout/AppShell.svelte +19 -2
  7. package/dist/components/layout/SideNav.svelte +16 -2
  8. package/dist/components/layout/SideNav.svelte.d.ts +5 -0
  9. package/dist/components/preview/inputs/FileInput.svelte +3 -2
  10. package/dist/components/primitives/Callout.svelte +73 -0
  11. package/dist/components/primitives/Callout.svelte.d.ts +40 -0
  12. package/dist/components/primitives/index.d.ts +2 -0
  13. package/dist/components/primitives/index.js +2 -0
  14. package/dist/components/viewer/MeshMetadataDialog.svelte +1 -1
  15. package/dist/components/viewer/SceneManager.svelte +274 -103
  16. package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
  17. package/dist/components/viewer/Viewer.svelte +104 -32
  18. package/dist/i18n/messages.d.ts +4 -2
  19. package/dist/i18n/messages.js +8 -4
  20. package/package.json +14 -14
  21. package/src/lib/components/ConfirmDialog.svelte +59 -0
  22. package/src/lib/components/compute/ComputeApp.svelte +10 -0
  23. package/src/lib/components/compute/ParameterPresetManager.svelte +3 -3
  24. package/src/lib/components/layout/AppShell.svelte +19 -2
  25. package/src/lib/components/layout/SideNav.svelte +16 -2
  26. package/src/lib/components/preview/inputs/FileInput.svelte +3 -2
  27. package/src/lib/components/primitives/Callout.svelte +73 -0
  28. package/src/lib/components/primitives/index.ts +2 -0
  29. package/src/lib/components/viewer/MeshMetadataDialog.svelte +1 -1
  30. package/src/lib/components/viewer/SceneManager.svelte +274 -103
  31. package/src/lib/components/viewer/Viewer.svelte +104 -32
  32. package/src/lib/i18n/messages.ts +12 -6
@@ -5,6 +5,7 @@
5
5
  initThree,
6
6
  updateScene,
7
7
  LOOKS,
8
+ LOOK_PRESETS,
8
9
  type Look,
9
10
  type ThreeInitializerOptions,
10
11
  type CameraController,
@@ -118,11 +119,13 @@
118
119
  let cameraController: CameraController | null = null;
119
120
  let measureTool: MeasureTool | null = null;
120
121
  let grid: Grid | null = null;
121
- let applyEdges: ((root: THREE.Object3D) => void) | null = null;
122
- let clearEdges: ((root: THREE.Object3D) => void) | null = null;
122
+ let applyEdges: ThreeViewer['applyEdges'] | null = null;
123
+ let clearEdges: ThreeViewer['clearEdges'] | null = null;
123
124
  let invalidate: (() => void) | null = null;
125
+ let captureImage: ThreeViewer['captureImage'] | null = null;
124
126
  let setLook: ((look: Look) => void) | null = null;
125
127
  let updateGridScale: (() => void) | null = null;
128
+ let updateShadowBounds: (() => void) | null = null;
126
129
  let fitToView: (() => void) | null = null;
127
130
  let viewerInitialized = false;
128
131
  let sceneVersion = $state(0);
@@ -144,10 +147,15 @@
144
147
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
145
148
  let selectedMeshName: string | null = $state(null);
146
149
 
147
- // Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit.
150
+ // Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit; the
151
+ // map only overrides the names that don't survive capitalisation ('xray' → 'X-Ray').
152
+ const LOOK_LABELS: Partial<Record<Look, string>> = {
153
+ xray: 'X-Ray',
154
+ lineart: 'Line Art'
155
+ };
148
156
  const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
149
157
  look,
150
- label: look.charAt(0).toUpperCase() + look.slice(1)
158
+ label: LOOK_LABELS[look] ?? look.charAt(0).toUpperCase() + look.slice(1)
151
159
  }));
152
160
 
153
161
  const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
@@ -175,12 +183,11 @@
175
183
  onMount(() => {
176
184
  if (!canvas) return;
177
185
 
178
- // Only what differs from the library defaults. The sun and shadows are off because the
179
- // technical look doesn't need themflat ambient plus the HDR environment carry it.
186
+ // Only what differs from the library defaults. Sunlight, shadows and AO are left ON (the
187
+ // library default) and their strength comes from the look with IBL alone every face of a
188
+ // box lights nearly equally and the model reads as a flat white silhouette.
180
189
  const opts: ThreeInitializerOptions = {
181
190
  look: renderStyle,
182
- lighting: { enableSunlight: false },
183
- render: { enableShadows: false },
184
191
  environment: { backgroundColor: config.backgroundColor },
185
192
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
186
193
  measure: { enabled: config.showToolsMenu },
@@ -210,8 +217,10 @@
210
217
  applyEdges = init.applyEdges;
211
218
  clearEdges = init.clearEdges;
212
219
  invalidate = init.invalidate;
220
+ captureImage = init.captureImage;
213
221
  setLook = init.setLook;
214
222
  updateGridScale = init.updateGridScale;
223
+ updateShadowBounds = init.updateShadowBounds;
215
224
  fitToView = init.fitToView;
216
225
  projection = init.cameraController.getProjection();
217
226
 
@@ -253,22 +262,84 @@
253
262
  invalidate?.();
254
263
  }
255
264
 
265
+ // Edges the lineart look switched on, so leaving it can put the user's own choice back rather
266
+ // than stranding them with overlays they never asked for.
267
+ let edgesBeforeLineDrawing: boolean | null = null;
268
+
256
269
  function setRenderStyle(look: Look) {
257
270
  if (!setLook || look === renderStyle) return;
271
+ const wasLineDrawing = LOOK_PRESETS[renderStyle].requiresEdges === true;
258
272
  renderStyle = look;
259
273
  setLook(look);
274
+
275
+ // A look never drives the overlay itself; honouring `requiresEdges` is the host's call. Without
276
+ // edges lineart is blank white shapes, so entering it forces them on.
277
+ if (LOOK_PRESETS[look].requiresEdges) {
278
+ if (!wasLineDrawing) edgesBeforeLineDrawing = edgesVisible;
279
+ if (!edgesVisible) {
280
+ edgesVisible = true;
281
+ } else if (!wasLineDrawing && scene) {
282
+ // Already-attached overlays carry the old fade setting, and addEdges skips a mesh that
283
+ // has one — so they have to go before the un-faded pass can replace them.
284
+ clearEdges?.(scene);
285
+ }
286
+ applyEdgeState();
287
+ } else if (wasLineDrawing && edgesBeforeLineDrawing !== null) {
288
+ edgesVisible = edgesBeforeLineDrawing;
289
+ // Rebuild either way: staying on means swapping the un-faded overlays back for faded ones.
290
+ if (edgesVisible && scene) clearEdges?.(scene);
291
+ applyEdgeState();
292
+ edgesBeforeLineDrawing = null;
293
+ } else if (edgesVisible && scene) {
294
+ // Neither look is a line drawing, but an overlay's colour is derived from its mesh's
295
+ // material — which `setLook` just repainted. addEdges skips meshes that already have an
296
+ // overlay, so without a rebuild the edges keep the previous look's colour.
297
+ clearEdges?.(scene);
298
+ applyEdgeState();
299
+ }
300
+ }
301
+
302
+ // Meshes past the overlay budget get the screen-space fallback instead, and that pass thresholds
303
+ // a depth/normal discontinuity to decide what is an edge — so a shallow crease sitting near the
304
+ // cutoff flips on and off as the camera turns a degree. Tolerable as a hint over a shaded model;
305
+ // in a line drawing it is the picture itself flickering. Raised far enough that a building model
306
+ // gets real overlays throughout: they cost draw calls, but they are stable under orbit.
307
+ const LINE_ART_MAX_OVERLAYS = 20_000;
308
+
309
+ // Edge colour is normally derived per mesh from that mesh's own material, darkened. lineart
310
+ // repaints every material near-white, so a derived edge lands at rgb(62,62,62) or lighter — and
311
+ // worse, `setLook` and `applyEdges` are independent, so whether the derivation sees the white
312
+ // override or the model's original colours depends on which ran last. That is the real source of
313
+ // the "edges look different every time" behaviour. Forcing a colour makes it deterministic.
314
+ const LINE_ART_EDGE_COLOR = 0x1a1d21;
315
+
316
+ // The density fade sets opacity per overlay, so a finely-detailed mesh fades as a whole and its
317
+ // long silhouette edges go with it — in a line drawing that erases the only thing on screen, and
318
+ // it recomputes per frame, so edges pop while orbiting. Shaded looks keep it: there it just
319
+ // softens outlines on a model you can still see.
320
+ function edgeOverrides() {
321
+ return LOOK_PRESETS[renderStyle].requiresEdges
322
+ ? {
323
+ distanceFade: false,
324
+ maxOverlays: LINE_ART_MAX_OVERLAYS,
325
+ color: LINE_ART_EDGE_COLOR
326
+ }
327
+ : undefined;
260
328
  }
261
329
 
262
330
  // `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
263
331
  // overlays; `clearEdges` is its inverse.
264
332
  function applyEdgeState() {
265
333
  if (!scene) return;
266
- if (edgesVisible) applyEdges?.(scene);
334
+ if (edgesVisible) applyEdges?.(scene, edgeOverrides());
267
335
  else clearEdges?.(scene);
268
336
  }
269
337
 
270
338
  function toggleEdges() {
271
339
  edgesVisible = !edgesVisible;
340
+ // Toggling by hand inside lineart overrides what that look forced, so the pre-look value is
341
+ // stale — leaving the look must not undo the user's more recent explicit choice.
342
+ if (LOOK_PRESETS[renderStyle].requiresEdges) edgesBeforeLineDrawing = null;
272
343
  applyEdgeState();
273
344
  }
274
345
 
@@ -278,10 +349,16 @@
278
349
  // Untracked because toggleEdges() already handles the toggle directly — reading
279
350
  // `edgesVisible` tracked here would re-trigger a full solve.
280
351
  untrack(() => {
352
+ // The new meshes carry the materials the parser built, so a look that overrides them
353
+ // (arctic, x-ray) has to be re-applied or the solve silently reverts to shaded.
354
+ setLook?.(renderStyle);
281
355
  // updateScene discarded the previous solve's overlays along with its content.
282
- if (edgesVisible) applyEdges?.(scene!);
356
+ if (edgesVisible) applyEdges?.(scene!, edgeOverrides());
283
357
  // Rescale the grid so cells and fade match the new content's extent.
284
358
  updateGridScale?.();
359
+ // The shadow frustum is sized to scene content, so it has to follow the new geometry —
360
+ // left at the old extent, shadows go blocky or fall outside the map entirely.
361
+ updateShadowBounds?.();
285
362
  // The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
286
363
  // identity, not on the instances just discarded, so it can re-hide it.
287
364
  outliner?.applyTo();
@@ -311,8 +388,7 @@
311
388
  const EXCLUDED_KEYS = new Set([
312
389
  'name',
313
390
  'layer',
314
- 'originalIndex',
315
- 'sourceComponentId',
391
+ 'id',
316
392
  'vertexCount',
317
393
  'faceCount',
318
394
  'vertexOffset',
@@ -322,23 +398,19 @@
322
398
  return usefulEntries.length > 0;
323
399
  }
324
400
 
325
- function downloadScreenshot() {
326
- if (!canvas) return;
327
-
328
- requestAnimationFrame(() => {
329
- canvas.toBlob((blob) => {
330
- if (!blob) return;
331
- const url = URL.createObjectURL(blob);
332
- const link = document.createElement('a');
333
- const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
334
- link.href = url;
335
- link.download = `viewer-${timestamp}.png`;
336
- document.body.appendChild(link);
337
- link.click();
338
- document.body.removeChild(link);
339
- URL.revokeObjectURL(url);
340
- });
341
- });
401
+ async function downloadScreenshot() {
402
+ const blob = await captureImage?.();
403
+ if (!blob) return;
404
+
405
+ const url = URL.createObjectURL(blob);
406
+ const link = document.createElement('a');
407
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
408
+ link.href = url;
409
+ link.download = `viewer-${timestamp}.png`;
410
+ document.body.appendChild(link);
411
+ link.click();
412
+ document.body.removeChild(link);
413
+ URL.revokeObjectURL(url);
342
414
  }
343
415
  </script>
344
416
 
@@ -422,10 +494,10 @@
422
494
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
423
495
  {#if projection === 'perspective'}
424
496
  <Square class="h-4 w-4" />
425
- {t.switchTo2D}
497
+ {t.switchToOrthographic}
426
498
  {:else}
427
499
  <Box class="h-4 w-4" />
428
- {t.switchTo3D}
500
+ {t.switchToPerspective}
429
501
  {/if}
430
502
  </DropdownMenu.Item>
431
503
 
@@ -569,7 +641,7 @@
569
641
  {#if sceneManagerOpen && scene && outliner}
570
642
  <Resizable.Handle withHandle />
571
643
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
572
- <SceneManager {outliner} {sceneVersion} />
644
+ <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
573
645
  </Resizable.Pane>
574
646
  {/if}
575
647
  </Resizable.PaneGroup>
@@ -11,8 +11,8 @@ export type Locale = 'en' | 'de';
11
11
  export interface ViewerMessages {
12
12
  // Tools menu
13
13
  toolsMenu: string;
14
- switchTo2D: string;
15
- switchTo3D: string;
14
+ switchToOrthographic: string;
15
+ switchToPerspective: string;
16
16
  fitToView: string;
17
17
  views: string;
18
18
  measure: string;
@@ -38,6 +38,8 @@ export interface ViewerMessages {
38
38
  clearSearch: string;
39
39
  expandLayer: string;
40
40
  collapseLayer: string;
41
+ expandAll: string;
42
+ collapseAll: string;
41
43
  showLayer: string;
42
44
  hideLayer: string;
43
45
  showObject: string;
@@ -73,8 +75,8 @@ export interface ViewerMessages {
73
75
 
74
76
  const en: ViewerMessages = {
75
77
  toolsMenu: 'Viewer tools',
76
- switchTo2D: 'Switch to 2D',
77
- switchTo3D: 'Switch to 3D',
78
+ switchToOrthographic: 'Orthographic camera',
79
+ switchToPerspective: 'Perspective camera',
78
80
  fitToView: 'Fit to view',
79
81
  views: 'Views',
80
82
  measure: 'Measure',
@@ -98,6 +100,8 @@ const en: ViewerMessages = {
98
100
  clearSearch: 'Clear search',
99
101
  expandLayer: 'Expand layer',
100
102
  collapseLayer: 'Collapse layer',
103
+ expandAll: 'Expand all',
104
+ collapseAll: 'Collapse all',
101
105
  showLayer: 'Show layer',
102
106
  hideLayer: 'Hide layer',
103
107
  showObject: 'Show object',
@@ -128,8 +132,8 @@ const en: ViewerMessages = {
128
132
 
129
133
  const de: ViewerMessages = {
130
134
  toolsMenu: 'Viewer-Werkzeuge',
131
- switchTo2D: 'Zu 2D wechseln',
132
- switchTo3D: 'Zu 3D wechseln',
135
+ switchToOrthographic: 'Orthografische Kamera',
136
+ switchToPerspective: 'Perspektivische Kamera',
133
137
  fitToView: 'Ansicht anpassen',
134
138
  views: 'Ansichten',
135
139
  measure: 'Messen',
@@ -153,6 +157,8 @@ const de: ViewerMessages = {
153
157
  clearSearch: 'Suche löschen',
154
158
  expandLayer: 'Ebene aufklappen',
155
159
  collapseLayer: 'Ebene zuklappen',
160
+ expandAll: 'Alle aufklappen',
161
+ collapseAll: 'Alle zuklappen',
156
162
  showLayer: 'Ebene einblenden',
157
163
  hideLayer: 'Ebene ausblenden',
158
164
  showObject: 'Objekt einblenden',