@selvajs/ui 6.2.0 → 6.3.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.
@@ -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,128 @@
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
+ // Prefixed, like the object rows below: a layer row must never share a key with
138
+ // an object row whose identity happens to read like a layer name.
139
+ key: `layer:${layerName}`,
140
+ top,
141
+ height: LAYER_ROW_HEIGHT,
142
+ layerName,
143
+ entries
144
+ });
145
+ top += LAYER_ROW_HEIGHT;
146
+ if (collapsed.has(layerName)) continue;
147
+ for (const entry of entries) {
148
+ flat.push({
149
+ kind: 'object',
150
+ key: `obj:${entry.rowKey}`,
151
+ top,
152
+ height: OBJECT_ROW_HEIGHT,
153
+ entry
154
+ });
155
+ top += OBJECT_ROW_HEIGHT;
156
+ }
157
+ }
158
+ return flat;
159
+ });
160
+
161
+ const totalHeight = $derived(
162
+ rows.length === 0 ? 0 : rows[rows.length - 1]!.top + rows[rows.length - 1]!.height
163
+ );
164
+
165
+ let scrollTop = $state(0);
166
+ let viewportHeight = $state(0);
167
+
168
+ /** First row index whose bottom edge is at or past `offset`. Rows are ordered by `top`. */
169
+ const findRowAt = (offset: number) => {
170
+ let low = 0;
171
+ let high = rows.length - 1;
172
+ while (low < high) {
173
+ const mid = (low + high) >> 1;
174
+ if (rows[mid]!.top + rows[mid]!.height <= offset) low = mid + 1;
175
+ else high = mid;
176
+ }
177
+ return low;
178
+ };
179
+
180
+ const visibleRows = $derived.by(() => {
181
+ if (rows.length === 0) return [];
182
+ // Before the first measurement `viewportHeight` is 0; render a screenful so the list is
183
+ // never briefly empty.
184
+ const height = viewportHeight || 600;
185
+ const start = Math.max(0, findRowAt(scrollTop) - OVERSCAN);
186
+ const end = Math.min(rows.length, findRowAt(scrollTop + height) + 1 + OVERSCAN);
187
+ return rows.slice(start, end);
66
188
  });
67
189
 
68
190
  // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
69
191
  // `visibility.isHidden` — that reaches the set through a plain reference inside the outliner
70
192
  // and returns a correct value that never re-renders this row.
71
- const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
193
+ const isEntryHidden = (entry: SceneEntry) =>
194
+ entry.memberIndex === null
195
+ ? getMemberKeys(entry.object).every((key) => hidden.has(key))
196
+ : hidden.has(entry.key);
72
197
 
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;
198
+ // Same reason: count through the reactive set so the layer's tri-state eye tracks its entries.
199
+ const hiddenCount = (entries: SceneEntry[]) =>
200
+ entries.filter((entry) => isEntryHidden(entry)).length;
76
201
 
77
202
  // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
78
203
  const selectObject = (uuid: string, event: MouseEvent) => {
@@ -104,110 +229,142 @@
104
229
  />
105
230
  </button>
106
231
  {/if}
232
+
233
+ <button
234
+ class="shrink-0"
235
+ onclick={() => (allCollapsed ? expandAll() : collapseAll())}
236
+ title={allCollapsed ? t.expandAll : t.collapseAll}
237
+ aria-label={allCollapsed ? t.expandAll : t.collapseAll}
238
+ >
239
+ {#if allCollapsed}
240
+ <ChevronsUpDown
241
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
242
+ />
243
+ {:else}
244
+ <ChevronsDownUp
245
+ class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
246
+ />
247
+ {/if}
248
+ </button>
107
249
  </div>
108
250
 
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>
251
+ <div
252
+ class="py-1 flex-1 overflow-y-auto"
253
+ bind:clientHeight={viewportHeight}
254
+ onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
255
+ >
256
+ <!-- Spacer sized to the full list; windowed rows are placed into it by offset. The listbox
257
+ sits here rather than per layer: windowing renders one flat row list, so every option
258
+ shares a single owner. -->
259
+ <div role="listbox" aria-multiselectable="true" class="relative" style:height="{totalHeight}px">
260
+ {#each visibleRows as row (row.key)}
261
+ <div class="inset-x-0 absolute" style:top="{row.top}px" style:height="{row.height}px">
262
+ {#if row.kind === 'layer'}
263
+ {@const entries = row.entries}
264
+ {@const numHidden = hiddenCount(entries)}
265
+ {@const layerHidden = entries.length > 0 && numHidden === entries.length}
266
+ {@const layerPartial = numHidden > 0 && numHidden < entries.length}
267
+ {@const isCollapsed = collapsed.has(row.layerName)}
154
268
 
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
269
  <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)}
270
+ class="gap-1 pl-1 pr-2 group flex h-full items-center transition-colors hover:bg-muted"
176
271
  >
272
+ <button
273
+ class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
274
+ onclick={() => outliner.toggleCollapsed(row.layerName)}
275
+ aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
276
+ >
277
+ <ChevronRight
278
+ class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed
279
+ ? ''
280
+ : 'rotate-90'}"
281
+ />
282
+ </button>
283
+
177
284
  <button
178
285
  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}
