@selvajs/ui 6.0.0-beta.7 → 6.0.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/README.md +5 -7
- package/dist/components/compute/AppLayout.svelte +2 -8
- package/dist/components/compute/AppLayout.svelte.d.ts +0 -2
- package/dist/components/compute/ComputeApp.svelte +25 -43
- package/dist/components/compute/ComputeApp.svelte.d.ts +8 -24
- package/dist/components/compute/ParameterPresetManager.svelte +4 -9
- package/dist/components/compute/ParameterPresetManager.svelte.d.ts +1 -1
- package/dist/components/layout/AppShell.svelte +5 -13
- package/dist/components/layout/AppShell.svelte.d.ts +1 -2
- package/dist/components/layout/EmptyState.svelte +2 -3
- package/dist/components/layout/PageFooter.svelte +0 -4
- package/dist/components/layout/index.js +0 -1
- package/dist/components/preview/Group.svelte +2 -5
- package/dist/components/preview/ImageOutput.svelte +5 -4
- package/dist/components/preview/InputControl.svelte +27 -28
- package/dist/components/preview/InputControl.svelte.d.ts +3 -3
- package/dist/components/preview/OutputDisplay.svelte +3 -6
- package/dist/components/preview/TabLayout.svelte +3 -3
- package/dist/components/preview/inputs/FileInput.svelte +9 -12
- package/dist/components/primitives/field/field-error.svelte +1 -3
- package/dist/components/primitives/index.js +0 -1
- package/dist/components/viewer/SceneManager.svelte +9 -20
- package/dist/components/viewer/SceneManager.svelte.d.ts +3 -3
- package/dist/components/viewer/Viewer.svelte +36 -61
- package/dist/components/viewer/Viewer.svelte.d.ts +6 -19
- package/dist/composables/useFooterItem.svelte.d.ts +3 -4
- package/dist/composables/useFooterItem.svelte.js +3 -3
- package/dist/compute/solving.svelte.d.ts +6 -6
- package/dist/compute/solving.svelte.js +6 -6
- package/dist/compute/useSolveSession.svelte.d.ts +5 -7
- package/dist/compute/useSolveSession.svelte.js +11 -24
- package/dist/constants.js +4 -4
- package/dist/contexts/clientSlotContext.svelte.d.ts +3 -5
- package/dist/contexts/footerContext.svelte.d.ts +6 -8
- package/dist/external/storage.js +2 -3
- package/dist/i18n/localeContext.svelte.d.ts +5 -7
- package/dist/i18n/localeContext.svelte.js +8 -15
- package/dist/i18n/messages.d.ts +0 -2
- package/dist/i18n/messages.js +3 -8
- package/dist/index.js +7 -13
- package/dist/public.js +14 -30
- package/dist/schema/defaults.js +2 -3
- package/dist/schema/dynamic-value-list.js +13 -15
- package/dist/schema/param-exporter.d.ts +7 -14
- package/dist/schema/param-exporter.js +6 -12
- package/dist/schema/traversal.js +3 -4
- package/dist/schema/visibility-rules.d.ts +4 -8
- package/dist/schema/visibility-rules.js +4 -8
- package/dist/types/actionButton.d.ts +1 -25
- package/dist/types/presetLabels.d.ts +2 -3
- package/dist/utils/color.js +6 -9
- package/dist/utils/debounce.d.ts +1 -11
- package/dist/utils/debounce.js +1 -11
- package/dist/utils/file-download.js +4 -2
- package/dist/utils/loadScript.d.ts +2 -7
- package/dist/utils/loadScript.js +2 -7
- package/package.json +16 -16
- package/src/lib/components/compute/AppLayout.svelte +2 -8
- package/src/lib/components/compute/ComputeApp.svelte +25 -43
- package/src/lib/components/compute/ParameterPresetManager.svelte +4 -9
- package/src/lib/components/layout/AppShell.svelte +5 -13
- package/src/lib/components/layout/EmptyState.svelte +2 -3
- package/src/lib/components/layout/PageFooter.svelte +0 -4
- package/src/lib/components/layout/index.ts +0 -1
- package/src/lib/components/preview/Group.svelte +2 -5
- package/src/lib/components/preview/ImageOutput.svelte +5 -4
- package/src/lib/components/preview/InputControl.svelte +27 -28
- package/src/lib/components/preview/OutputDisplay.svelte +3 -6
- package/src/lib/components/preview/TabLayout.svelte +3 -3
- package/src/lib/components/preview/inputs/FileInput.svelte +9 -12
- package/src/lib/components/primitives/field/field-error.svelte +1 -3
- package/src/lib/components/primitives/index.ts +0 -2
- package/src/lib/components/viewer/SceneManager.svelte +9 -20
- package/src/lib/components/viewer/Viewer.svelte +36 -61
- package/src/lib/composables/useFooterItem.svelte.ts +3 -4
- package/src/lib/compute/solving.svelte.ts +6 -6
- package/src/lib/compute/useSolveSession.svelte.ts +11 -24
- package/src/lib/constants.ts +4 -4
- package/src/lib/contexts/clientSlotContext.svelte.ts +7 -12
- package/src/lib/contexts/footerContext.svelte.ts +6 -8
- package/src/lib/external/storage.ts +2 -3
- package/src/lib/i18n/localeContext.svelte.ts +8 -17
- package/src/lib/i18n/messages.ts +3 -10
- package/src/lib/index.ts +8 -13
- package/src/lib/public.ts +22 -40
- package/src/lib/schema/defaults.ts +2 -3
- package/src/lib/schema/dynamic-value-list.ts +13 -15
- package/src/lib/schema/param-exporter.ts +9 -16
- package/src/lib/schema/traversal.ts +3 -4
- package/src/lib/schema/visibility-rules.ts +4 -8
- package/src/lib/types/actionButton.ts +1 -25
- package/src/lib/types/presetLabels.ts +2 -3
- package/src/lib/utils/color.ts +6 -9
- package/src/lib/utils/debounce.ts +1 -11
- package/src/lib/utils/file-download.ts +4 -2
- package/src/lib/utils/loadScript.ts +2 -7
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
value?: SupportedTypes;
|
|
35
35
|
displayName?: string;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* `forceSolve` solves even in manual-solve mode. Used for system-initiated changes
|
|
38
|
+
* (e.g. pruning a vanished dynamic-list selection), where leaving the previous output
|
|
39
|
+
* on screen would misrepresent the input that now sits above it.
|
|
40
40
|
*/
|
|
41
41
|
onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
42
42
|
disabled?: boolean;
|
|
@@ -56,9 +56,8 @@
|
|
|
56
56
|
const inputId = $derived(`input-${item.paramId}`);
|
|
57
57
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// client slot source means presentation === 'slot'.
|
|
59
|
+
// The 'hidden' presentation never reaches here — visible:false filters it out
|
|
60
|
+
// upstream — so a client-sourced input here always means presentation === 'slot'.
|
|
62
61
|
const isClientSlot = $derived.by(() => {
|
|
63
62
|
const source = (item as { source?: { kind?: string; client?: { presentation?: string } } })
|
|
64
63
|
.source;
|
|
@@ -66,7 +65,7 @@
|
|
|
66
65
|
});
|
|
67
66
|
const clientSlot = getClientSlot();
|
|
68
67
|
|
|
69
|
-
//
|
|
68
|
+
// Min/max hint rendered next to the label. `hideRange` lets the author suppress it.
|
|
70
69
|
const numberRangeHint = $derived.by(() => {
|
|
71
70
|
if (!isNumberWidget(item)) return null;
|
|
72
71
|
const cfg = item.config;
|
|
@@ -81,8 +80,8 @@
|
|
|
81
80
|
|
|
82
81
|
const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
|
|
83
82
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
83
|
+
// Options from the last solve win over the author's seed list, so the control is empty
|
|
84
|
+
// until the first solve unless defaultOptions is set.
|
|
86
85
|
const dynamicListConfig = $derived(
|
|
87
86
|
isDynamicValueListWidget(item)
|
|
88
87
|
? (item.config as DynamicValueListWidgetConfig | undefined)
|
|
@@ -109,22 +108,22 @@
|
|
|
109
108
|
(dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
|
|
110
109
|
);
|
|
111
110
|
|
|
112
|
-
// When a dynamic value list recomputes,
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
111
|
+
// When a dynamic value list recomputes, the selected value may no longer be an option.
|
|
112
|
+
// Prune it so the control shows a valid option instead of rendering the orphaned raw
|
|
113
|
+
// value as its own label. The user didn't pick this, so force a solve — otherwise a
|
|
114
|
+
// manual-solve schema keeps the prior output on screen, making it look like the
|
|
115
|
+
// auto-picked option produced it.
|
|
117
116
|
//
|
|
118
|
-
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve.
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// is deliberately no "user cleared it, stay empty" path.
|
|
117
|
+
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. An
|
|
118
|
+
// empty selection reaches the definition as null/"", which throws NREs in downstream
|
|
119
|
+
// geometry components (e.g. Bounding Rectangle) and nulls every output beyond them.
|
|
120
|
+
// There is always at least one option, so every terminal state below resolves to one;
|
|
121
|
+
// there is deliberately no "user cleared it, stay empty" path.
|
|
123
122
|
//
|
|
124
|
-
// LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
|
|
125
|
-
// auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle
|
|
126
|
-
// solves and re-parses
|
|
127
|
-
//
|
|
123
|
+
// LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
|
|
124
|
+
// — auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle
|
|
125
|
+
// force-solves and re-parses the (possibly multi-MB) options, freezing the tab: every
|
|
126
|
+
// solve result blocks the main thread and the loop never yields. Bound consecutive
|
|
128
127
|
// system-initiated picks; any real user commit resets the budget.
|
|
129
128
|
const MAX_CONSECUTIVE_AUTO_PICKS = 3;
|
|
130
129
|
let autoPickCount = 0;
|
|
@@ -133,11 +132,11 @@
|
|
|
133
132
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
134
133
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
135
134
|
const firstOption = Object.values(dynamicListOptions)[0];
|
|
136
|
-
// Already valid → stable; refill the auto-pick budget and stop.
|
|
137
135
|
const isValid = Array.isArray(value)
|
|
138
136
|
? value.length > 0 && value.every((v) => typeof v === 'string' && validValues.has(v))
|
|
139
137
|
: typeof value === 'string' && value !== '' && validValues.has(value);
|
|
140
138
|
if (isValid) {
|
|
139
|
+
// Settled on a valid option — refill the budget.
|
|
141
140
|
autoPickCount = 0;
|
|
142
141
|
return;
|
|
143
142
|
}
|
|
@@ -151,21 +150,21 @@
|
|
|
151
150
|
}
|
|
152
151
|
if (Array.isArray(value)) {
|
|
153
152
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
154
|
-
// Empty
|
|
155
|
-
//
|
|
153
|
+
// Empty — never selected or fully pruned — falls back to the first option: a
|
|
154
|
+
// checklist that solves empty produces the same null cascade as a single value.
|
|
156
155
|
if (pruned.length !== value.length || value.length === 0) {
|
|
157
156
|
autoPickCount++;
|
|
158
157
|
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
159
158
|
}
|
|
160
159
|
} else if (typeof value !== 'string' || value === '' || !validValues.has(value)) {
|
|
161
|
-
//
|
|
160
|
+
// Same fallback for a single value: never-selected and stale both resolve, never clear.
|
|
162
161
|
autoPickCount++;
|
|
163
162
|
onChange(item.paramId, firstOption, true);
|
|
164
163
|
}
|
|
165
164
|
});
|
|
166
165
|
|
|
167
166
|
function commit(newValue: SupportedTypes) {
|
|
168
|
-
// A real user pick re-arms the
|
|
167
|
+
// A real user pick re-arms the auto-pick budget.
|
|
169
168
|
autoPickCount = 0;
|
|
170
169
|
value = newValue;
|
|
171
170
|
onChange(item.paramId, newValue);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { OutputLayoutItem } from '@selvajs/schemas';
|
|
3
|
-
import type
|
|
3
|
+
import { subFolderSegments, type FileData } from '@selvajs/compute/core';
|
|
4
4
|
import ChartOutput from './ChartOutput.svelte';
|
|
5
5
|
import ImageOutput from './ImageOutput.svelte';
|
|
6
6
|
import {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
|
|
24
24
|
let { item, value, displayName }: Props = $props();
|
|
25
25
|
|
|
26
|
-
// --- text/number state ---
|
|
27
26
|
let copied = $state(false);
|
|
28
27
|
let copyTimeout: ReturnType<typeof setTimeout>;
|
|
29
28
|
|
|
@@ -47,7 +46,6 @@
|
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
// --- file state ---
|
|
51
49
|
let downloading = $state(false);
|
|
52
50
|
let downloadError = $state<string | null>(null);
|
|
53
51
|
|
|
@@ -57,7 +55,6 @@
|
|
|
57
55
|
const fileCount = $derived(filesArray.length);
|
|
58
56
|
const totalSize = $derived(filesArray.reduce((sum, f) => sum + getBase64FileSize(f.data), 0));
|
|
59
57
|
|
|
60
|
-
// --- folder tree ---
|
|
61
58
|
type TreeNode =
|
|
62
59
|
| { type: 'file'; file: FileData }
|
|
63
60
|
| { type: 'folder'; name: string; children: SvelteMap<string, TreeNode> };
|
|
@@ -66,7 +63,7 @@
|
|
|
66
63
|
const root = new SvelteMap<string, TreeNode>();
|
|
67
64
|
for (let i = 0; i < files.length; i++) {
|
|
68
65
|
const file = files[i];
|
|
69
|
-
const parts = (file.subFolder
|
|
66
|
+
const parts = subFolderSegments(file.subFolder);
|
|
70
67
|
let current = root;
|
|
71
68
|
for (const part of parts) {
|
|
72
69
|
if (!current.has(part)) {
|
|
@@ -98,7 +95,7 @@
|
|
|
98
95
|
const fileTree = $derived(hasSubFolders ? buildTree(filesArray) : null);
|
|
99
96
|
|
|
100
97
|
function fullPath(f: FileData): string {
|
|
101
|
-
const folder = (f.subFolder
|
|
98
|
+
const folder = subFolderSegments(f.subFolder).join('/');
|
|
102
99
|
const name = `${f.fileName}${f.fileType ?? ''}`;
|
|
103
100
|
return folder ? `${folder}/${name}` : name;
|
|
104
101
|
}
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
// Computed value list options keyed by the target input id, derived from solved outputs.
|
|
37
37
|
const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
|
|
38
38
|
|
|
39
|
-
// Tab selection
|
|
40
39
|
$effect(() => {
|
|
41
40
|
if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
|
|
42
41
|
activeTabId = requestedTabId;
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
}
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
//
|
|
47
|
+
// Seed each group's collapsed state once; re-seeding would discard the user's own toggles.
|
|
49
48
|
$effect(() => {
|
|
50
49
|
if (schema.layout.type !== 'tabbed') return;
|
|
51
50
|
const initial: Record<string, boolean> = {};
|
|
@@ -57,7 +56,8 @@
|
|
|
57
56
|
if (Object.keys(initial).length > 0) Object.assign(collapsedGroups, initial);
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
//
|
|
59
|
+
// A hidden or disabled input still solves, so reset it to its default — otherwise the
|
|
60
|
+
// value the user last set while it was visible keeps feeding the definition.
|
|
61
61
|
$effect(() => {
|
|
62
62
|
if (schema.layout.type !== 'tabbed') return;
|
|
63
63
|
const updates: Record<string, unknown> = {};
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
onChange
|
|
22
22
|
}: Props = $props();
|
|
23
23
|
|
|
24
|
-
// Resolve which modes are actually available
|
|
25
24
|
let effectiveModes = $derived(
|
|
26
25
|
allowedInputModes && allowedInputModes.length > 0
|
|
27
26
|
? allowedInputModes
|
|
@@ -29,7 +28,6 @@
|
|
|
29
28
|
);
|
|
30
29
|
let showToggle = $derived(effectiveModes.length > 1);
|
|
31
30
|
|
|
32
|
-
// Active mode: default to defaultInputMode if allowed, otherwise first allowed mode
|
|
33
31
|
let activeMode = $state<'upload' | 'url'>('upload');
|
|
34
32
|
$effect(() => {
|
|
35
33
|
const preferred = defaultInputMode ?? 'upload';
|
|
@@ -44,7 +42,7 @@
|
|
|
44
42
|
let urlError = $state<{ message: string; isCors: boolean; hint?: string } | null>(null);
|
|
45
43
|
let urlSuccess = $state('');
|
|
46
44
|
|
|
47
|
-
//
|
|
45
|
+
// The committed value is a JSON envelope; rebuild the display state from it.
|
|
48
46
|
$effect(() => {
|
|
49
47
|
if (value) {
|
|
50
48
|
try {
|
|
@@ -79,8 +77,7 @@
|
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
function isCorsError(error: unknown): boolean {
|
|
82
|
-
//
|
|
83
|
-
// because the browser blocks the response entirely
|
|
80
|
+
// The browser blocks the response entirely, so this is all we get — no status code.
|
|
84
81
|
if (error instanceof TypeError && error.message === 'Failed to fetch') return true;
|
|
85
82
|
return false;
|
|
86
83
|
}
|
|
@@ -158,9 +155,8 @@
|
|
|
158
155
|
return '';
|
|
159
156
|
}
|
|
160
157
|
})();
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// Give a message that covers both cases.
|
|
158
|
+
// The opaque TypeError hides CORS blocks and plain network failures (404, DNS)
|
|
159
|
+
// alike, so the message has to cover both.
|
|
164
160
|
let hint =
|
|
165
161
|
'Check that the URL is correct and the file is publicly accessible. If the server requires login, download the file and use the Upload option instead.';
|
|
166
162
|
if (host.includes('sharepoint.com') || host.includes('onedrive.com')) {
|
|
@@ -201,10 +197,11 @@
|
|
|
201
197
|
return;
|
|
202
198
|
}
|
|
203
199
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
200
|
+
// The file is base64-embedded into the compute request body, so without a
|
|
201
|
+
// client-side cap an oversize file comes back as an opaque 413. This cap is
|
|
202
|
+
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
203
|
+
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
204
|
+
// path applies the same cap.
|
|
208
205
|
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
209
206
|
alert(
|
|
210
207
|
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
@@ -15,13 +15,11 @@
|
|
|
15
15
|
} = $props();
|
|
16
16
|
|
|
17
17
|
const hasContent = $derived.by(() => {
|
|
18
|
-
// has slotted error
|
|
19
18
|
if (children) return true;
|
|
20
19
|
|
|
21
|
-
// no errors
|
|
22
20
|
if (!errors || errors.length === 0) return false;
|
|
23
21
|
|
|
24
|
-
//
|
|
22
|
+
// A lone error with no message renders an empty alert box — suppress it.
|
|
25
23
|
if (errors.length === 1 && !errors[0]?.message) {
|
|
26
24
|
return false;
|
|
27
25
|
}
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
interface Props {
|
|
16
16
|
/**
|
|
17
|
-
* Owned by `Viewer.svelte`,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Owned by `Viewer.svelte`, never built here: this panel unmounts when it closes, and hidden
|
|
18
|
+
* objects must stay hidden — and be re-hidden after each solve — while it is. Backed by
|
|
19
|
+
* SvelteSets, so mutating one re-renders this list.
|
|
20
20
|
*/
|
|
21
21
|
outliner: SceneOutliner;
|
|
22
22
|
sceneVersion?: number;
|
|
@@ -24,24 +24,20 @@
|
|
|
24
24
|
|
|
25
25
|
let { outliner, sceneVersion = 0 }: Props = $props();
|
|
26
26
|
|
|
27
|
-
// All list logic (content filtering, layer grouping, search, visibility, selection) lives in
|
|
28
|
-
// @selvajs/visualization/scene. This component only renders it and forwards clicks.
|
|
29
27
|
// Derived, not destructured: the prop is reassignable, and these must follow it.
|
|
30
28
|
const hidden = $derived(outliner.visibility.hidden);
|
|
31
29
|
const selected = $derived(outliner.selection.selected);
|
|
32
30
|
const collapsed = $derived(outliner.collapsed);
|
|
33
31
|
|
|
34
|
-
// Mirrored into runes because the outliner holds
|
|
35
|
-
//
|
|
36
|
-
// them, while hiding and collapse persist in the outliner that outlives it.
|
|
32
|
+
// Mirrored into runes because the outliner holds these as plain fields, not sets. Both are panel
|
|
33
|
+
// state, so reopening clears them — unlike hiding and collapse, which the outliner outlives.
|
|
37
34
|
let searchQuery = $state('');
|
|
38
35
|
let anchor = $state<string | null>(null);
|
|
39
36
|
|
|
40
37
|
$effect(() => outliner.onAnchorChange((next) => (anchor = next)));
|
|
41
38
|
|
|
42
39
|
// `scene.children` is a plain array the render layer mutates in place, so a solve bumping
|
|
43
|
-
// `sceneVersion` is the only signal that content changed
|
|
44
|
-
// is what makes one walk per solve serve every template site that needs the list.
|
|
40
|
+
// `sceneVersion` is the only signal that content changed — hence the `void` reads below.
|
|
45
41
|
const sceneObjects = $derived.by(() => {
|
|
46
42
|
void sceneVersion;
|
|
47
43
|
return outliner.objects();
|
|
@@ -54,12 +50,9 @@
|
|
|
54
50
|
return outliner.layerGroups();
|
|
55
51
|
});
|
|
56
52
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// re-renders this row
|
|
60
|
-
// `SvelteSet.has()` is itself the reactive read, so go through the set rather than calling
|
|
61
|
-
// `visibility.isHidden` — that reads the set through a plain reference inside the outliner and
|
|
62
|
-
// would not re-render this row.
|
|
53
|
+
// `SvelteSet.has()` is the reactive read, so go through the set rather than calling
|
|
54
|
+
// `visibility.isHidden` — that reaches the set through a plain reference inside the outliner
|
|
55
|
+
// and returns a correct value that never re-renders this row.
|
|
63
56
|
const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
|
|
64
57
|
|
|
65
58
|
// Same reason: count through the reactive set so the layer's tri-state eye tracks its objects.
|
|
@@ -165,7 +158,6 @@
|
|
|
165
158
|
onkeydown={(e) =>
|
|
166
159
|
e.key === 'Enter' && selectObject(object.uuid, e as unknown as MouseEvent)}
|
|
167
160
|
>
|
|
168
|
-
<!-- Object visibility toggle -->
|
|
169
161
|
<button
|
|
170
162
|
class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
|
|
171
163
|
onclick={(e) => {
|
|
@@ -182,7 +174,6 @@
|
|
|
182
174
|
{/if}
|
|
183
175
|
</button>
|
|
184
176
|
|
|
185
|
-
<!-- Object name -->
|
|
186
177
|
<span
|
|
187
178
|
class="min-w-0 text-xs flex-1 truncate {isHidden
|
|
188
179
|
? 'text-muted-foreground line-through'
|
|
@@ -191,7 +182,6 @@
|
|
|
191
182
|
{getObjectLabel(object)}
|
|
192
183
|
</span>
|
|
193
184
|
|
|
194
|
-
<!-- Type badge -->
|
|
195
185
|
<span
|
|
196
186
|
class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
|
|
197
187
|
>
|
|
@@ -203,7 +193,6 @@
|
|
|
203
193
|
{/if}
|
|
204
194
|
{/each}
|
|
205
195
|
|
|
206
|
-
<!-- Empty state -->
|
|
207
196
|
{#if sceneObjects.length === 0}
|
|
208
197
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
209
198
|
<EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
@@ -46,12 +46,7 @@
|
|
|
46
46
|
showFullscreenButton?: boolean;
|
|
47
47
|
showSceneManager?: boolean;
|
|
48
48
|
showToolsMenu?: boolean;
|
|
49
|
-
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
50
49
|
showGridToggle?: boolean;
|
|
51
|
-
/**
|
|
52
|
-
* Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
|
|
53
|
-
* Defaults on. Starts on the 'technical' style with edges hidden.
|
|
54
|
-
*/
|
|
55
50
|
showDisplayMenu?: boolean;
|
|
56
51
|
enableMeshClick?: boolean;
|
|
57
52
|
backgroundColor?: string;
|
|
@@ -64,26 +59,18 @@
|
|
|
64
59
|
isBlurred?: boolean;
|
|
65
60
|
drawerOpen?: boolean;
|
|
66
61
|
viewerConfig?: ViewerConfig;
|
|
67
|
-
/**
|
|
68
|
-
* Branding logo URL. When set, shown as a small watermark in the viewer's
|
|
69
|
-
* bottom-right corner. Omitted/empty renders nothing.
|
|
70
|
-
*/
|
|
62
|
+
/** Shown as a watermark in the bottom-right corner. */
|
|
71
63
|
logoUrl?: string;
|
|
72
64
|
/**
|
|
73
65
|
* Hands the live three.js viewer to the host once the canvas is up, for apps drawing their
|
|
74
|
-
* own content
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* Return a cleanup function to tear down what you added; it runs before the viewer disposes.
|
|
78
|
-
* Anything added outside a solve needs `viewer.invalidate()` to repaint — the render loop is
|
|
79
|
-
* on-demand.
|
|
66
|
+
* own content. Return a cleanup function to tear down what you added; it runs before the
|
|
67
|
+
* viewer disposes. Anything added outside a solve needs `viewer.invalidate()` to repaint —
|
|
68
|
+
* the render loop is on-demand.
|
|
80
69
|
*/
|
|
81
70
|
onViewerReady?: (viewer: ThreeViewer) => void | (() => void);
|
|
82
71
|
/**
|
|
83
|
-
* UI language for the viewer's own chrome
|
|
84
|
-
*
|
|
85
|
-
* reads the nearest locale context (set by the host app), defaulting to
|
|
86
|
-
* English. Does not translate Grasshopper-sourced names/metadata.
|
|
72
|
+
* UI language for the viewer's own chrome. Omitted, the viewer reads the nearest locale
|
|
73
|
+
* context. Does not translate Grasshopper-sourced names/metadata.
|
|
87
74
|
*/
|
|
88
75
|
lang?: Locale;
|
|
89
76
|
}
|
|
@@ -113,13 +100,10 @@
|
|
|
113
100
|
|
|
114
101
|
const config = $derived({ ...defaultViewerConfig, ...viewerConfig });
|
|
115
102
|
|
|
116
|
-
// Read
|
|
103
|
+
// Read the host's locale before overriding it for our subtree.
|
|
117
104
|
const hostLocale = getLocaleContext();
|
|
118
105
|
|
|
119
|
-
//
|
|
120
|
-
// Provide the resolved value to our subtree so the scene manager and metadata
|
|
121
|
-
// dialog read the same locale. The getter is re-read reactively, so switching
|
|
122
|
-
// the `lang` prop (or the host's locale) updates the chrome live.
|
|
106
|
+
// A getter, not a value: it is re-read reactively, so changing `lang` updates the chrome live.
|
|
123
107
|
setLocaleContext(() => lang ?? hostLocale.locale);
|
|
124
108
|
const locale = getLocaleContext();
|
|
125
109
|
const t = $derived(locale.messages);
|
|
@@ -142,10 +126,8 @@
|
|
|
142
126
|
let hideButton = $state(false);
|
|
143
127
|
let sceneManagerOpen = $state(false);
|
|
144
128
|
|
|
145
|
-
// The
|
|
146
|
-
//
|
|
147
|
-
// closed — and must be re-hidden after each solve, which nothing would do if the state
|
|
148
|
-
// unmounted with the panel.
|
|
129
|
+
// The outliner lives here, not in <SceneManager>: that component mounts only while its panel is
|
|
130
|
+
// open, and hidden objects must stay hidden — and be re-hidden after each solve — while it is closed.
|
|
149
131
|
const hiddenObjects = new SvelteSet<string>();
|
|
150
132
|
const selectedObjects = new SvelteSet<string>();
|
|
151
133
|
const collapsedLayers = new SvelteSet<string>();
|
|
@@ -153,15 +135,12 @@
|
|
|
153
135
|
let projection: CameraProjection = $state('perspective');
|
|
154
136
|
let measureActive = $state(false);
|
|
155
137
|
let gridVisible = $state(false);
|
|
156
|
-
// Render style + edge overlays. 'technical' is the default look; edges (crease lines) start off —
|
|
157
|
-
// both are user-switchable via the Display submenu.
|
|
158
138
|
let renderStyle: Look = $state('technical');
|
|
159
139
|
let edgesVisible = $state(false);
|
|
160
140
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
161
141
|
let selectedMeshName: string | null = $state(null);
|
|
162
142
|
|
|
163
|
-
//
|
|
164
|
-
// renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
|
|
143
|
+
// Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit.
|
|
165
144
|
const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
|
|
166
145
|
look,
|
|
167
146
|
label: look.charAt(0).toUpperCase() + look.slice(1)
|
|
@@ -192,16 +171,13 @@
|
|
|
192
171
|
onMount(() => {
|
|
193
172
|
if (!canvas) return;
|
|
194
173
|
|
|
195
|
-
// Only
|
|
196
|
-
//
|
|
197
|
-
// switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
|
|
198
|
-
// lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
|
|
174
|
+
// Only what differs from the library defaults. The sun and shadows are off because the
|
|
175
|
+
// technical look doesn't need them — flat ambient plus the HDR environment carry it.
|
|
199
176
|
const opts: ThreeInitializerOptions = {
|
|
200
177
|
look: renderStyle,
|
|
201
178
|
lighting: { enableSunlight: false },
|
|
202
179
|
render: { enableShadows: false },
|
|
203
180
|
environment: { backgroundColor: config.backgroundColor },
|
|
204
|
-
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
205
181
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
206
182
|
measure: { enabled: config.showToolsMenu },
|
|
207
183
|
events: {
|
|
@@ -237,8 +213,7 @@
|
|
|
237
213
|
|
|
238
214
|
const renderer = init.renderer;
|
|
239
215
|
|
|
240
|
-
// Untracked
|
|
241
|
-
// inside it must not re-run onMount's teardown.
|
|
216
|
+
// Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
|
|
242
217
|
const hostCleanup = untrack(() => onViewerReady?.(init));
|
|
243
218
|
|
|
244
219
|
return () => {
|
|
@@ -280,9 +255,8 @@
|
|
|
280
255
|
setLook(look);
|
|
281
256
|
}
|
|
282
257
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// it also cancels in-flight attaches and stands down the screen-space fallback for capped meshes.
|
|
258
|
+
// `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
|
|
259
|
+
// overlays; `clearEdges` is its inverse.
|
|
286
260
|
function applyEdgeState() {
|
|
287
261
|
if (!scene) return;
|
|
288
262
|
if (edgesVisible) applyEdges?.(scene);
|
|
@@ -297,18 +271,18 @@
|
|
|
297
271
|
$effect(() => {
|
|
298
272
|
if (scene && camera && controls) {
|
|
299
273
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
|
|
274
|
+
// Untracked because toggleEdges() already handles the toggle directly — reading
|
|
275
|
+
// `edgesVisible` tracked here would re-trigger a full solve.
|
|
303
276
|
untrack(() => {
|
|
277
|
+
// updateScene discarded the previous solve's overlays along with its content.
|
|
304
278
|
if (edgesVisible) applyEdges?.(scene!);
|
|
305
|
-
// Rescale the grid
|
|
279
|
+
// Rescale the grid so cells and fade match the new content's extent.
|
|
306
280
|
updateGridScale?.();
|
|
307
|
-
// The rebuild
|
|
308
|
-
//
|
|
281
|
+
// The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
|
|
282
|
+
// identity, not on the instances just discarded, so it can re-hide it.
|
|
309
283
|
outliner?.applyTo();
|
|
310
284
|
sceneVersion++;
|
|
311
|
-
//
|
|
285
|
+
// Repaint now rather than on the render loop's safety interval.
|
|
312
286
|
invalidate?.();
|
|
313
287
|
});
|
|
314
288
|
|
|
@@ -373,11 +347,19 @@
|
|
|
373
347
|
<!-- `defaultSize` must sum to 100 across the live panes. Panes register a frame before the
|
|
374
348
|
group recomputes its layout, so a sum of 115 renders one frame at the raw flex-grow ratio
|
|
375
349
|
and is then renormalized — and if the recompute short-circuits on an equal layout, the
|
|
376
|
-
scene pane keeps a sliver of its intended width. Hence 85 + 15, and the explicit
|
|
377
|
-
|
|
350
|
+
scene pane keeps a sliver of its intended width. Hence 85 + 15, and the explicit id/order
|
|
351
|
+
so a conditionally-rendered pane keeps its slot. -->
|
|
378
352
|
<Resizable.Pane id="viewport" order={1} defaultSize={sceneManagerOpen ? 85 : 100} minSize={40}>
|
|
379
353
|
<div class="relative h-full w-full" style="touch-action: none;">
|
|
380
|
-
|
|
354
|
+
<!-- Mesh count of the geometry currently in the scene. The canvas itself is opaque to
|
|
355
|
+
the DOM, so this is the only observable proof a solve's geometry decoded and
|
|
356
|
+
rendered; e2e asserts on it. -->
|
|
357
|
+
<canvas
|
|
358
|
+
class="block h-full w-full"
|
|
359
|
+
data-testid="viewer-canvas"
|
|
360
|
+
data-mesh-count={meshes.length}
|
|
361
|
+
bind:this={canvas}
|
|
362
|
+
></canvas>
|
|
381
363
|
|
|
382
364
|
<div
|
|
383
365
|
class="inset-0 blur-overlay absolute z-20 {isBlurred
|
|
@@ -392,9 +374,8 @@
|
|
|
392
374
|
{/if}
|
|
393
375
|
|
|
394
376
|
{#if logoUrl}
|
|
395
|
-
<!--
|
|
396
|
-
|
|
397
|
-
non-interactive so it never intercepts canvas drags. -->
|
|
377
|
+
<!-- Bottom offset matches the tools menu so it clears the mobile drawer handle.
|
|
378
|
+
Always pointer-events:none so it never intercepts canvas drags. -->
|
|
398
379
|
<div
|
|
399
380
|
class="right-4 {isFullscreen ? 'bottom-4' : 'bottom-16 sm:bottom-4'} absolute z-20"
|
|
400
381
|
style={hideButton
|
|
@@ -434,7 +415,6 @@
|
|
|
434
415
|
align="start"
|
|
435
416
|
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 min-w-44 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
|
|
436
417
|
>
|
|
437
|
-
<!-- Camera -->
|
|
438
418
|
<DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
|
|
439
419
|
{#if projection === 'perspective'}
|
|
440
420
|
<Square class="h-4 w-4" />
|
|
@@ -482,7 +462,6 @@
|
|
|
482
462
|
sideOffset={4}
|
|
483
463
|
class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
|
|
484
464
|
>
|
|
485
|
-
<!-- Render style: single-choice, current one checked. -->
|
|
486
465
|
{#each STYLE_OPTIONS as { look, label } (look)}
|
|
487
466
|
<DropdownMenu.Item
|
|
488
467
|
closeOnSelect={false}
|
|
@@ -498,7 +477,6 @@
|
|
|
498
477
|
|
|
499
478
|
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
500
479
|
|
|
501
|
-
<!-- Edges overlay toggle. -->
|
|
502
480
|
<DropdownMenu.Item
|
|
503
481
|
closeOnSelect={false}
|
|
504
482
|
class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
|
|
@@ -540,7 +518,6 @@
|
|
|
540
518
|
</DropdownMenu.Item>
|
|
541
519
|
{/if}
|
|
542
520
|
|
|
543
|
-
<!-- Scene tools -->
|
|
544
521
|
{#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
|
|
545
522
|
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
546
523
|
{/if}
|
|
@@ -585,7 +562,6 @@
|
|
|
585
562
|
</div>
|
|
586
563
|
</Resizable.Pane>
|
|
587
564
|
|
|
588
|
-
<!-- Scene Manager Pane -->
|
|
589
565
|
{#if sceneManagerOpen && scene && outliner}
|
|
590
566
|
<Resizable.Handle withHandle />
|
|
591
567
|
<Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
|
|
@@ -618,7 +594,6 @@
|
|
|
618
594
|
border-radius: 0 !important;
|
|
619
595
|
}
|
|
620
596
|
|
|
621
|
-
/* Blur overlay animation */
|
|
622
597
|
.blur-overlay {
|
|
623
598
|
pointer-events: none;
|
|
624
599
|
}
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
FOOTER_CONTEXT_KEY
|
|
6
6
|
} from '$lib/contexts/footerContext.svelte';
|
|
7
7
|
|
|
8
|
-
/** Config for a footer-item registration; generic over the component's props. */
|
|
9
8
|
export type FooterItemConfig<P extends Record<string, unknown> = Record<string, unknown>> = Omit<
|
|
10
9
|
FooterItem<P>,
|
|
11
10
|
'position' | 'priority'
|
|
@@ -15,9 +14,9 @@ export type FooterItemConfig<P extends Record<string, unknown> = Record<string,
|
|
|
15
14
|
/**
|
|
16
15
|
* Register a footer item for the lifetime of the calling component.
|
|
17
16
|
*
|
|
18
|
-
* Registration
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Registration waits for onMount so it never runs during SSR, where reading the context
|
|
18
|
+
* throws. A no-op when no footer context is present (a component rendered outside the root
|
|
19
|
+
* layout) or no component is supplied.
|
|
21
20
|
*/
|
|
22
21
|
export function useFooterItem<P extends Record<string, unknown>>(config: FooterItemConfig<P>) {
|
|
23
22
|
onMount(() => {
|