@selvajs/ui 5.0.0 → 6.0.0-beta.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.
Files changed (49) hide show
  1. package/dist/components/compute/ComputeApp.svelte +15 -7
  2. package/dist/components/compute/ComputeApp.svelte.d.ts +1 -1
  3. package/dist/components/primitives/alert/alert.svelte.d.ts +8 -8
  4. package/dist/components/primitives/badge/badge.svelte.d.ts +14 -14
  5. package/dist/components/primitives/button/button.svelte.d.ts +41 -41
  6. package/dist/components/primitives/button-group/button-group.svelte.d.ts +8 -8
  7. package/dist/components/primitives/field/field.svelte.d.ts +5 -5
  8. package/dist/components/primitives/textarea/textarea.svelte +1 -2
  9. package/dist/components/viewer/SceneManager.svelte +75 -160
  10. package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
  11. package/dist/components/viewer/Viewer.svelte +20 -3
  12. package/dist/compute/useSolveSession.svelte.d.ts +12 -0
  13. package/dist/compute/useSolveSession.svelte.js +76 -0
  14. package/dist/external/storage.d.ts +1 -15
  15. package/dist/external/storage.js +4 -54
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.js +7 -4
  18. package/dist/public.d.ts +4 -3
  19. package/dist/public.js +9 -5
  20. package/package.json +17 -15
  21. package/src/lib/components/compute/ComputeApp.svelte +15 -7
  22. package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
  23. package/src/lib/components/viewer/SceneManager.svelte +75 -160
  24. package/src/lib/components/viewer/Viewer.svelte +20 -3
  25. package/src/lib/compute/mesh-policy-wiring.test.ts +72 -0
  26. package/src/lib/compute/useSolveSession.svelte.ts +83 -0
  27. package/src/lib/external/storage.ts +12 -64
  28. package/src/lib/index.ts +15 -5
  29. package/src/lib/public.ts +17 -6
  30. package/dist/compute/computeThrottle.svelte.d.ts +0 -24
  31. package/dist/compute/computeThrottle.svelte.js +0 -82
  32. package/dist/compute/createSolveSession.svelte.d.ts +0 -66
  33. package/dist/compute/createSolveSession.svelte.js +0 -159
  34. package/dist/compute/solve-session-core.d.ts +0 -54
  35. package/dist/compute/solve-session-core.js +0 -87
  36. package/dist/compute/solveMemo.d.ts +0 -22
  37. package/dist/compute/solveMemo.js +0 -124
  38. package/dist/types/solveFn.d.ts +0 -25
  39. package/dist/types/solveFn.js +0 -1
  40. package/src/lib/compute/computeThrottle.svelte.ts +0 -109
  41. package/src/lib/compute/computeThrottle.test.ts +0 -106
  42. package/src/lib/compute/createSolveSession.svelte.ts +0 -239
  43. package/src/lib/compute/createSolveSession.test.ts +0 -168
  44. package/src/lib/compute/solve-session-core.test.ts +0 -153
  45. package/src/lib/compute/solve-session-core.ts +0 -122
  46. package/src/lib/compute/solveMemo.test.ts +0 -220
  47. package/src/lib/compute/solveMemo.ts +0 -140
  48. package/src/lib/external/storage.test.ts +0 -99
  49. package/src/lib/types/solveFn.ts +0 -29
@@ -1,173 +1,85 @@
1
1
  <script lang="ts">
2
2
  import * as THREE from 'three';
3
3
  import { Eye, EyeOff, ChevronRight, Search, X } from '@lucide/svelte';
4
- import { SvelteSet, SvelteMap } from 'svelte/reactivity';
4
+ import {
5
+ getObjectLabel,
6
+ getTrackingKey,
7
+ getTypeLabel,
8
+ type SceneOutliner
9
+ } from '@selvajs/visualization/scene';
5
10
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
6
11
 
7
12
  const locale = getLocaleContext();
8
13
  const t = $derived(locale.messages);
9
14
 