286
+ onclick={() => toggleLayer(entries)}
287
+ title={layerHidden ? t.showLayer : t.hideLayer}
288
+ aria-label={layerHidden ? t.showLayer : t.hideLayer}
185
289
  >
186
- {#if isHidden}
187
- <EyeOff class="h-3 w-3 text-muted-foreground/60" />
290
+ {#if layerHidden}
291
+ <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
292
+ {:else if layerPartial}
293
+ <Eye class="h-3.5 w-3.5 text-muted-foreground/60" />
188
294
  {:else}
189
- <Eye class="h-3 w-3 text-muted-foreground" />
295
+ <Eye class="h-3.5 w-3.5 text-muted-foreground" />
190
296
  {/if}
191
297
  </button>
192
298
 
193
299
  <span
194
- class="min-w-0 text-xs flex-1 truncate {isHidden
195
- ? 'text-muted-foreground line-through'
196
- : 'text-foreground/80'}"
300
+ class="min-w-0 text-xs font-medium flex-1 truncate {layerHidden
301
+ ? 'text-muted-foreground/40 line-through'
302
+ : 'text-foreground'}"
197
303
  >
198
- {getObjectLabel(object)}
304
+ {row.layerName}
199
305
  </span>
200
306
 
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)}
307
+ <span class="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
308
+ {entries.length}
205
309
  </span>
206
310
  </div>
207
- {/each}
311
+ {:else}
312
+ {@const entry = row.entry}
313
+ <!-- Both keyed by the entry's stable identity: a merged mesh holds many entries, so
314
+ the object's uuid cannot tell its members apart. -->
315
+ {@const isHidden = isEntryHidden(entry)}
316
+ {@const isSelected = selected.has(entry.key)}
317
+ <div class="ml-3 h-full border-l border-border">
318
+ <div
319
+ role="option"
320
+ aria-selected={isSelected}
321
+ tabindex="-1"
322
+ class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
323
+ {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
324
+ {isHidden ? 'opacity-40' : ''}"
325
+ onmousedown={(e) => {
326
+ e.stopPropagation();
327
+ if (e.shiftKey) e.preventDefault();
328
+ }}
329
+ onclick={(e) => selectObject(entry.key, e)}
330
+ onkeydown={(e) =>
331
+ e.key === 'Enter' && selectObject(entry.key, e as unknown as MouseEvent)}
332
+ >
333
+ <button
334
+ class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
335
+ onclick={(e) => {
336
+ e.stopPropagation();
337
+ toggleEntry(entry);
338
+ }}
339
+ title={isHidden ? t.showObject : t.hideObject}
340
+ aria-label={isHidden ? t.showObject : t.hideObject}
341
+ >
342
+ {#if isHidden}
343
+ <EyeOff class="h-3 w-3 text-muted-foreground/60" />
344
+ {:else}
345
+ <Eye class="h-3 w-3 text-muted-foreground" />
346
+ {/if}
347
+ </button>
348
+
349
+ <span
350
+ class="min-w-0 text-xs flex-1 truncate {isHidden
351
+ ? 'text-muted-foreground line-through'
352
+ : 'text-foreground/80'}"
353
+ >
354
+ {entry.label}
355
+ </span>
356
+
357
+ <span
358
+ class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
359
+ >
360
+ {getTypeLabel(entry.object)}
361
+ </span>
362
+ </div>
363
+ </div>
364
+ {/if}
208
365
  </div>
209
- {/if}
210
- {/each}
366
+ {/each}
367
+ </div>
211
368
 
212
369
  {#if sceneObjects.length === 0}
213
370
  <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',