@selvajs/ui 4.6.2-beta.1 → 4.7.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/compute/AppLayout.svelte +1 -1
- package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
- package/dist/components/compute/ComputeApp.svelte +2 -2
- package/dist/components/preview/InputControl.svelte +20 -4
- package/dist/components/preview/InputControl.svelte.d.ts +6 -1
- package/dist/components/preview/TabContent.svelte +1 -1
- package/dist/components/preview/TabContent.svelte.d.ts +1 -1
- package/dist/components/preview/TabLayout.svelte +1 -1
- package/dist/components/preview/TabLayout.svelte.d.ts +1 -1
- package/dist/components/viewer/SceneManager.svelte +8 -1
- package/dist/components/viewer/Viewer.svelte +32 -2
- package/dist/components/viewer/Viewer.svelte.d.ts +2 -0
- package/dist/compute/createSolveSession.svelte.d.ts +6 -2
- package/dist/compute/createSolveSession.svelte.js +8 -2
- package/package.json +5 -5
- package/src/lib/components/compute/AppLayout.svelte +1 -1
- package/src/lib/components/compute/ComputeApp.svelte +2 -2
- package/src/lib/components/preview/InputControl.svelte +20 -4
- package/src/lib/components/preview/TabContent.svelte +1 -1
- package/src/lib/components/preview/TabLayout.svelte +1 -1
- package/src/lib/components/viewer/SceneManager.svelte +8 -1
- package/src/lib/components/viewer/Viewer.svelte +32 -2
- package/src/lib/compute/createSolveSession.svelte.ts +15 -4
- package/src/lib/compute/createSolveSession.test.ts +70 -0
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
isViewerFullscreen?: boolean;
|
|
25
25
|
oncalculate?: () => void;
|
|
26
26
|
values: Record<string, unknown>;
|
|
27
|
-
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
27
|
+
onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
|
|
28
28
|
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
29
29
|
panelActions?: ActionButton[];
|
|
30
30
|
showSaveButton?: boolean;
|
|
@@ -12,7 +12,7 @@ interface Props {
|
|
|
12
12
|
isViewerFullscreen?: boolean;
|
|
13
13
|
oncalculate?: () => void;
|
|
14
14
|
values: Record<string, unknown>;
|
|
15
|
-
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
15
|
+
onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
|
|
16
16
|
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
17
17
|
panelActions?: ActionButton[];
|
|
18
18
|
showSaveButton?: boolean;
|
|
@@ -133,8 +133,8 @@
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
function handleValueChange(id: string, val: unknown) {
|
|
137
|
-
session.setValue(id, val);
|
|
136
|
+
function handleValueChange(id: string, val: unknown, forceSolve?: boolean) {
|
|
137
|
+
session.setValue(id, val, forceSolve);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
function handleCalculate() {
|
|
@@ -33,7 +33,12 @@
|
|
|
33
33
|
item: InputLayoutItem;
|
|
34
34
|
value?: SupportedTypes;
|
|
35
35
|
displayName?: string;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
|
|
38
|
+
* system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
|
|
39
|
+
* leaving the previous output on screen would misrepresent the now-changed input.
|
|
40
|
+
*/
|
|
41
|
+
onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
37
42
|
disabled?: boolean;
|
|
38
43
|
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
39
44
|
dynamicOptions?: Record<string, string>;
|
|
@@ -108,7 +113,10 @@
|
|
|
108
113
|
|
|
109
114
|
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
110
115
|
// available option. Prune the stale selection so the control shows a valid option (or empty)
|
|
111
|
-
// instead of rendering the orphaned raw value as its own label.
|
|
116
|
+
// instead of rendering the orphaned raw value as its own label. This is a system-initiated
|
|
117
|
+
// change (the user didn't pick the new option), so force a solve — otherwise manual-solve
|
|
118
|
+
// schemas would keep the prior output on screen, making it look like the auto-picked option
|
|
119
|
+
// produced it.
|
|
112
120
|
$effect(() => {
|
|
113
121
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
114
122
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
@@ -116,9 +124,17 @@
|
|
|
116
124
|
// directly from an effect trips Svelte's binding-ownership check.
|
|
117
125
|
if (Array.isArray(value)) {
|
|
118
126
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
119
|
-
if (pruned.length !== value.length)
|
|
127
|
+
if (pruned.length !== value.length) {
|
|
128
|
+
// Fall back to first option when checklist becomes fully empty after pruning
|
|
129
|
+
onChange(
|
|
130
|
+
item.paramId,
|
|
131
|
+
pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
|
|
132
|
+
true
|
|
133
|
+
);
|
|
134
|
+
}
|
|
120
135
|
} else if (typeof value === 'string' && value && !validValues.has(value)) {
|
|
121
|
-
|
|
136
|
+
// Fall back to first available option instead of clearing to empty
|
|
137
|
+
onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
|
|
122
138
|
}
|
|
123
139
|
});
|
|
124
140
|
|
|
@@ -3,7 +3,12 @@ interface Props {
|
|
|
3
3
|
item: InputLayoutItem;
|
|
4
4
|
value?: SupportedTypes;
|
|
5
5
|
displayName?: string;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
|
|
8
|
+
* system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
|
|
9
|
+
* leaving the previous output on screen would misrepresent the now-changed input.
|
|
10
|
+
*/
|
|
11
|
+
onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
7
12
|
disabled?: boolean;
|
|
8
13
|
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
9
14
|
dynamicOptions?: Record<string, string>;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
values: Record<string, unknown>;
|
|
21
21
|
collapsedGroups: Record<string, boolean>;
|
|
22
22
|
onToggleGroup: (groupId: string) => void;
|
|
23
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
23
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
24
24
|
inputs: SchemaInput[];
|
|
25
25
|
outputs: DiscoveredOutput[];
|
|
26
26
|
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
@@ -4,7 +4,7 @@ interface Props {
|
|
|
4
4
|
values: Record<string, unknown>;
|
|
5
5
|
collapsedGroups: Record<string, boolean>;
|
|
6
6
|
onToggleGroup: (groupId: string) => void;
|
|
7
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
7
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
8
8
|
inputs: SchemaInput[];
|
|
9
9
|
outputs: DiscoveredOutput[];
|
|
10
10
|
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
interface Props {
|
|
11
11
|
schema: UISchema;
|
|
12
12
|
values: Record<string, unknown>;
|
|
13
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
13
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
14
14
|
/** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
|
|
15
15
|
panelFilter?: 'left' | 'right';
|
|
16
16
|
/** Externally request a specific tab to be active (e.g. from collapsed strip click) */
|
|
@@ -2,7 +2,7 @@ import type { UISchema, SupportedTypes } from '@selvajs/schemas';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
schema: UISchema;
|
|
4
4
|
values: Record<string, unknown>;
|
|
5
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
5
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
6
6
|
/** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
|
|
7
7
|
panelFilter?: 'left' | 'right';
|
|
8
8
|
/** Externally request a specific tab to be active (e.g. from collapsed strip click) */
|
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
|
|
12
12
|
|
|
13
|
+
// Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
|
|
14
|
+
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
15
|
+
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
16
|
+
|
|
13
17
|
const getSceneObjects = () => {
|
|
14
18
|
void sceneVersion;
|
|
15
19
|
return scene.children.filter(
|
|
16
|
-
(obj) =>
|
|
20
|
+
(obj) =>
|
|
21
|
+
!(obj instanceof THREE.Camera) &&
|
|
22
|
+
!(obj instanceof THREE.Light) &&
|
|
23
|
+
!HELPER_IDS.has(obj.userData?.id)
|
|
17
24
|
);
|
|
18
25
|
};
|
|
19
26
|
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
type CameraController,
|
|
9
9
|
type CameraProjection,
|
|
10
10
|
type MeasureTool,
|
|
11
|
-
type ViewPreset
|
|
11
|
+
type ViewPreset,
|
|
12
|
+
type Grid
|
|
12
13
|
} from '@selvajs/compute/visualization';
|
|
13
14
|
import {
|
|
14
15
|
Maximize,
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
Square,
|
|
21
22
|
Frame,
|
|
22
23
|
Ruler,
|
|
24
|
+
Grid3x3,
|
|
23
25
|
Check,
|
|
24
26
|
ChevronRight
|
|
25
27
|
} from '@lucide/svelte';
|
|
@@ -35,6 +37,8 @@
|
|
|
35
37
|
showFullscreenButton?: boolean;
|
|
36
38
|
showSceneManager?: boolean;
|
|
37
39
|
showToolsMenu?: boolean;
|
|
40
|
+
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
41
|
+
showGridToggle?: boolean;
|
|
38
42
|
enableMeshClick?: boolean;
|
|
39
43
|
backgroundColor?: string;
|
|
40
44
|
}
|
|
@@ -53,6 +57,7 @@
|
|
|
53
57
|
showFullscreenButton: true,
|
|
54
58
|
showSceneManager: true,
|
|
55
59
|
showToolsMenu: true,
|
|
60
|
+
showGridToggle: true,
|
|
56
61
|
enableMeshClick: true,
|
|
57
62
|
backgroundColor: '#E6E6E6'
|
|
58
63
|
};
|
|
@@ -74,6 +79,7 @@
|
|
|
74
79
|
let controls: OrbitControls | null = null;
|
|
75
80
|
let cameraController: CameraController | null = null;
|
|
76
81
|
let measureTool: MeasureTool | null = null;
|
|
82
|
+
let grid: Grid | null = null;
|
|
77
83
|
let fitToView: (() => void) | null = null;
|
|
78
84
|
let viewerInitialized = false;
|
|
79
85
|
let sceneVersion = $state(0);
|
|
@@ -81,6 +87,7 @@
|
|
|
81
87
|
let sceneManagerOpen = $state(false);
|
|
82
88
|
let projection: CameraProjection = $state('perspective');
|
|
83
89
|
let measureActive = $state(false);
|
|
90
|
+
let gridVisible = $state(false);
|
|
84
91
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
85
92
|
let selectedMeshName: string | null = $state(null);
|
|
86
93
|
|
|
@@ -112,7 +119,8 @@
|
|
|
112
119
|
const opts: ThreeInitializerOptions = {
|
|
113
120
|
environment: { backgroundColor: config.backgroundColor },
|
|
114
121
|
controls: {},
|
|
115
|
-
grid
|
|
122
|
+
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
123
|
+
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
116
124
|
gizmo: { enabled: false },
|
|
117
125
|
measure: { enabled: config.showToolsMenu },
|
|
118
126
|
events: {
|
|
@@ -133,6 +141,8 @@
|
|
|
133
141
|
controls = init.controls;
|
|
134
142
|
cameraController = init.cameraController;
|
|
135
143
|
measureTool = init.measureTool;
|
|
144
|
+
grid = init.grid;
|
|
145
|
+
grid?.setVisible(gridVisible);
|
|
136
146
|
fitToView = init.fitToView;
|
|
137
147
|
projection = init.cameraController.getProjection();
|
|
138
148
|
|
|
@@ -156,6 +166,12 @@
|
|
|
156
166
|
measureTool.setEnabled(measureActive);
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
function toggleGrid() {
|
|
170
|
+
if (!grid) return;
|
|
171
|
+
gridVisible = !gridVisible;
|
|
172
|
+
grid.setVisible(gridVisible);
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
$effect(() => {
|
|
160
176
|
if (scene && camera && controls) {
|
|
161
177
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
@@ -309,6 +325,20 @@
|
|
|
309
325
|
{/if}
|
|
310
326
|
</DropdownMenu.Item>
|
|
311
327
|
|
|
328
|
+
{#if config.showGridToggle}
|
|
329
|
+
<DropdownMenu.Item
|
|
330
|
+
closeOnSelect={false}
|
|
331
|
+
class="{itemClass} {gridVisible ? 'text-primary' : ''}"
|
|
332
|
+
onSelect={toggleGrid}
|
|
333
|
+
>
|
|
334
|
+
<Grid3x3 class="h-4 w-4" />
|
|
335
|
+
<span class="flex-1">Grid</span>
|
|
336
|
+
{#if gridVisible}
|
|
337
|
+
<Check class="h-4 w-4" />
|
|
338
|
+
{/if}
|
|
339
|
+
</DropdownMenu.Item>
|
|
340
|
+
{/if}
|
|
341
|
+
|
|
312
342
|
<!-- Scene tools -->
|
|
313
343
|
{#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
|
|
314
344
|
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
@@ -3,6 +3,8 @@ export interface ViewerConfig {
|
|
|
3
3
|
showFullscreenButton?: boolean;
|
|
4
4
|
showSceneManager?: boolean;
|
|
5
5
|
showToolsMenu?: boolean;
|
|
6
|
+
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
7
|
+
showGridToggle?: boolean;
|
|
6
8
|
enableMeshClick?: boolean;
|
|
7
9
|
backgroundColor?: string;
|
|
8
10
|
}
|
|
@@ -19,8 +19,12 @@ export interface SolveSession {
|
|
|
19
19
|
readonly hasPendingChanges: boolean;
|
|
20
20
|
readonly hasNeverSolved: boolean;
|
|
21
21
|
readonly isSolving: boolean;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Records a value change and dispatches a solve unless the schema is manual-solve.
|
|
24
|
+
* `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
|
|
25
|
+
* value list pruning a vanished selection), so the output can't lag behind the new value.
|
|
26
|
+
*/
|
|
27
|
+
setValue(id: string, value: unknown, forceSolve?: boolean): void;
|
|
24
28
|
/** Explicit "calculate" — dispatches a solve with the current values. */
|
|
25
29
|
solve(): void;
|
|
26
30
|
/** Merges incoming values, then solves (auto) or marks dirty (manual). */
|
|
@@ -46,10 +46,16 @@ export function createSolveSession(args) {
|
|
|
46
46
|
get isSolving() {
|
|
47
47
|
return args.driver.isSolving;
|
|
48
48
|
},
|
|
49
|
-
setValue(id, value) {
|
|
49
|
+
setValue(id, value, forceSolve = false) {
|
|
50
50
|
const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
|
|
51
|
-
if (shouldSolve)
|
|
51
|
+
if (shouldSolve || forceSolve) {
|
|
52
|
+
// A forced solve reconciles the deferred output; clear the dirty flags it raised.
|
|
53
|
+
if (forceSolve && !shouldSolve) {
|
|
54
|
+
state.pendingValues = {};
|
|
55
|
+
state.hasPendingChanges = false;
|
|
56
|
+
}
|
|
52
57
|
dispatch();
|
|
58
|
+
}
|
|
53
59
|
},
|
|
54
60
|
solve() {
|
|
55
61
|
dispatch();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"**/*.css"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@selvajs/compute": "^2.1.0
|
|
40
|
+
"@selvajs/compute": "^2.1.0",
|
|
41
41
|
"@sveltejs/kit": "^2",
|
|
42
42
|
"bits-ui": "^2.18.0",
|
|
43
43
|
"svelte": "^5",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"rimraf": "^6.0.1",
|
|
72
72
|
"svelte": "5.55.5",
|
|
73
73
|
"tailwind-variants": "^3.2.2",
|
|
74
|
-
"vitest": "^3.2.
|
|
75
|
-
"@selvajs/
|
|
76
|
-
"@selvajs/
|
|
74
|
+
"vitest": "^3.2.6",
|
|
75
|
+
"@selvajs/config": "0.0.1",
|
|
76
|
+
"@selvajs/schemas": "4.5.0"
|
|
77
77
|
},
|
|
78
78
|
"scripts": {
|
|
79
79
|
"dev": "vite dev",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
isViewerFullscreen?: boolean;
|
|
25
25
|
oncalculate?: () => void;
|
|
26
26
|
values: Record<string, unknown>;
|
|
27
|
-
onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
|
|
27
|
+
onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
|
|
28
28
|
onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
|
|
29
29
|
panelActions?: ActionButton[];
|
|
30
30
|
showSaveButton?: boolean;
|
|
@@ -133,8 +133,8 @@
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
function handleValueChange(id: string, val: unknown) {
|
|
137
|
-
session.setValue(id, val);
|
|
136
|
+
function handleValueChange(id: string, val: unknown, forceSolve?: boolean) {
|
|
137
|
+
session.setValue(id, val, forceSolve);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
function handleCalculate() {
|
|
@@ -33,7 +33,12 @@
|
|
|
33
33
|
item: InputLayoutItem;
|
|
34
34
|
value?: SupportedTypes;
|
|
35
35
|
displayName?: string;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
|
|
38
|
+
* system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
|
|
39
|
+
* leaving the previous output on screen would misrepresent the now-changed input.
|
|
40
|
+
*/
|
|
41
|
+
onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
37
42
|
disabled?: boolean;
|
|
38
43
|
/** Runtime-computed options for a dynamic value list input (name -> value). */
|
|
39
44
|
dynamicOptions?: Record<string, string>;
|
|
@@ -108,7 +113,10 @@
|
|
|
108
113
|
|
|
109
114
|
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
110
115
|
// available option. Prune the stale selection so the control shows a valid option (or empty)
|
|
111
|
-
// instead of rendering the orphaned raw value as its own label.
|
|
116
|
+
// instead of rendering the orphaned raw value as its own label. This is a system-initiated
|
|
117
|
+
// change (the user didn't pick the new option), so force a solve — otherwise manual-solve
|
|
118
|
+
// schemas would keep the prior output on screen, making it look like the auto-picked option
|
|
119
|
+
// produced it.
|
|
112
120
|
$effect(() => {
|
|
113
121
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
114
122
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
@@ -116,9 +124,17 @@
|
|
|
116
124
|
// directly from an effect trips Svelte's binding-ownership check.
|
|
117
125
|
if (Array.isArray(value)) {
|
|
118
126
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
119
|
-
if (pruned.length !== value.length)
|
|
127
|
+
if (pruned.length !== value.length) {
|
|
128
|
+
// Fall back to first option when checklist becomes fully empty after pruning
|
|
129
|
+
onChange(
|
|
130
|
+
item.paramId,
|
|
131
|
+
pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
|
|
132
|
+
true
|
|
133
|
+
);
|
|
134
|
+
}
|
|
120
135
|
} else if (typeof value === 'string' && value && !validValues.has(value)) {
|
|
121
|
-
|
|
136
|
+
// Fall back to first available option instead of clearing to empty
|
|
137
|
+
onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
|
|
122
138
|
}
|
|
123
139
|
});
|
|
124
140
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
values: Record<string, unknown>;
|
|
21
21
|
collapsedGroups: Record<string, boolean>;
|
|
22
22
|
onToggleGroup: (groupId: string) => void;
|
|
23
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
23
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
24
24
|
inputs: SchemaInput[];
|
|
25
25
|
outputs: DiscoveredOutput[];
|
|
26
26
|
/** Computed value list options keyed by the target dynamic-value-list input id. */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
interface Props {
|
|
11
11
|
schema: UISchema;
|
|
12
12
|
values: Record<string, unknown>;
|
|
13
|
-
onValueChange: (paramId: string, value: SupportedTypes) => void;
|
|
13
|
+
onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
|
|
14
14
|
/** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
|
|
15
15
|
panelFilter?: 'left' | 'right';
|
|
16
16
|
/** Externally request a specific tab to be active (e.g. from collapsed strip click) */
|
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
|
|
12
12
|
|
|
13
|
+
// Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
|
|
14
|
+
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
15
|
+
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
16
|
+
|
|
13
17
|
const getSceneObjects = () => {
|
|
14
18
|
void sceneVersion;
|
|
15
19
|
return scene.children.filter(
|
|
16
|
-
(obj) =>
|
|
20
|
+
(obj) =>
|
|
21
|
+
!(obj instanceof THREE.Camera) &&
|
|
22
|
+
!(obj instanceof THREE.Light) &&
|
|
23
|
+
!HELPER_IDS.has(obj.userData?.id)
|
|
17
24
|
);
|
|
18
25
|
};
|
|
19
26
|
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
type CameraController,
|
|
9
9
|
type CameraProjection,
|
|
10
10
|
type MeasureTool,
|
|
11
|
-
type ViewPreset
|
|
11
|
+
type ViewPreset,
|
|
12
|
+
type Grid
|
|
12
13
|
} from '@selvajs/compute/visualization';
|
|
13
14
|
import {
|
|
14
15
|
Maximize,
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
Square,
|
|
21
22
|
Frame,
|
|
22
23
|
Ruler,
|
|
24
|
+
Grid3x3,
|
|
23
25
|
Check,
|
|
24
26
|
ChevronRight
|
|
25
27
|
} from '@lucide/svelte';
|
|
@@ -35,6 +37,8 @@
|
|
|
35
37
|
showFullscreenButton?: boolean;
|
|
36
38
|
showSceneManager?: boolean;
|
|
37
39
|
showToolsMenu?: boolean;
|
|
40
|
+
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
41
|
+
showGridToggle?: boolean;
|
|
38
42
|
enableMeshClick?: boolean;
|
|
39
43
|
backgroundColor?: string;
|
|
40
44
|
}
|
|
@@ -53,6 +57,7 @@
|
|
|
53
57
|
showFullscreenButton: true,
|
|
54
58
|
showSceneManager: true,
|
|
55
59
|
showToolsMenu: true,
|
|
60
|
+
showGridToggle: true,
|
|
56
61
|
enableMeshClick: true,
|
|
57
62
|
backgroundColor: '#E6E6E6'
|
|
58
63
|
};
|
|
@@ -74,6 +79,7 @@
|
|
|
74
79
|
let controls: OrbitControls | null = null;
|
|
75
80
|
let cameraController: CameraController | null = null;
|
|
76
81
|
let measureTool: MeasureTool | null = null;
|
|
82
|
+
let grid: Grid | null = null;
|
|
77
83
|
let fitToView: (() => void) | null = null;
|
|
78
84
|
let viewerInitialized = false;
|
|
79
85
|
let sceneVersion = $state(0);
|
|
@@ -81,6 +87,7 @@
|
|
|
81
87
|
let sceneManagerOpen = $state(false);
|
|
82
88
|
let projection: CameraProjection = $state('perspective');
|
|
83
89
|
let measureActive = $state(false);
|
|
90
|
+
let gridVisible = $state(false);
|
|
84
91
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
85
92
|
let selectedMeshName: string | null = $state(null);
|
|
86
93
|
|
|
@@ -112,7 +119,8 @@
|
|
|
112
119
|
const opts: ThreeInitializerOptions = {
|
|
113
120
|
environment: { backgroundColor: config.backgroundColor },
|
|
114
121
|
controls: {},
|
|
115
|
-
grid
|
|
122
|
+
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
123
|
+
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
116
124
|
gizmo: { enabled: false },
|
|
117
125
|
measure: { enabled: config.showToolsMenu },
|
|
118
126
|
events: {
|
|
@@ -133,6 +141,8 @@
|
|
|
133
141
|
controls = init.controls;
|
|
134
142
|
cameraController = init.cameraController;
|
|
135
143
|
measureTool = init.measureTool;
|
|
144
|
+
grid = init.grid;
|
|
145
|
+
grid?.setVisible(gridVisible);
|
|
136
146
|
fitToView = init.fitToView;
|
|
137
147
|
projection = init.cameraController.getProjection();
|
|
138
148
|
|
|
@@ -156,6 +166,12 @@
|
|
|
156
166
|
measureTool.setEnabled(measureActive);
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
function toggleGrid() {
|
|
170
|
+
if (!grid) return;
|
|
171
|
+
gridVisible = !gridVisible;
|
|
172
|
+
grid.setVisible(gridVisible);
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
$effect(() => {
|
|
160
176
|
if (scene && camera && controls) {
|
|
161
177
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
@@ -309,6 +325,20 @@
|
|
|
309
325
|
{/if}
|
|
310
326
|
</DropdownMenu.Item>
|
|
311
327
|
|
|
328
|
+
{#if config.showGridToggle}
|
|
329
|
+
<DropdownMenu.Item
|
|
330
|
+
closeOnSelect={false}
|
|
331
|
+
class="{itemClass} {gridVisible ? 'text-primary' : ''}"
|
|
332
|
+
onSelect={toggleGrid}
|
|
333
|
+
>
|
|
334
|
+
<Grid3x3 class="h-4 w-4" />
|
|
335
|
+
<span class="flex-1">Grid</span>
|
|
336
|
+
{#if gridVisible}
|
|
337
|
+
<Check class="h-4 w-4" />
|
|
338
|
+
{/if}
|
|
339
|
+
</DropdownMenu.Item>
|
|
340
|
+
{/if}
|
|
341
|
+
|
|
312
342
|
<!-- Scene tools -->
|
|
313
343
|
{#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
|
|
314
344
|
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
@@ -35,8 +35,12 @@ export interface SolveSession {
|
|
|
35
35
|
readonly hasPendingChanges: boolean;
|
|
36
36
|
readonly hasNeverSolved: boolean;
|
|
37
37
|
readonly isSolving: boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Records a value change and dispatches a solve unless the schema is manual-solve.
|
|
40
|
+
* `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
|
|
41
|
+
* value list pruning a vanished selection), so the output can't lag behind the new value.
|
|
42
|
+
*/
|
|
43
|
+
setValue(id: string, value: unknown, forceSolve?: boolean): void;
|
|
40
44
|
/** Explicit "calculate" — dispatches a solve with the current values. */
|
|
41
45
|
solve(): void;
|
|
42
46
|
/** Merges incoming values, then solves (auto) or marks dirty (manual). */
|
|
@@ -100,9 +104,16 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
100
104
|
return args.driver.isSolving;
|
|
101
105
|
},
|
|
102
106
|
|
|
103
|
-
setValue(id, value) {
|
|
107
|
+
setValue(id, value, forceSolve = false) {
|
|
104
108
|
const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
|
|
105
|
-
if (shouldSolve)
|
|
109
|
+
if (shouldSolve || forceSolve) {
|
|
110
|
+
// A forced solve reconciles the deferred output; clear the dirty flags it raised.
|
|
111
|
+
if (forceSolve && !shouldSolve) {
|
|
112
|
+
state.pendingValues = {};
|
|
113
|
+
state.hasPendingChanges = false;
|
|
114
|
+
}
|
|
115
|
+
dispatch();
|
|
116
|
+
}
|
|
106
117
|
},
|
|
107
118
|
|
|
108
119
|
solve() {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSolveSession, type SolveDriver } from './createSolveSession.svelte';
|
|
3
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
4
|
+
|
|
5
|
+
// Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
|
|
6
|
+
// added for dynamic-value-list reconciliation. The pure transition logic is pinned in
|
|
7
|
+
// solve-session-core.test.ts; this file pins how the rune shell turns those decisions into
|
|
8
|
+
// driver.solve() calls. Rune module, so it runs under the svelte vitest plugin.
|
|
9
|
+
|
|
10
|
+
function schema(instanceSolve?: boolean): UISchema {
|
|
11
|
+
return {
|
|
12
|
+
id: 'test',
|
|
13
|
+
name: 'Test',
|
|
14
|
+
instanceSolve,
|
|
15
|
+
inputs: [{ id: 'a', paramId: 'a', paramType: 'text', default: 'x' }],
|
|
16
|
+
outputs: [{ id: 'out' }],
|
|
17
|
+
layout: { type: 'flat', groups: [{ items: [] }] }
|
|
18
|
+
} as unknown as UISchema;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Records every solve dispatch with the snapshot of values it received.
|
|
22
|
+
function recordingDriver(): SolveDriver & { solves: Record<string, unknown>[] } {
|
|
23
|
+
const solves: Record<string, unknown>[] = [];
|
|
24
|
+
return {
|
|
25
|
+
solves,
|
|
26
|
+
solve(values) {
|
|
27
|
+
solves.push(values);
|
|
28
|
+
},
|
|
29
|
+
cancel() {},
|
|
30
|
+
get isSolving() {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('createSolveSession.setValue', () => {
|
|
37
|
+
it('auto-solve mode dispatches on every value change', () => {
|
|
38
|
+
const driver = recordingDriver();
|
|
39
|
+
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
40
|
+
session.setValue('a', 'y');
|
|
41
|
+
expect(driver.solves.length).toBe(1);
|
|
42
|
+
expect(driver.solves[0].a).toBe('y');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('manual mode defers: marks pending, no dispatch', () => {
|
|
46
|
+
const driver = recordingDriver();
|
|
47
|
+
const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
|
|
48
|
+
session.setValue('a', 'y');
|
|
49
|
+
expect(driver.solves.length).toBe(0);
|
|
50
|
+
expect(session.hasPendingChanges).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('forceSolve dispatches even in manual mode and clears the dirty flags', () => {
|
|
54
|
+
const driver = recordingDriver();
|
|
55
|
+
const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
|
|
56
|
+
// Reconcile a system-initiated change (e.g. a pruned dynamic-list selection).
|
|
57
|
+
session.setValue('a', 'reconciled', true);
|
|
58
|
+
expect(driver.solves.length).toBe(1);
|
|
59
|
+
expect(driver.solves[0].a).toBe('reconciled');
|
|
60
|
+
// The forced solve produces the matching output, so nothing is left pending.
|
|
61
|
+
expect(session.hasPendingChanges).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('forceSolve in auto mode still dispatches exactly once', () => {
|
|
65
|
+
const driver = recordingDriver();
|
|
66
|
+
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
67
|
+
session.setValue('a', 'y', true);
|
|
68
|
+
expect(driver.solves.length).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
});
|