10
15
  interface Props {
11
- scene: THREE.Scene;
16
+ /**
17
+ * Owned by `Viewer.svelte`, not built here: this panel unmounts when it is closed, and
18
+ * hidden objects have to stay hidden — and keep being re-hidden after each solve — while it
19
+ * is. Its state is backed by SvelteSets, so mutating one re-renders this list.
20
+ */
21
+ outliner: SceneOutliner;
12
22
  sceneVersion?: number;
13
23
  }
14
24
 
15
- let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
25
+ let { outliner, sceneVersion = 0 }: Props = $props();
16
26
 
17
- // Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
18
- // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
19
- const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
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
+ // Derived, not destructured: the prop is reassignable, and these must follow it.
30
+ const hidden = $derived(outliner.visibility.hidden);
31
+ const selected = $derived(outliner.selection.selected);
32
+ const collapsed = $derived(outliner.collapsed);
20
33
 
21
- // Content objects of the scene, recomputed only when a solve bumps `sceneVersion` (scene.children is
22
- // a plain array the library mutates in place, so that counter is the sole reactive trigger). Derived
23
- // not a function so the several template sites that read it share one walk per solve instead of
24
- // re-filtering scene.children on every render.
34
+ // Mirrored into runes because the outliner holds them as plain fields, not sets. Both start
35
+ // fresh: the search box and the shift-anchor are panel state, so reopening the panel clears
36
+ // them, while hiding and collapse persist in the outliner that outlives it.
37
+ let searchQuery = $state('');
38
+ let anchor = $state<string | null>(null);
39
+
40
+ $effect(() => outliner.onAnchorChange((next) => (anchor = next)));
41
+
42
+ // `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. Both derivations below read it, which
44
+ // is what makes one walk per solve serve every template site that needs the list.
25
45
  const sceneObjects = $derived.by(() => {
26
46
  void sceneVersion;
27
- return scene.children.filter(
28
- (obj) =>
29
- !(obj instanceof THREE.Camera) &&
30
- !(obj instanceof THREE.Light) &&
31
- !HELPER_IDS.has(obj.userData?.id)
32
- );
47
+ return outliner.objects();
33
48
  });
34
49
 
35
- // Content grouped by layer. Derived from `sceneObjects`, so the grouping map is rebuilt once per
36
- // solve rather than every time the list renders.
37
50
  const layerGroups = $derived.by(() => {
38
- const groups = new SvelteMap<string, THREE.Object3D[]>();
39
- for (const obj of sceneObjects) {
40
- const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
41
- if (!groups.has(layer)) groups.set(layer, []);
42
- groups.get(layer)!.push(obj);
43
- }
44
- return groups;
45
- });
46
-
47
- let hiddenUuids = new SvelteSet<string>();
48
-
49
- let collapsedLayers = new SvelteSet<string>();
50
-
51
- const setObjectVisible = (object: THREE.Object3D, visible: boolean) => {
52
- object.visible = visible;
53
- object.traverse((child) => {
54
- child.visible = visible;
55
- });
56
- if (visible) {
57
- hiddenUuids.delete(object.uuid);
58
- } else {
59
- hiddenUuids.add(object.uuid);
60
- }
61
- };
62
-
63
- const toggleObject = (object: THREE.Object3D) => {
64
- if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
65
- const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
66
- const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
67
- for (const o of selected) setObjectVisible(o, allHidden);
68
- } else {
69
- setObjectVisible(object, hiddenUuids.has(object.uuid));
70
- }
71
- };
72
-
73
- const isLayerHidden = (objects: THREE.Object3D[]) =>
74
- objects.every((obj) => hiddenUuids.has(obj.uuid));
75
-
76
- const isLayerPartial = (objects: THREE.Object3D[]) => {
77
- const hidden = objects.filter((obj) => hiddenUuids.has(obj.uuid)).length;
78
- return hidden > 0 && hidden < objects.length;
79
- };
80
-
81
- const toggleLayer = (_layerName: string, objects: THREE.Object3D[]) => {
82
- const allHidden = isLayerHidden(objects);
83
- for (const obj of objects) {
84
- setObjectVisible(obj, allHidden);
85
- }
86
- };
87
-
88
- const toggleLayerCollapsed = (layerName: string) => {
89
- if (collapsedLayers.has(layerName)) {
90
- collapsedLayers.delete(layerName);
91
- } else {
92
- collapsedLayers.add(layerName);
93
- }
94
- };
95
-
96
- const prettyType = (type: string) =>
97
- type
98
- .replace(/^Line(Segments)?2$/, 'Curve')
99
- .replace('Mesh', '')
100
- .replace('Object3D', 'Obj') || type;
101
-
102
- const getObjectLabel = (object: THREE.Object3D) =>
103
- object.userData?.name || object.userData?.fileName || object.name || prettyType(object.type);
104
-
105
- const getTypeLabel = (object: THREE.Object3D) => prettyType(object.type);
106
-
107
- let searchQuery = $state('');
108
-
109
- // Layer groups after the search filter. Derived from `layerGroups` + `searchQuery`, so filtering
110
- // runs once per search keystroke — not twice per render (it's read by both the list and the
111
- // empty-state check below).
112
- const filteredLayerGroups = $derived.by(() => {
113
- if (!searchQuery.trim()) return layerGroups;
114
- const q = searchQuery.toLowerCase();
115
- const filtered = new SvelteMap<string, THREE.Object3D[]>();
116
- for (const [layerName, objects] of layerGroups) {
117
- const matchingObjects = layerName.toLowerCase().includes(q)
118
- ? objects
119
- : objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
120
- if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
121
- }
122
- return filtered;
51
+ void sceneVersion;
52
+ void searchQuery;
53
+ outliner.searchQuery = searchQuery;
54
+ return outliner.layerGroups();
123
55
  });
