@selvajs/ui 6.2.0 → 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.
@@ -13,7 +13,7 @@
13
13
  isFullscreen?: boolean;
14
14
  }
15
15
 
16
- const EXCLUDED_KEYS = new Set(['name', 'layer', 'originalIndex', 'sourceComponentId']);
16
+ const EXCLUDED_KEYS = new Set(['name', 'layer', 'id']);
17
17
 
18
18
  let {
19
19
  open = $bindable(),
@@ -1,10 +1,17 @@
1
1
  <script lang="ts">
2
- import * as THREE from 'three';
3
- import { Eye, EyeOff, ChevronRight, Search, X } from '@lucide/svelte';
4
2
  import {
5
- getObjectLabel,
6
- getTrackingKey,
3
+ Eye,
4
+ EyeOff,
5
+ ChevronRight,
6
+ ChevronsDownUp,
7
+ ChevronsUpDown,
8
+ Search,
9
+ X
10
+ } from '@lucide/svelte';
11
+ import {
12
+ getMemberKeys,
7
13
  getTypeLabel,
14
+ type SceneEntry,
8
15
  type SceneOutliner
9
16
  } from '@selvajs/visualization/scene';
10
17
  import { getLocaleContext } from '../../i18n/localeContext.svelte';
@@ -30,13 +37,17 @@
30
37
 
31
38
  let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
32
39
 
33
- const toggleLayer = (objects: THREE.Object3D[]) => {
34
- outliner.visibility.toggleLayer(objects);
40
+ // A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
41
+ // `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
42
+ // now members, not necessarily whole objects.
43
+ const toggleLayer = (entries: SceneEntry[]) => {
44
+ const show = entries.length > 0 && entries.every((entry) => isEntryHidden(entry));
45
+ for (const entry of entries) outliner.visibility.setEntryVisible(entry, show);
35
46
  onVisibilityChange?.();
36
47
  };
37
48
 
38
- const toggleObject = (object: THREE.Object3D) => {
39
- outliner.toggleObject(object);
49
+ const toggleEntry = (entry: SceneEntry) => {
50
+ outliner.toggleEntry(entry);
40
51
  onVisibilityChange?.();
41
52
  };
42
53
 
@@ -51,6 +62,11 @@
51
62
  let searchQuery = $state('');
52
63
  let anchor = $state<string | null>(null);
53
64
 
65
+ // Layers start collapsed: a solve can produce dozens, and an all-expanded list buries the
66
+ // layer names it exists to show. Only for the first batch of content — after that the user's
67
+ // collapse state is theirs, and re-collapsing on every solve would fight them.
68
+ let collapsedOnLoad = false;
69
+
54
70
  $effect(() => outliner.onAnchorChange((next) => (anchor = next)));
55
71
 
56
72
  // `scene.children` is a plain array the render layer mutates in place, so a solve bumping
@@ -60,19 +76,126 @@
60
76
  return outliner.objects();
61
77
  });
62
78
 
79
+ // Entries, not objects: a merged mesh renders as one THREE object but lists as one row per
80
+ // source object, so an imported model shows its building elements instead of the handful of
81
+ // meshes they were merged into.
63
82
  const layerGroups = $derived.by(() => {
64
83
  void sceneVersion;
65
- return outliner.layerGroups(searchQuery);
84
+ return outliner.entryGroups(searchQuery);
85
+ });
86
+
87
+ const collapseAll = () => {
88
+ for (const layerName of outliner.entryGroups().keys()) collapsed.add(layerName);
89
+ };
90
+
91
+ $effect(() => {
92
+ void sceneVersion;
93
+ if (collapsedOnLoad || outliner.entryGroups().size === 0) return;
94
+ collapsedOnLoad = true;
95
+ collapseAll();
96
+ });
97
+
98
+ const expandAll = () => collapsed.clear();
99
+
100
+ const allCollapsed = $derived(
101
+ layerGroups.size > 0 && [...layerGroups.keys()].every((name) => collapsed.has(name))
102
+ );
103
+
104
+ // ============================================================================
105
+ // Windowing
106
+ // ============================================================================
107
+ //
108
+ // A scene can hold thousands of objects. Mounting a row per object makes expanding a layer,
109
+ // typing in the search box, and scrolling all pay for the whole list, so only the rows
110
+ // overlapping the viewport are rendered and the rest are replaced by spacer height.
111
+ //
112
+ // The two row types have different heights, so the flat model records each row's height and a
113
+ // running offset rather than assuming a single row size. Keep these in sync with the markup:
114
+ // a header is `py-1` around a 3.5-unit icon, an object row `py-0.5` around a 3-unit one.
115
+ const LAYER_ROW_HEIGHT = 30;
116
+ const OBJECT_ROW_HEIGHT = 24;
117
+ // Rows rendered beyond each edge of the viewport, so a fast scroll doesn't show blank space.
118
+ const OVERSCAN = 8;
119
+
120
+ type Row =
121
+ | {
122
+ kind: 'layer';
123
+ key: string;
124
+ top: number;
125
+ height: number;
126
+ layerName: string;
127
+ entries: SceneEntry[];
128
+ }
129
+ | { kind: 'object'; key: string; top: number; height: number; entry: SceneEntry };
130
+
131
+ const rows = $derived.by(() => {
132
+ const flat: Row[] = [];
133
+ let top = 0;
134
+ for (const [layerName, entries] of layerGroups) {
135
+ flat.push({
136
+ kind: 'layer',
137
+ key: `layer:${layerName}`,
138
+ top,
139
+ height: LAYER_ROW_HEIGHT,
140
+ layerName,
141
+ entries
142
+ });
143
+ top += LAYER_ROW_HEIGHT;
144
+ if (collapsed.has(layerName)) continue;
145
+ for (const entry of entries) {
146
+ flat.push({
147
+ kind: 'object',
148
+ key: entry.key,
149
+ top,
150
+ height: OBJECT_ROW_HEIGHT,
151
+ entry
152
+ });
153
+ top += OBJECT_ROW_HEIGHT;
154
+ }
155
+ }
156
+ return flat;
157
+ });
158
+
159
+ const totalHeight = $derived(
160
+ rows.length === 0 ? 0 : rows[rows.length - 1]!.top + rows[rows.length - 1]!.height
161
+ );
162
+
163
+ let scrollTop = $state(0);
164
+ let viewportHeight = $state(0);
165
+
166
+ /** First row index whose bottom edge is at or past `offset`. Rows are ordered by `top`. */
167
+ const findRowAt = (offset: number) => {
168
+ let low = 0;
169
+ let high = rows.length - 1;
170
+ while (low < high) {
171
+ const mid = (low + high) >> 1;
172
+ if (rows[mid]!.top + rows[mid]!.height <= offset) low = mid + 1;
173
+ else high = mid;
174
+ }
175
+ return low;
176
+ };
177
+
178
+ const visibleRows = $derived.by(() => {
179
+ if (rows.length === 0) return [];
180
+ // Before the first measurement `viewportHeight` is 0; render a screenful so the list is
181
+ // never briefly empty.
182
+ const height = viewportHeight || 600;
183
+ const start = Math.max(0, findRowAt(scrollTop) - OVERSCAN);
184
+ const end = Math.min(rows.length, findRowAt(scrollTop + height) + 1 + OVERSCAN);
185
+ return rows.slice(start, end);
66
186
  });
67
187
 
68
188
  // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
69
189
  // `visibility.isHidden` — that reaches the set through a plain reference inside the outliner
70
190
  // and returns a correct value that never re-renders this row.
71
- const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
191
+ const isEntryHidden = (entry: SceneEntry) =>
192
+ entry.memberIndex === null
193
+ ? getMemberKeys(entry.object).every((key) => hidden.has(key))
194
+ : hidden.has(entry.key);
72
195
 
73
- // Same reason: count through the reactive set so the layer's tri-state eye tracks its objects.
74
- const hiddenCount = (objects: THREE.Object3D[]) =>
75
- objects.filter((object) => isObjectHidden(object)).length;
196
+ // Same reason: count through the reactive set so the layer's tri-state eye tracks its entries.
197
+ const hiddenCount = (entries: SceneEntry[]) =>
198
+ entries.filter((entry) => isEntryHidden(entry)).length;
76
199
 
77
200
  // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
78
201
  const selectObject = (uuid: string, event: MouseEvent) => {
@@ -104,110 +227,142 @@
104
227
  />
105
228
  </button>
106
229
  {/if}
230
+
231
+ <button
232
+ class="shrink-0"
233
+ onclick={() => (allCollapsed ? expandAll() : collapseAll())}
234
+ title={allCollapsed ? t.expandAll : t.collapseAll}
235
+ aria-label={allCollapsed ? t.expandAll : t.collapseAll}
236
+ >
237
+ {#if allCollapsed}
238
+ <ChevronsUpDown
239
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
240
+ />
241
+ {:else}
242
+ <ChevronsDownUp
243
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
244
+ />
245
+ {/if}
246
+ </button>
107
247
  </div>
108
248
 
109
- <div class="py-1 flex-1 overflow-y-auto">
110
- {#each [...layerGroups] as [layerName, objects] (layerName)}
111
- {@const numHidden = hiddenCount(objects)}
112
- {@const layerHidden = objects.length > 0 && numHidden === objects.length}
113
- {@const layerPartial = numHidden > 0 && numHidden < objects.length}
114
- {@const isCollapsed = collapsed.has(layerName)}
115
-
116
- <div class="gap-1 pl-1 pr-2 py-1 group flex items-center transition-colors hover:bg-muted">
117
- <button
118
- class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
119
- onclick={() => outliner.toggleCollapsed(layerName)}
120
- aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
121
- >
122
- <ChevronRight
123
- class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed ? '' : 'rotate-90'}"
124
- />
125
- </button>
126
-
127
- <button
128
- class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
129
- onclick={() => toggleLayer(objects)}
130
- title={layerHidden ? t.showLayer : t.hideLayer}
131
- aria-label={layerHidden ? t.showLayer : t.hideLayer}
132
- >
133
- {#if layerHidden}
134
- <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
135
- {:else if layerPartial}
136
- <Eye class="h-3.5 w-3.5 text-muted-foreground/60" />
137
- {:else}
138
- <Eye class="h-3.5 w-3.5 text-muted-foreground" />
139
- {/if}
140
- </button>
141
-
142
- <span
143
- class="min-w-0 text-xs font-medium flex-1 truncate {layerHidden
144
- ? 'text-muted-foreground/40 line-through'
145
- : 'text-foreground'}"
146
- >
147
- {layerName}
148
- </span>
149
-
150
- <span class="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
151
- {objects.length}
152
- </span>
153
- </div>
249
+ <div
250
+ class="py-1 flex-1 overflow-y-auto"
251
+ bind:clientHeight={viewportHeight}
252
+ onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
253
+ >
254
+ <!-- Spacer sized to the full list; windowed rows are placed into it by offset. The listbox
255
+ sits here rather than per layer: windowing renders one flat row list, so every option
256
+ shares a single owner. -->
257
+ <div role="listbox" aria-multiselectable="true" class="relative" style:height="{totalHeight}px">
258
+ {#each visibleRows as row (row.key)}
259
+ <div class="inset-x-0 absolute" style:top="{row.top}px" style:height="{row.height}px">
260
+ {#if row.kind === 'layer'}
261
+ {@const entries = row.entries}
262
+ {@const numHidden = hiddenCount(entries)}
263
+ {@const layerHidden = entries.length > 0 && numHidden === entries.length}
264
+ {@const layerPartial = numHidden > 0 && numHidden < entries.length}
265
+ {@const isCollapsed = collapsed.has(row.layerName)}
154
266
 
155
- {#if !isCollapsed}
156
- <div role="listbox" aria-multiselectable="true" class="ml-3 border-l border-border">
157
- {#each objects as object (object.uuid)}
158
- <!-- Visibility is keyed by Grasshopper identity (so it survives a solve), selection
159
- by uuid (so it does not) — hence the two different lookups. -->
160
- {@const isHidden = isObjectHidden(object)}
161
- {@const isSelected = selected.has(object.uuid)}
162
267
  <div
163
- role="option"
164
- aria-selected={isSelected}
165
- tabindex="-1"
166
- class="gap-1.5 pl-5 pr-2 py-0.5 flex cursor-pointer items-center transition-colors
167
- {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
168
- {isHidden ? 'opacity-40' : ''}"
169
- onmousedown={(e) => {
170
- e.stopPropagation();
171
- if (e.shiftKey) e.preventDefault();
172
- }}
173
- onclick={(e) => selectObject(object.uuid, e)}
174
- onkeydown={(e) =>
175
- e.key === 'Enter' && selectObject(object.uuid, e as unknown as MouseEvent)}
268
+ class="gap-1 pl-1 pr-2 group flex h-full items-center transition-colors hover:bg-muted"
176
269
  >
270
+ <button
271
+ class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
272
+ onclick={() => outliner.toggleCollapsed(row.layerName)}
273
+ aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
274
+ >
275
+ <ChevronRight
276
+ class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed
277
+ ? ''
278
+ : 'rotate-90'}"
279
+ />
280
+ </button>
281
+
177
282
  <button
178
283
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
179
- onclick={(e) => {
180
- e.stopPropagation();
181
- toggleObject(object);
182
- }}
183
- title={isHidden ? t.showObject : t.hideObject}
184
- aria-label={isHidden ? t.showObject : t.hideObject}
284
+ onclick={() => toggleLayer(entries)}
285
+ title={layerHidden ? t.showLayer : t.hideLayer}
286
+ aria-label={layerHidden ? t.showLayer : t.hideLayer}
185
287
  >
186
- {#if isHidden}
187
- <EyeOff class="h-3 w-3 text-muted-foreground/60" />
288
+ {#if layerHidden}
289
+ <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
290
+ {:else if layerPartial}
291
+ <Eye class="h-3.5 w-3.5 text-muted-foreground/60" />
188
292
  {:else}
189
- <Eye class="h-3 w-3 text-muted-foreground" />
293
+ <Eye class="h-3.5 w-3.5 text-muted-foreground" />
190
294
  {/if}
191
295
  </button>
192
296
 
193
297
  <span
194
- class="min-w-0 text-xs flex-1 truncate {isHidden
195
- ? 'text-muted-foreground line-through'
196
- : 'text-foreground/80'}"
298
+ class="min-w-0 text-xs font-medium flex-1 truncate {layerHidden
299
+ ? 'text-muted-foreground/40 line-through'
300
+ : 'text-foreground'}"
197
301
  >
198
- {getObjectLabel(object)}
302
+ {row.layerName}
199
303
  </span>
200
304
 
201
- <span
202
- class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
203
- >
204
- {getTypeLabel(object)}
305
+ <span class="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
306
+ {entries.length}
205
307
  </span>
206
308
  </div>
207
- {/each}
309
+ {:else}
310
+ {@const entry = row.entry}
311
+ <!-- Both keyed by the entry's stable identity: a merged mesh holds many entries, so
312
+ the object's uuid cannot tell its members apart. -->
313
+ {@const isHidden = isEntryHidden(entry)}
314
+ {@const isSelected = selected.has(entry.key)}
315
+ <div class="ml-3 h-full border-l border-border">
316
+ <div
317
+ role="option"
318
+ aria-selected={isSelected}
319
+ tabindex="-1"
320
+ class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
321
+ {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
322
+ {isHidden ? 'opacity-40' : ''}"
323
+ onmousedown={(e) => {
324
+ e.stopPropagation();
325
+ if (e.shiftKey) e.preventDefault();
326
+ }}
327
+ onclick={(e) => selectObject(entry.key, e)}
328
+ onkeydown={(e) =>
329
+ e.key === 'Enter' && selectObject(entry.key, e as unknown as MouseEvent)}
330
+ >
331
+ <button
332
+ class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
333
+ onclick={(e) => {
334
+ e.stopPropagation();
335
+ toggleEntry(entry);
336
+ }}
337
+ title={isHidden ? t.showObject : t.hideObject}
338
+ aria-label={isHidden ? t.showObject : t.hideObject}
339
+ >
340
+ {#if isHidden}
341
+ <EyeOff class="h-3 w-3 text-muted-foreground/60" />
342
+ {:else}
343
+ <Eye class="h-3 w-3 text-muted-foreground" />
344
+ {/if}
345
+ </button>
346
+
347
+ <span
348
+ class="min-w-0 text-xs flex-1 truncate {isHidden
349
+ ? 'text-muted-foreground line-through'
350
+ : 'text-foreground/80'}"
351
+ >
352
+ {entry.label}
353
+ </span>
354
+
355
+ <span
356
+ class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
357
+ >
358
+ {getTypeLabel(entry.object)}
359
+ </span>
360
+ </div>
361
+ </div>
362
+ {/if}
208
363
  </div>
209
- {/if}
210
- {/each}
364
+ {/each}
365
+ </div>
211
366
 
212
367
  {#if sceneObjects.length === 0}
213
368
  <div class="py-12 flex flex-col items-center justify-center text-center">
@@ -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,12 +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;
124
125
  let captureImage: ThreeViewer['captureImage'] | null = null;
125
126
  let setLook: ((look: Look) => void) | null = null;
126
127
  let updateGridScale: (() => void) | null = null;
128
+ let updateShadowBounds: (() => void) | null = null;
127
129
  let fitToView: (() => void) | null = null;
128
130
  let viewerInitialized = false;
129
131
  let sceneVersion = $state(0);
@@ -145,10 +147,15 @@
145
147
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
146
148
  let selectedMeshName: string | null = $state(null);
147
149
 
148
- // 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
+ };
149
156
  const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
150
157
  look,
151
- label: look.charAt(0).toUpperCase() + look.slice(1)
158
+ label: LOOK_LABELS[look] ?? look.charAt(0).toUpperCase() + look.slice(1)
152
159
  }));
153
160
 
154
161
  const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
@@ -176,12 +183,11 @@
176
183
  onMount(() => {
177
184
  if (!canvas) return;
178
185
 
179
- // Only what differs from the library defaults. The sun and shadows are off because the
180
- // 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.
181
189
  const opts: ThreeInitializerOptions = {
182
190
  look: renderStyle,
183
- lighting: { enableSunlight: false },
184
- render: { enableShadows: false },
185
191
  environment: { backgroundColor: config.backgroundColor },
186
192
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
187
193
  measure: { enabled: config.showToolsMenu },
@@ -214,6 +220,7 @@
214
220
  captureImage = init.captureImage;
215
221
  setLook = init.setLook;
216
222
  updateGridScale = init.updateGridScale;
223
+ updateShadowBounds = init.updateShadowBounds;
217
224
  fitToView = init.fitToView;
218
225
  projection = init.cameraController.getProjection();
219
226
 
@@ -255,22 +262,84 @@
255
262
  invalidate?.();
256
263
  }
257
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
+
258
269
  function setRenderStyle(look: Look) {
259
270
  if (!setLook || look === renderStyle) return;
271
+ const wasLineDrawing = LOOK_PRESETS[renderStyle].requiresEdges === true;
260
272
  renderStyle = look;
261
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;
262
328
  }
263
329
 
264
330
  // `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
265
331
  // overlays; `clearEdges` is its inverse.
266
332
  function applyEdgeState() {
267
333
  if (!scene) return;
268
- if (edgesVisible) applyEdges?.(scene);
334
+ if (edgesVisible) applyEdges?.(scene, edgeOverrides());
269
335
  else clearEdges?.(scene);
270
336
  }
271
337
 
272
338
  function toggleEdges() {
273
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;
274
343
  applyEdgeState();
275
344
  }
276
345
 
@@ -280,10 +349,16 @@
280
349
  // Untracked because toggleEdges() already handles the toggle directly — reading
281
350
  // `edgesVisible` tracked here would re-trigger a full solve.
282
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);
283
355
  // updateScene discarded the previous solve's overlays along with its content.
284
- if (edgesVisible) applyEdges?.(scene!);
356
+ if (edgesVisible) applyEdges?.(scene!, edgeOverrides());
285
357
  // Rescale the grid so cells and fade match the new content's extent.
286
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?.();
287
362
  // The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
288
363
  // identity, not on the instances just discarded, so it can re-hide it.
289
364
  outliner?.applyTo();
@@ -313,8 +388,7 @@
313
388
  const EXCLUDED_KEYS = new Set([
314
389
  'name',
315
390
  'layer',
316
- 'originalIndex',
317
- 'sourceComponentId',
391
+ 'id',
318
392
  'vertexCount',
319
393
  'faceCount',
320
394
  'vertexOffset',
@@ -420,10 +494,10 @@
420
494
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
421
495
  {#if projection === 'perspective'}
422
496
  <Square class="h-4 w-4" />
423
- {t.switchTo2D}
497
+ {t.switchToOrthographic}
424
498
  {:else}
425
499
  <Box class="h-4 w-4" />
426
- {t.switchTo3D}
500
+ {t.switchToPerspective}
427
501
  {/if}
428
502
  </DropdownMenu.Item>
429
503
 
@@ -1,8 +1,8 @@
1
1
  export type Locale = 'en' | 'de';
2
2
  export interface ViewerMessages {
3
3
  toolsMenu: string;
4
- switchTo2D: string;
5
- switchTo3D: string;
4
+ switchToOrthographic: string;
5
+ switchToPerspective: string;
6
6
  fitToView: string;
7
7
  views: string;
8
8
  measure: string;
@@ -24,6 +24,8 @@ export interface ViewerMessages {
24
24
  clearSearch: string;
25
25
  expandLayer: string;
26
26
  collapseLayer: string;
27
+ expandAll: string;
28
+ collapseAll: string;
27
29
  showLayer: string;
28
30
  hideLayer: string;
29
31
  showObject: string;
@@ -7,8 +7,8 @@
7
7
  // cannot be translated here.
8
8
  const en = {
9
9
  toolsMenu: 'Viewer tools',
10
- switchTo2D: 'Switch to 2D',
11
- switchTo3D: 'Switch to 3D',
10
+ switchToOrthographic: 'Orthographic camera',
11
+ switchToPerspective: 'Perspective camera',
12
12
  fitToView: 'Fit to view',
13
13
  views: 'Views',
14
14
  measure: 'Measure',
@@ -30,6 +30,8 @@ const en = {
30
30
  clearSearch: 'Clear search',
31
31
  expandLayer: 'Expand layer',
32
32
  collapseLayer: 'Collapse layer',
33
+ expandAll: 'Expand all',
34
+ collapseAll: 'Collapse all',
33
35
  showLayer: 'Show layer',
34
36
  hideLayer: 'Hide layer',
35
37
  showObject: 'Show object',
@@ -56,8 +58,8 @@ const en = {
56
58
  };
57
59
  const de = {
58
60
  toolsMenu: 'Viewer-Werkzeuge',
59
- switchTo2D: 'Zu 2D wechseln',
60
- switchTo3D: 'Zu 3D wechseln',
61
+ switchToOrthographic: 'Orthografische Kamera',
62
+ switchToPerspective: 'Perspektivische Kamera',
61
63
  fitToView: 'Ansicht anpassen',
62
64
  views: 'Ansichten',
63
65
  measure: 'Messen',
@@ -79,6 +81,8 @@ const de = {
79
81
  clearSearch: 'Suche löschen',
80
82
  expandLayer: 'Ebene aufklappen',
81
83
  collapseLayer: 'Ebene zuklappen',
84
+ expandAll: 'Alle aufklappen',
85
+ collapseAll: 'Alle zuklappen',
82
86
  showLayer: 'Ebene einblenden',
83
87
  hideLayer: 'Ebene ausblenden',
84
88
  showObject: 'Objekt einblenden',