@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.
- package/dist/components/viewer/MeshMetadataDialog.svelte +1 -1
- package/dist/components/viewer/SceneManager.svelte +253 -98
- package/dist/components/viewer/Viewer.svelte +88 -14
- package/dist/i18n/messages.d.ts +4 -2
- package/dist/i18n/messages.js +8 -4
- package/package.json +14 -14
- package/src/lib/components/viewer/MeshMetadataDialog.svelte +1 -1
- package/src/lib/components/viewer/SceneManager.svelte +253 -98
- package/src/lib/components/viewer/Viewer.svelte +88 -14
- package/src/lib/i18n/messages.ts +12 -6
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
39
|
-
outliner.
|
|
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.
|
|
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
|
|
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
|
|
74
|
-
const hiddenCount = (
|
|
75
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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={(
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
187
|
-
<EyeOff class="h-3 w-3 text-muted-foreground/
|
|
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 {
|
|
195
|
-
? 'text-muted-foreground line-through'
|
|
196
|
-
: 'text-foreground
|
|
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
|
-
{
|
|
302
|
+
{row.layerName}
|
|
199
303
|
</span>
|
|
200
304
|
|
|
201
|
-
<span
|
|
202
|
-
|
|
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
|
-
{
|
|
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
|
-
{/
|
|
210
|
-
|
|
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:
|
|
122
|
-
let clearEdges:
|
|
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.
|
|
180
|
-
//
|
|
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
|
-
'
|
|
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.
|
|
497
|
+
{t.switchToOrthographic}
|
|
424
498
|
{:else}
|
|
425
499
|
<Box class="h-4 w-4" />
|
|
426
|
-
{t.
|
|
500
|
+
{t.switchToPerspective}
|
|
427
501
|
{/if}
|
|
428
502
|
</DropdownMenu.Item>
|
|
429
503
|
|
package/dist/i18n/messages.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export type Locale = 'en' | 'de';
|
|
2
2
|
export interface ViewerMessages {
|
|
3
3
|
toolsMenu: string;
|
|
4
|
-
|
|
5
|
-
|
|
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;
|
package/dist/i18n/messages.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// cannot be translated here.
|
|
8
8
|
const en = {
|
|
9
9
|
toolsMenu: 'Viewer tools',
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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',
|