124
56
 
125
- const getFlatVisibleUuids = (): string[] => {
126
- const result: string[] = [];
127
- for (const [layerName, objects] of filteredLayerGroups) {
128
- if (!collapsedLayers.has(layerName)) {
129
- for (const obj of objects) result.push(obj.uuid);
130
- }
131
- }
132
- return result;
133
- };
57
+ // Hidden state is keyed by Grasshopper identity, not by uuid, so ask the outliner rather than
58
+ // looking the object up in the set directly. Touching `hidden` registers the reactive read that
59
+ // re-renders this row when the eye is clicked.
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.
63
+ const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
134
64
 
135
- let selectedUuids = new SvelteSet<string>();
136
- let lastSelectedUuid = $state<string | null>(null);
65
+ // Same reason: count through the reactive set so the layer's tri-state eye tracks its objects.
66
+ const hiddenCount = (objects: THREE.Object3D[]) =>
67
+ objects.filter((object) => isObjectHidden(object)).length;
137
68
 
69
+ // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
138
70
  const selectObject = (uuid: string, event: MouseEvent) => {
139
- if (event.shiftKey && lastSelectedUuid) {
140
- const flat = getFlatVisibleUuids();
141
- const a = flat.indexOf(lastSelectedUuid);
142
- const b = flat.indexOf(uuid);
143
- if (a !== -1 && b !== -1) {
144
- const [lo, hi] = a < b ? [a, b] : [b, a];
145
- if (!event.ctrlKey && !event.metaKey) selectedUuids.clear();
146
- for (let i = lo; i <= hi; i++) selectedUuids.add(flat[i]);
147
- }
148
- } else if (event.ctrlKey || event.metaKey) {
149
- if (selectedUuids.has(uuid)) {
150
- selectedUuids.delete(uuid);
151
- } else {
152
- selectedUuids.add(uuid);
153
- lastSelectedUuid = uuid;
154
- }
155
- } else {
156
- selectedUuids.clear();
157
- selectedUuids.add(uuid);
158
- lastSelectedUuid = uuid;
159
- }
160
- if (!event.shiftKey) lastSelectedUuid = uuid;
71
+ void anchor;
72
+ outliner.select(uuid, {
73
+ shiftKey: event.shiftKey,
74
+ toggleKey: event.ctrlKey || event.metaKey
75
+ });
161
76
  };
162
77
  </script>
163
78
 
164
79
  <!-- svelte-ignore a11y_no_static_element_interactions -->
165
80
  <div
166
81
  class="flex h-full flex-col bg-card text-card-foreground"
167
- onmousedown={() => {
168
- selectedUuids.clear();
169
- lastSelectedUuid = null;
170
- }}
82
+ onmousedown={() => outliner.selection.clear()}
171
83
  >
172
84
  <div class="px-3 py-2 gap-2 flex items-center border-b border-border">
173
85
  <Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
@@ -186,25 +98,26 @@
186
98
  </div>
187
99
 
188
100
  <div class="py-1 flex-1 overflow-y-auto">
189
- {#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
190
- {@const layerHidden = isLayerHidden(objects)}
191
- {@const layerPartial = isLayerPartial(objects)}
192
- {@const collapsed = collapsedLayers.has(layerName)}
101
+ {#each [...layerGroups] as [layerName, objects] (layerName)}
102
+ {@const numHidden = hiddenCount(objects)}
103
+ {@const layerHidden = objects.length > 0 && numHidden === objects.length}
104
+ {@const layerPartial = numHidden > 0 && numHidden < objects.length}
105
+ {@const isCollapsed = collapsed.has(layerName)}
193
106
 
194
107
  <div class="gap-1 pl-1 pr-2 py-1 group flex items-center transition-colors hover:bg-muted">
195
108
  <button
196
109
  class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
197
- onclick={() => toggleLayerCollapsed(layerName)}
198
- aria-label={collapsed ? t.expandLayer : t.collapseLayer}
110
+ onclick={() => outliner.toggleCollapsed(layerName)}
111
+ aria-label={isCollapsed ? t.expandLayer : t.collapseLayer}
199
112
  >
200
113
  <ChevronRight
201
- class="h-3.5 w-3.5 transition-transform duration-150 {collapsed ? '' : 'rotate-90'}"
114
+ class="h-3.5 w-3.5 transition-transform duration-150 {isCollapsed ? '' : 'rotate-90'}"
202
115
  />
203
116
  </button>
204
117
 
205
118
  <button
206
119
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
207
- onclick={() => toggleLayer(layerName, objects)}
120
+ onclick={() => outliner.visibility.toggleLayer(objects)}
208
121
  title={layerHidden ? t.showLayer : t.hideLayer}
209
122
  aria-label={layerHidden ? t.showLayer : t.hideLayer}
210
123
  >
@@ -230,18 +143,20 @@
230
143
  </span>
231
144
  </div>
232
145
 
233
- {#if !collapsed}
146
+ {#if !isCollapsed}
234
147
  <div role="listbox" aria-multiselectable="true" class="ml-3 border-l border-border">
235
148
  {#each objects as object (object.uuid)}
236
- {@const hidden = hiddenUuids.has(object.uuid)}
237
- {@const selected = selectedUuids.has(object.uuid)}
149
+ <!-- Visibility is keyed by Grasshopper identity (so it survives a solve), selection
150
+ by uuid (so it does not) — hence the two different lookups. -->
151
+ {@const isHidden = isObjectHidden(object)}
152
+ {@const isSelected = selected.has(object.uuid)}
238
153
  <div
239
154
  role="option"
240
- aria-selected={selected}
155
+ aria-selected={isSelected}
241
156
  tabindex="-1"
242
157
  class="gap-1.5 pl-5 pr-2 py-0.5 flex cursor-pointer items-center transition-colors
243
- {selected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
244
- {hidden ? 'opacity-40' : ''}"
158
+ {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
159
+ {isHidden ? 'opacity-40' : ''}"
245
160
  onmousedown={(e) => {
246
161
  e.stopPropagation();
247
162
  if (e.shiftKey) e.preventDefault();
@@ -255,12 +170,12 @@
255
170
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
256
171
  onclick={(e) => {
257
172
  e.stopPropagation();
258
- toggleObject(object);
173
+ outliner.toggleObject(object);
259
174
  }}
260
- title={hidden ? t.showObject : t.hideObject}
261
- aria-label={hidden ? t.showObject : t.hideObject}
175
+ title={isHidden ? t.showObject : t.hideObject}
176
+ aria-label={isHidden ? t.showObject : t.hideObject}
262
177
  >
263
- {#if hidden}
178
+ {#if isHidden}
264
179
  <EyeOff class="h-3 w-3 text-muted-foreground/60" />
265
180
  {:else}
266
181
  <Eye class="h-3 w-3 text-muted-foreground" />
@@ -269,7 +184,7 @@
269
184
 
270
185
  <!-- Object name -->
271
186
  <span
272
- class="min-w-0 text-xs flex-1 truncate {hidden
187
+ class="min-w-0 text-xs flex-1 truncate {isHidden
273
188
  ? 'text-muted-foreground line-through'
274
189
  : 'text-foreground/80'}"
275
190
  >
@@ -294,7 +209,7 @@
294
209
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
295
210
  <p class="text-xs text-muted-foreground">{t.noObjects}</p>
296
211
  </div>
297
- {:else if filteredLayerGroups.size === 0}
212
+ {:else if layerGroups.size === 0}
298
213
  <div class="py-12 flex flex-col items-center justify-center text-center">
299
214
  <Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
300
215
  <p class="text-xs text-muted-foreground">
@@ -12,7 +12,7 @@
12
12
  type MeasureTool,
13
13
  type ViewPreset,
14
14
  type Grid
15
- } from '@selvajs/compute/visualization';
15
+ } from '@selvajs/visualization/render';
16
16
  import {
17
17
  Maximize,
18
18
  Minimize,
@@ -30,8 +30,10 @@
30
30
  Spline
31
31
  } from '@lucide/svelte';
32
32
  import { DropdownMenu } from 'bits-ui';
33
+ import { SvelteSet } from 'svelte/reactivity';
33
34
  import type * as THREE from 'three';
34
35
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
36
+ import { createSceneOutliner, type SceneOutliner } from '@selvajs/visualization/scene';
35
37
  import SceneManager from './SceneManager.svelte';
36
38
  import MeshMetadataDialog from './MeshMetadataDialog.svelte';
37
39
  import * as Resizable from '$lib/components/primitives/resizable/index.js';
@@ -127,6 +129,15 @@
127
129
  let sceneVersion = $state(0);
128
130
  let hideButton = $state(false);
129
131
  let sceneManagerOpen = $state(false);
132
+
133
+ // The scene outliner lives here rather than inside <SceneManager> because that component is
134
+ // mounted only while its panel is open. Hidden objects must stay hidden when the panel is
135
+ // closed — and must be re-hidden after each solve, which nothing would do if the state
136
+ // unmounted with the panel.
137
+ const hiddenObjects = new SvelteSet<string>();
138
+ const selectedObjects = new SvelteSet<string>();
139
+ const collapsedLayers = new SvelteSet<string>();
140
+ let outliner: SceneOutliner | null = $state(null);
130
141
  let projection: CameraProjection = $state('perspective');
131
142
  let measureActive = $state(false);
132
143
  let gridVisible = $state(false);
@@ -195,6 +206,9 @@
195
206
 
196
207
  const init = initThree(canvas, opts);
197
208
  scene = init.scene;
209
+ outliner = createSceneOutliner(init.scene, {
210
+ sets: { hidden: hiddenObjects, selected: selectedObjects, collapsed: collapsedLayers }
211
+ });
198
212
  camera = init.camera;
199
213
  controls = init.controls;
200
214
  cameraController = init.cameraController;
@@ -272,6 +286,9 @@
272
286
  if (edgesVisible) applyEdges?.(scene!);
273
287
  // Rescale the grid to the new content's extent so cells and fade match the part size.
274
288
  updateGridScale?.();
289
+ // The rebuild above also un-hid everything the user had hidden. Re-hide it: the outliner
290
+ // keys that state on Grasshopper identity, not on the instances just discarded.
291
+ outliner?.applyTo();
275
292
  sceneVersion++;
276
293
  // New solve content — repaint now rather than on the render loop's safety interval.
277
294
  invalidate?.();
@@ -546,10 +563,10 @@
546
563
  </Resizable.Pane>
547
564
 
548
565
  <!-- Scene Manager Pane -->
549
- {#if sceneManagerOpen && scene}
566
+ {#if sceneManagerOpen && scene && outliner}
550
567
  <Resizable.Handle withHandle />
551
568
  <Resizable.Pane defaultSize={15} minSize={8} maxSize={30}>
552
- <SceneManager {scene} {sceneVersion} />
569
+ <SceneManager {outliner} {sceneVersion} />
553
570
  </Resizable.Pane>
554
571
  {/if}
555
572
  </Resizable.PaneGroup>
@@ -0,0 +1,72 @@
1
+ import * as THREE from 'three';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createRequestResponseDriver, type SolveReporter } from '@selvajs/solve/client';
5
+ import type { SolveResult } from '@selvajs/solve/shared';
6
+ import { meshPolicy } from '@selvajs/visualization/parse';
7
+
8
+ // The C1 seam test. `@selvajs/solve` keeps meshes opaque and `@selvajs/visualization` owns the
9
+ // three.js clone/dispose rules; neither package can prove they compose, because neither may
10
+ // import the other. This is the only place both are in scope — it exists to catch a
11
+ // `ComputeApp` that forgets to pass `meshPolicy`, or a policy whose shape drifts from
12
+ // `MeshPolicy`.
13
+
14
+ function meshResult(tag: string): SolveResult<THREE.Object3D> {
15
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial());
16
+ mesh.name = tag;
17
+ return { outputs: { out: tag }, meshes: [mesh] };
18
+ }
19
+
20
+ /** Mirrors the viewer's `clearScene`: it disposes the geometry of whatever it was handed. */
21
+ function renderAndDispose(result: SolveResult<THREE.Object3D>): void {
22
+ result.meshes?.forEach((root) =>
23
+ root.traverse((child) => (child as Partial<THREE.Mesh>).geometry?.dispose())
24
+ );
25
+ }
26
+
27
+ function collectingReporter(): SolveReporter<THREE.Object3D> & {
28
+ reports: SolveResult<THREE.Object3D>[];
29
+ } {
30
+ const reports: SolveResult<THREE.Object3D>[] = [];
31
+ return {
32
+ reports,
33
+ report: (result) => reports.push(result),
34
+ reportError: () => {}
35
+ };
36
+ }
37
+
38
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
39
+
40
+ describe('mesh policy wiring (audit C1)', () => {
41
+ it('a memo hit serves a live mesh after the viewer disposed the previous one', async () => {
42
+ const onSolve = async () => meshResult('a');
43
+ const reporter = collectingReporter();
44
+ const driver = createRequestResponseDriver(onSolve, () => reporter, { meshPolicy });
45
+
46
+ driver.solve({ a: 1 });
47
+ await flush();
48
+ renderAndDispose(reporter.reports[0]); // the viewer eats solve 1's meshes
49
+
50
+ driver.solve({ a: 1 }); // same inputs → memo hit, no onSolve call
51
+ await flush();
52
+
53
+ expect(reporter.reports).toHaveLength(2);
54
+ const served = reporter.reports[1].meshes![0] as THREE.Mesh;
55
+ expect(served).not.toBe(reporter.reports[0].meshes![0]);
56
+ expect(served.geometry.attributes.position).toBeDefined();
57
+ });
58
+
59
+ it('without a policy the same flow serves the disposed instance (why the wiring matters)', async () => {
60
+ // Pins the failure mode, so the assertion above is known to be testing something.
61
+ const onSolve = async () => meshResult('a');
62
+ const reporter = collectingReporter();
63
+ const driver = createRequestResponseDriver(onSolve, () => reporter); // no meshPolicy
64
+
65
+ driver.solve({ a: 1 });
66
+ await flush();
67
+ driver.solve({ a: 1 });
68
+ await flush();
69
+
70
+ expect(reporter.reports[1].meshes![0]).toBe(reporter.reports[0].meshes![0]);
71
+ });
72
+ });
@@ -0,0 +1,83 @@
1
+ // The Svelte binding for a Solve Session.
2
+ //
3
+ // The session itself lives in `@selvajs/solve/client` and is framework-free: it
4
+ // exposes plain getters plus a `subscribe()` seam. That makes it usable headless, but a
5
+ // plain getter read inside Svelte markup is NOT reactive — nothing tells the component to
6
+ // re-run. This adapter closes that gap: it subscribes once, bumps a `$state` version
7
+ // counter on every notification, and reads that counter inside each getter so any
8
+ // component touching one re-runs when the session changes.
9
+ //
10
+ // The counter (rather than mirroring each field into its own `$state`) keeps this a pure
11
+ // republish: no field list to keep in sync as the session grows, and no risk of a mirrored
12
+ // copy drifting from the source of truth.
13
+
14
+ import type { SolveSession, SolveSessionArgs } from '@selvajs/solve/client';
15
+ import { createSolveSession } from '@selvajs/solve/client';
16
+
17
+ /**
18
+ * Wraps a Solve Session so its state reads reactively inside Svelte components.
19
+ *
20
+ * Returns the same `SolveSession` surface — every method delegates untouched, and every
21
+ * getter additionally depends on the version counter. Callers use it exactly like the
22
+ * session it wraps.
23
+ *
24
+ * Must be called during component initialization (it uses `$effect` to manage the
25
+ * subscription, so teardown follows the owning component's lifecycle).
26
+ */
27
+ export function useSolveSession(args: SolveSessionArgs): SolveSession {
28
+ const session = createSolveSession(args);
29
+
30
+ // Bumped on every session notification. Reading it inside a getter is what registers
31
+ // the dependency; the value itself is never meaningful.
32
+ let version = $state(0);
33
+
34
+ $effect(() => {
35
+ // Re-read on mount and unsubscribe on teardown. The session outlives no component
36
+ // here — it is created alongside this adapter — so dropping the subscription is the
37
+ // whole cleanup.
38
+ return session.subscribe(() => {
39
+ version += 1;
40
+ });
41
+ });
42
+
43
+ /** Registers the reactive dependency, then returns the live value. */
44
+ function track<T>(read: () => T): T {
45
+ void version;
46
+ return read();
47
+ }
48
+
49
+ return {
50
+ get values() {
51
+ return track(() => session.values);
52
+ },
53
+ get error() {
54
+ return track(() => session.error);
55
+ },
56
+ get computeErrors() {
57
+ return track(() => session.computeErrors);
58
+ },
59
+ get computeWarnings() {
60
+ return track(() => session.computeWarnings);
61
+ },
62
+ get meshes() {
63
+ return track(() => session.meshes);
64
+ },
65
+ get hasPendingChanges() {
66
+ return track(() => session.hasPendingChanges);
67
+ },
68
+ get hasNeverSolved() {
69
+ return track(() => session.hasNeverSolved);
70
+ },
71
+ get isSolving() {
72
+ return track(() => session.isSolving);
73
+ },
74
+ setValue: (id, value, forceSolve) => session.setValue(id, value, forceSolve),
75
+ solve: () => session.solve(),
76
+ loadValues: (incoming) => session.loadValues(incoming),
77
+ rebuild: (schema, scopeKey) => session.rebuild(schema, scopeKey),
78
+ report: (result) => session.report(result),
79
+ reportError: (message) => session.reportError(message),
80
+ subscribe: (listener) => session.subscribe(listener),
81
+ notify: () => session.notify()
82
+ };
83
+ }
@@ -1,64 +1,12 @@
1
- // Client-supplied input transit storage.
2
- //
3
- // When an input has source.kind === 'client', a producer route writes the produced
4
- // value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
5
- // values for one solver/input don't bleed into another. The scope key is whatever
6
- // uniquely identifies the solver context — sessionId in plugin-ui/preview,
7
- // definition guid in selva/library, etc.
8
- //
9
- // inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
10
-
11
- import type { UISchema } from '@selvajs/schemas';
12
- import { getInputItems } from '@selvajs/schemas';
13
-
14
- const STORAGE_PREFIX = 'external';
15
-
16
- function makeKey(scopeKey: string, inputId: string): string {
17
- return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
18
- }
19
-
20
- export interface ExternalValueRef {
21
- scopeKey: string;
22
- inputId: string;
23
- }
24
-
25
- export function writeExternalValue(args: ExternalValueRef & { value: unknown }): void {
26
- const { scopeKey, inputId, value } = args;
27
- if (!scopeKey || !inputId) return;
28
- if (typeof sessionStorage === 'undefined') return;
29
- sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
30
- }
31
-
32
- export function readExternalValue(ref: ExternalValueRef): unknown | undefined {
33
- const { scopeKey, inputId } = ref;
34
- if (!scopeKey || !inputId) return undefined;
35
- if (typeof sessionStorage === 'undefined') return undefined;
36
- const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
37
- if (raw === null) return undefined;
38
- try {
39
- return JSON.parse(raw);
40
- } catch {
41
- return undefined;
42
- }
43
- }
44
-
45
- export function clearExternalValue(ref: ExternalValueRef): void {
46
- const { scopeKey, inputId } = ref;
47
- if (!scopeKey || !inputId) return;
48
- if (typeof sessionStorage === 'undefined') return;
49
- sessionStorage.removeItem(makeKey(scopeKey, inputId));
50
- }
51
-
52
- export interface ExternalInput {
53
- paramId: string;
54
- displayName: string;
55
- }
56
-
57
- export function getExternalInputs(schema: UISchema): ExternalInput[] {
58
- return getInputItems(schema)
59
- .filter((item) => item.source?.kind === 'client')
60
- .map((item) => ({
61
- paramId: item.paramId,
62
- displayName: item.displayName ?? item.paramId
63
- }));
64
- }
1
+ // Moved to `@selvajs/solve/client`, where the Solve Session that hydrates from it
2
+ // now lives. Re-exported here to keep the published `@selvajs/ui/external` sub-path — and
3
+ // the pre-step producer routes that import it working unchanged.
4
+
5
+ export {
6
+ writeExternalValue,
7
+ readExternalValue,
8
+ clearExternalValue,
9
+ getExternalInputs,
10
+ type ExternalValueRef,
11
+ type ExternalInput
12
+ } from '@selvajs/solve/client';
package/src/lib/index.ts CHANGED
@@ -21,9 +21,12 @@ export * from './schema/dynamic-value-list';
21
21
  export * from './schema/traversal';
22
22
  export * from './compute/solving.svelte';
23
23
 
24
- // Solve Session seam (transport-agnostic value/lifecycle state machine + its driver
25
- // interface). Exported so transports outside this package e.g. plugin-ui's WebSocket
24
+ // Solve Session seam. The session itself now lives in `@selvajs/solve/client`
25
+ // (framework-free); `useSolveSession` is this package's Svelte binding, which republishes
26
+ // the session's subscribe() notifications as rune state so its getters read reactively in
27
+ // markup. Re-exported so transports outside this package — e.g. plugin-ui's WebSocket
26
28
  // driver — can satisfy SolveDriver and drive a session. See CONTEXT.md.
29
+ export { useSolveSession } from './compute/useSolveSession.svelte';
27
30
  export {
28
31
  createSolveSession,
29
32
  createRequestResponseDriver,
@@ -31,10 +34,17 @@ export {
31
34
  type SolveSessionArgs,
32
35
  type SolveDriver,
33
36
  type SolveReporter
34
- } from './compute/createSolveSession.svelte';
37
+ } from '@selvajs/solve/client';
35
38
 
36
39
  // External-input transit storage (used by routes that wire pre-step producers)
37
- export * from './external/storage';
40
+ export {
41
+ writeExternalValue,
42
+ readExternalValue,
43
+ clearExternalValue,
44
+ getExternalInputs,
45
+ type ExternalValueRef,
46
+ type ExternalInput
47
+ } from '@selvajs/solve/client';
38
48
 
39
49
  // Contexts & Composables
40
50
  export * from './contexts/footerContext.svelte';
@@ -47,5 +57,5 @@ export { randomId } from './utils/randomId';
47
57
 
48
58
  // UI-specific runtime types (not from schema)
49
59
  export type { ActionButton } from './types/actionButton';
50
- export type { SolveFn, SolveResult } from './types/solveFn';
60
+ export type { SolveFn, SolveResult } from '@selvajs/solve/shared';
51
61
  export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';