@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
package/src/lib/public.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  // Scope: the compute-app SDK — everything an external host app needs to embed a
9
9
  // Grasshopper-driven app (ComputeApp), drive solves, and wire pre-step
10
10
  // producers. Verified against real external host apps: they import
11
- // ComputeApp + its types, the solve seam, and external/storage. Nothing else.
11
+ // ComputeApp + its types, the solve seam, and the external-input storage
12
+ // helpers. Nothing else.
12
13
  //
13
14
  // Deliberately NOT public: design-system primitives (Button, Card, Dialog, …),
14
15
  // page-chrome layout (AppShell, SideNav, …), toast/Toaster, ThemeSwitcher,
@@ -44,8 +45,11 @@ export {
44
45
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
45
46
 
46
47
  // Solve Session seam (transport-agnostic value/lifecycle state machine + its
47
- // driver interface). Exported so transports outside this package can satisfy
48
- // SolveDriver and drive a session. See CONTEXT.md.
48
+ // driver interface). The session lives in `@selvajs/solve/client` and is
49
+ // framework-free; `useSolveSession` is the Svelte binding that makes its getters
50
+ // read reactively inside components. A host embedding <ComputeApp> needs neither —
51
+ // both are re-exported for hosts driving a session themselves. See CONTEXT.md.
52
+ export { useSolveSession } from './compute/useSolveSession.svelte';
49
53
  export {
50
54
  createSolveSession,
51
55
  createRequestResponseDriver,
@@ -53,14 +57,21 @@ export {
53
57
  type SolveSessionArgs,
54
58
  type SolveDriver,
55
59
  type SolveReporter
56
- } from './compute/createSolveSession.svelte';
60
+ } from '@selvajs/solve/client';
57
61
 
58
62
  // Client-slot context type (host apps render their own cell for client-sourced
59
63
  // inputs, and may commit a value back via ClientSlotArgs.onValueChange).
60
64
  export type { ClientSlotArgs, ClientSlot } from './contexts/clientSlotContext.svelte';
61
65
 
62
66
  // Pre-step producer transit storage (host apps wire producers via these).
63
- export * from './external/storage';
67
+ export {
68
+ writeExternalValue,
69
+ readExternalValue,
70
+ clearExternalValue,
71
+ getExternalInputs,
72
+ type ExternalValueRef,
73
+ type ExternalInput
74
+ } from '@selvajs/solve/client';
64
75
 
65
76
  // Schema utilities a ComputeApp host reasonably needs to read/shape values.
66
77
  export * from './schema/defaults';
@@ -69,5 +80,5 @@ export * from './schema/dynamic-value-list';
69
80
 
70
81
  // UI-facing runtime types (not from schema)
71
82
  export type { ActionButton } from './types/actionButton';
72
- export type { SolveFn, SolveResult } from './types/solveFn';
83
+ export type { SolveFn, SolveResult } from '@selvajs/solve/shared';
73
84
  export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
@@ -1,24 +0,0 @@
1
- /**
2
- * Compute throttle utility for managing async compute requests.
3
- *
4
- * - Only one request in-flight at a time
5
- * - Latest values always win (no queue)
6
- * - AbortController support to cancel stale requests
7
- * - Configurable timeout with automatic abort
8
- */
9
- interface ComputeThrottleOptions {
10
- timeout?: number;
11
- }
12
- /**
13
- * Creates a throttled compute handler that keeps only one request in-flight.
14
- * If a new value arrives while one is running, it overwrites the single pending
15
- * slot — older pending values are dropped. The pending value runs once the
16
- * current request finishes (or aborts).
17
- */
18
- export declare function createComputeThrottle<T>(computeFn: (values: T, signal: AbortSignal) => Promise<void>, options?: ComputeThrottleOptions): {
19
- trigger: (values: T) => void;
20
- readonly isComputing: boolean;
21
- readonly hasPending: boolean;
22
- cancel: () => void;
23
- };
24
- export {};
@@ -1,82 +0,0 @@
1
- /**
2
- * Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
3
- * Used only by callers without a deployment-specific limit (e.g. plugin-ui
4
- * over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
5
- * server config via `ComputeApp`'s `solveTimeoutMs` prop.
6
- */
7
- const DEFAULT_TIMEOUT_MS = 60_000;
8
- /**
9
- * Creates a throttled compute handler that keeps only one request in-flight.
10
- * If a new value arrives while one is running, it overwrites the single pending
11
- * slot — older pending values are dropped. The pending value runs once the
12
- * current request finishes (or aborts).
13
- */
14
- export function createComputeThrottle(computeFn, options = {}) {
15
- const { timeout = DEFAULT_TIMEOUT_MS } = options;
16
- let isComputing = $state(false);
17
- let pendingValues = $state(null);
18
- let currentAbortController = null;
19
- function abortCurrent() {
20
- currentAbortController?.abort();
21
- currentAbortController = null;
22
- }
23
- async function executeCompute(values) {
24
- abortCurrent();
25
- currentAbortController = new AbortController();
26
- const { signal } = currentAbortController;
27
- const timeoutId = setTimeout(() => {
28
- // Cleared in `finally` on every other path, so firing means a genuine
29
- // timeout — the only signal for it (the abort itself is swallowed below).
30
- console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
31
- currentAbortController?.abort();
32
- }, timeout);
33
- isComputing = true;
34
- try {
35
- await computeFn(values, signal);
36
- }
37
- catch (err) {
38
- // AbortError is expected (timeout or cancel). Non-abort errors must be
39
- // handled inside computeFn — re-throwing here would produce an unhandled
40
- // rejection because executeCompute is always called fire-and-forget.
41
- if (!(err instanceof Error) || (err.name !== 'AbortError' && err.name !== 'TimeoutError')) {
42
- console.error('[computeThrottle] unhandled error in computeFn:', err);
43
- }
44
- }
45
- finally {
46
- clearTimeout(timeoutId);
47
- isComputing = false;
48
- currentAbortController = null;
49
- if (pendingValues !== null) {
50
- const next = pendingValues;
51
- pendingValues = null;
52
- executeCompute(next);
53
- }
54
- }
55
- }
56
- function trigger(values) {
57
- if (isComputing) {
58
- if (pendingValues !== null) {
59
- // Latest-wins: the previously-queued values are dropped, not solved.
60
- console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
61
- }
62
- pendingValues = values;
63
- }
64
- else {
65
- executeCompute(values);
66
- }
67
- }
68
- function cancel() {
69
- pendingValues = null;
70
- abortCurrent();
71
- }
72
- return {
73
- trigger,
74
- get isComputing() {
75
- return isComputing;
76
- },
77
- get hasPending() {
78
- return pendingValues !== null;
79
- },
80
- cancel
81
- };
82
- }
@@ -1,66 +0,0 @@
1
- import type { UISchema } from '@selvajs/schemas';
2
- import type { SolveFn, SolveResult } from '../types/solveFn';
3
- /**
4
- * The transport behind a Solve Session. Knows how to start and cancel a solve and
5
- * reports its in-flight state. It does NOT return outputs — those come back via the
6
- * session's report() so push transports (WebSocket) fit without contortion.
7
- */
8
- export interface SolveDriver {
9
- solve(values: Record<string, unknown>): void;
10
- cancel(): void;
11
- readonly isSolving: boolean;
12
- /**
13
- * Drops any cached solve results the driver holds. Optional — only drivers with a
14
- * client-side memo (the request/response driver) implement it. Called on rebuild so a
15
- * definition swap can't serve a stale result from a prior definition's input space.
16
- */
17
- clearCache?(): void;
18
- }
19
- export interface SolveSession {
20
- readonly values: Record<string, unknown>;
21
- readonly error: string;
22
- readonly computeErrors: string[];
23
- readonly computeWarnings: string[];
24
- readonly meshes: unknown[];
25
- readonly hasPendingChanges: boolean;
26
- readonly hasNeverSolved: boolean;
27
- readonly isSolving: boolean;
28
- /**
29
- * Records a value change and dispatches a solve unless the schema is manual-solve.
30
- * `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
31
- * value list pruning a vanished selection), so the output can't lag behind the new value.
32
- */
33
- setValue(id: string, value: unknown, forceSolve?: boolean): void;
34
- /** Explicit "calculate" — dispatches a solve with the current values. */
35
- solve(): void;
36
- /** Merges incoming values, then solves (auto) or marks dirty (manual). */
37
- loadValues(incoming: Record<string, unknown>): void;
38
- /** Re-seed for a new active definition: rebuild values, clear outputs, gate the solve. */
39
- rebuild(schema: UISchema, scopeKey: string): void;
40
- /** Feed a completed solve result back into the session (called by the driver/host). */
41
- report(result: SolveResult): void;
42
- /** Report a solve failure (transport/solver error) — surfaces in `error`. */
43
- reportError(message: string): void;
44
- }
45
- export interface SolveSessionArgs {
46
- schema: UISchema;
47
- scopeKey: string;
48
- driver: SolveDriver;
49
- }
50
- export declare function createSolveSession(args: SolveSessionArgs): SolveSession;
51
- /** The slice of a SolveSession a driver feeds completed/failed solves back into. */
52
- export interface SolveReporter {
53
- report(result: SolveResult): void;
54
- reportError(message: string): void;
55
- }
56
- /**
57
- * Request/response Solve Driver: wraps createComputeThrottle around a SolveFn and feeds
58
- * the resolved result back through the reporter. One solve in flight at a time; the
59
- * latest triggered values win. Used by ComputeApp (Rhino.Compute over HTTP).
60
- *
61
- * Because the session and driver reference each other, the host passes the reporter
62
- * lazily (`() => session`) so it can construct the session with the driver in hand.
63
- */
64
- export declare function createRequestResponseDriver(onSolve: SolveFn, getReporter: () => SolveReporter, options?: {
65
- timeout?: number;
66
- }): SolveDriver;
@@ -1,159 +0,0 @@
1
- // Reactive Solve Session (see CONTEXT.md): the $state-backed shell over the pure
2
- // transition logic in solve-session-core.ts. It owns the live values/flags, delegates
3
- // every transition to the core, and drives solves through a transport-agnostic
4
- // SolveDriver. A completed solve re-enters via report().
5
- import { readExternalValue } from '../external/storage';
6
- import { createComputeThrottle } from './computeThrottle.svelte';
7
- import { createSolveMemo } from './solveMemo';
8
- import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
9
- export function createSolveSession(args) {
10
- let currentSchema = args.schema;
11
- const flags = makeInitialFlags(currentSchema?.instanceSolve);
12
- const state = $state({
13
- values: buildInitialValues(currentSchema, args.scopeKey, readExternalValue),
14
- error: '',
15
- computeErrors: [],
16
- computeWarnings: [],
17
- meshes: [],
18
- pendingValues: {},
19
- hasPendingChanges: flags.hasPendingChanges,
20
- hasNeverSolved: flags.hasNeverSolved
21
- });
22
- function dispatch() {
23
- // Input values only: outputs merged into state.values (for widgets that read
24
- // them, e.g. dynamic value lists) must not be echoed back to the transport.
25
- args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
26
- }
27
- return {
28
- get values() {
29
- return state.values;
30
- },
31
- get error() {
32
- return state.error;
33
- },
34
- get computeErrors() {
35
- return state.computeErrors;
36
- },
37
- get computeWarnings() {
38
- return state.computeWarnings;
39
- },
40
- get meshes() {
41
- return state.meshes;
42
- },
43
- get hasPendingChanges() {
44
- return state.hasPendingChanges;
45
- },
46
- get hasNeverSolved() {
47
- return state.hasNeverSolved;
48
- },
49
- get isSolving() {
50
- return args.driver.isSolving;
51
- },
52
- setValue(id, value, forceSolve = false) {
53
- const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
54
- if (shouldSolve || forceSolve) {
55
- // A forced solve reconciles the deferred output; clear the dirty flags it raised.
56
- if (forceSolve && !shouldSolve) {
57
- state.pendingValues = {};
58
- state.hasPendingChanges = false;
59
- }
60
- dispatch();
61
- }
62
- },
63
- solve() {
64
- dispatch();
65
- },
66
- loadValues(incoming) {
67
- Object.assign(state.values, incoming);
68
- if (currentSchema?.instanceSolve !== false) {
69
- dispatch();
70
- }
71
- else {
72
- state.hasPendingChanges = true;
73
- }
74
- },
75
- rebuild(schema, scopeKey) {
76
- currentSchema = schema;
77
- // Drop the driver's result memo: the new definition has its own input space, so
78
- // a matching input key from the prior definition must not serve its stale result.
79
- args.driver.clearCache?.();
80
- state.meshes = [];
81
- state.error = '';
82
- state.computeErrors = [];
83
- state.computeWarnings = [];
84
- state.pendingValues = {};
85
- state.values = buildInitialValues(schema, scopeKey, readExternalValue);
86
- const f = makeInitialFlags(schema?.instanceSolve);
87
- state.hasPendingChanges = f.hasPendingChanges;
88
- state.hasNeverSolved = f.hasNeverSolved;
89
- if (schema && Object.keys(state.values).length > 0 && schema.instanceSolve !== false) {
90
- dispatch();
91
- }
92
- },
93
- report(result) {
94
- applySolveResult(state, result);
95
- },
96
- reportError(message) {
97
- state.error = message;
98
- }
99
- };
100
- }
101
- /**
102
- * Request/response Solve Driver: wraps createComputeThrottle around a SolveFn and feeds
103
- * the resolved result back through the reporter. One solve in flight at a time; the
104
- * latest triggered values win. Used by ComputeApp (Rhino.Compute over HTTP).
105
- *
106
- * Because the session and driver reference each other, the host passes the reporter
107
- * lazily (`() => session`) so it can construct the session with the driver in hand.
108
- */
109
- export function createRequestResponseDriver(onSolve, getReporter, options = {}) {
110
- // M2: a small LRU memoizing completed solves by their input values. A slider dragged
111
- // back to a value already solved this session reports instantly without a network
112
- // round-trip. The check lives inside the throttled computeFn so the throttle's
113
- // latest-wins ordering still holds — a hit only serves after the throttle picks these
114
- // values as the ones to run.
115
- const memo = createSolveMemo();
116
- const throttle = createComputeThrottle(async (values, signal) => {
117
- const cached = memo.get(values);
118
- if (cached !== undefined) {
119
- if (signal.aborted)
120
- return;
121
- getReporter().report(cached);
122
- return;
123
- }
124
- try {
125
- const result = await onSolve(values, signal);
126
- if (signal.aborted) {
127
- // Discarded on purpose (superseded/cancelled) — never memoized or reported.
128
- console.debug('[Compute/session] solve completed after abort — result discarded');
129
- return;
130
- }
131
- memo.set(values, result);
132
- getReporter().report(result);
133
- }
134
- catch (err) {
135
- if (signal.aborted) {
136
- console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
137
- return;
138
- }
139
- // reportError only sets reactive state; without this line a transport
140
- // failure leaves no console trace at all.
141
- console.warn('[Compute/session] solve failed:', err);
142
- getReporter().reportError(err instanceof Error ? err.message : String(err));
143
- }
144
- }, options);
145
- return {
146
- solve(values) {
147
- throttle.trigger(values);
148
- },
149
- cancel() {
150
- throttle.cancel();
151
- },
152
- get isSolving() {
153
- return throttle.isComputing;
154
- },
155
- clearCache() {
156
- memo.clear();
157
- }
158
- };
159
- }
@@ -1,54 +0,0 @@
1
- import type { UISchema } from '@selvajs/schemas';
2
- import { type ExternalValueRef } from '../external/storage';
3
- import type { SolveResult } from '../types/solveFn';
4
- export interface SolveSessionState {
5
- values: Record<string, unknown>;
6
- error: string;
7
- computeErrors: string[];
8
- computeWarnings: string[];
9
- meshes: unknown[];
10
- /** Values changed since the last solve, in manual (instanceSolve === false) mode. */
11
- pendingValues: Record<string, unknown>;
12
- hasPendingChanges: boolean;
13
- hasNeverSolved: boolean;
14
- }
15
- /** Reads a previously produced client-sourced value, or undefined if absent. */
16
- export type ExternalReader = (ref: ExternalValueRef) => unknown | undefined;
17
- /**
18
- * Builds the initial `values` map for a schema. Non-client inputs get their declared
19
- * default (falling back to the paramType default); client-sourced inputs are hydrated
20
- * from `read` and left absent when no stored value exists so the missing-inputs panel
21
- * can detect them. Outputs are seeded to null.
22
- */
23
- export declare function buildInitialValues(schema: UISchema, scopeKey: string, read: ExternalReader): Record<string, unknown>;
24
- /**
25
- * Initial lifecycle flags. Manual-solve schemas (instanceSolve === false) start dirty
26
- * so the user must explicitly calculate; auto-solve schemas start clean.
27
- */
28
- export declare function makeInitialFlags(instanceSolve: boolean | undefined): {
29
- hasPendingChanges: boolean;
30
- hasNeverSolved: boolean;
31
- };
32
- /**
33
- * Records a single value change and decides whether it should dispatch a solve now.
34
- * Auto-solve mode dispatches immediately; manual mode defers (records the pending value
35
- * and raises the dirty flag) and never dispatches.
36
- */
37
- export declare function applyValueChange(state: SolveSessionState, id: string, value: unknown, instanceSolve: boolean | undefined): {
38
- state: SolveSessionState;
39
- shouldSolve: boolean;
40
- };
41
- /**
42
- * Projects the session's live values down to solve INPUTS. Solve outputs are merged
43
- * into the same values map after each solve (applySolveResult) so widgets like
44
- * dynamic value lists can read them — but they are not solve inputs, and echoing
45
- * them back to the driver re-uploads potentially MB-sized payloads (a measured
46
- * 6.4 MB options list) that no backend reads. Every transport gets this projection
47
- * for free by going through the session's dispatch.
48
- */
49
- export declare function pickInputValues(schema: UISchema | undefined, values: Record<string, unknown>): Record<string, unknown>;
50
- /**
51
- * Merges a reported solve result into the state and clears the post-solve lifecycle
52
- * flags. Missing result arrays are treated as empty.
53
- */
54
- export declare function applySolveResult(state: SolveSessionState, result: SolveResult): SolveSessionState;
@@ -1,87 +0,0 @@
1
- // Pure, framework-free transition logic for a Solve Session (see CONTEXT.md).
2
- //
3
- // This module holds the value/lifecycle state machine with no Svelte runes and no
4
- // transport: how values are seeded (including client-sourced hydration), when a value
5
- // change should dispatch a solve vs. defer it, and what a reported solve result does to
6
- // the flags. The reactive wrapper in createSolveSession.svelte.ts is a thin shell over
7
- // these functions; everything testable lives here.
8
- import { getDefaultValue } from '../schema/defaults';
9
- import { getExternalInputs } from '../external/storage';
10
- /**
11
- * Builds the initial `values` map for a schema. Non-client inputs get their declared
12
- * default (falling back to the paramType default); client-sourced inputs are hydrated
13
- * from `read` and left absent when no stored value exists so the missing-inputs panel
14
- * can detect them. Outputs are seeded to null.
15
- */
16
- export function buildInitialValues(schema, scopeKey, read) {
17
- const clientSet = new Set(getExternalInputs(schema).map((e) => e.paramId));
18
- const values = {};
19
- for (const input of schema.inputs) {
20
- if (clientSet.has(input.id)) {
21
- const stored = read({ scopeKey, inputId: input.id });
22
- if (stored !== undefined)
23
- values[input.id] = stored;
24
- continue;
25
- }
26
- values[input.id] = input.default ?? getDefaultValue(input.paramType);
27
- }
28
- for (const output of schema.outputs) {
29
- values[output.id] = null;
30
- }
31
- return values;
32
- }
33
- /**
34
- * Initial lifecycle flags. Manual-solve schemas (instanceSolve === false) start dirty
35
- * so the user must explicitly calculate; auto-solve schemas start clean.
36
- */
37
- export function makeInitialFlags(instanceSolve) {
38
- const manual = instanceSolve === false;
39
- return { hasPendingChanges: manual, hasNeverSolved: manual };
40
- }
41
- /**
42
- * Records a single value change and decides whether it should dispatch a solve now.
43
- * Auto-solve mode dispatches immediately; manual mode defers (records the pending value
44
- * and raises the dirty flag) and never dispatches.
45
- */
46
- export function applyValueChange(state, id, value, instanceSolve) {
47
- state.values[id] = value;
48
- if (instanceSolve === false) {
49
- state.pendingValues[id] = value;
50
- state.hasPendingChanges = true;
51
- return { state, shouldSolve: false };
52
- }
53
- return { state, shouldSolve: true };
54
- }
55
- /**
56
- * Projects the session's live values down to solve INPUTS. Solve outputs are merged
57
- * into the same values map after each solve (applySolveResult) so widgets like
58
- * dynamic value lists can read them — but they are not solve inputs, and echoing
59
- * them back to the driver re-uploads potentially MB-sized payloads (a measured
60
- * 6.4 MB options list) that no backend reads. Every transport gets this projection
61
- * for free by going through the session's dispatch.
62
- */
63
- export function pickInputValues(schema, values) {
64
- if (!schema?.inputs)
65
- return values;
66
- const picked = {};
67
- for (const input of schema.inputs) {
68
- if (input.id in values)
69
- picked[input.id] = values[input.id];
70
- }
71
- return picked;
72
- }
73
- /**
74
- * Merges a reported solve result into the state and clears the post-solve lifecycle
75
- * flags. Missing result arrays are treated as empty.
76
- */
77
- export function applySolveResult(state, result) {
78
- state.error = '';
79
- state.computeErrors = result.errors ?? [];
80
- state.computeWarnings = result.warnings ?? [];
81
- state.meshes = result.meshes ?? [];
82
- Object.assign(state.values, result.outputs);
83
- state.pendingValues = {};
84
- state.hasPendingChanges = false;
85
- state.hasNeverSolved = false;
86
- return state;
87
- }
@@ -1,22 +0,0 @@
1
- import type { SolveResult } from '../types/solveFn';
2
- /**
3
- * Deterministic string key for a set of input values. Object keys are sorted at every
4
- * level so two logically-equal inputs (built in different key order) collide, matching
5
- * the server's stable-input keying intent. Values are plain JSON (numbers, strings,
6
- * booleans, arrays) — the projected solve inputs never contain functions or cycles.
7
- */
8
- export declare function stableInputKey(values: Record<string, unknown>): string;
9
- export interface SolveMemo {
10
- /** Returns a previously stored result for these inputs, or undefined on a miss. */
11
- get(values: Record<string, unknown>): SolveResult | undefined;
12
- /** Records a completed solve result under its input key (evicting the LRU tail). */
13
- set(values: Record<string, unknown>, result: SolveResult): void;
14
- /** Drops every entry — called when the active definition changes. */
15
- clear(): void;
16
- }
17
- /**
18
- * A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
19
- * default is deliberately small — this targets the tight slider-scrub loop, not a durable
20
- * cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
21
- */
22
- export declare function createSolveMemo(max?: number): SolveMemo;
@@ -1,124 +0,0 @@
1
- // Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
2
- // the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
3
- // back to a value already solved this session returns instantly without a network
4
- // round-trip — killing slider-scrub storms before they leave the browser. It pairs with
5
- // the throttle's latest-wins abort: the memo only serves values that fully solved, so a
6
- // hit is always a complete result.
7
- //
8
- // GPU ownership (audit C1): a SolveResult carries live three.js objects, and the viewer
9
- // takes ownership of every mesh array it renders — `updateScene` disposes the previous
10
- // content on the next update. So the memo can neither hand out its own instances (they'd
11
- // be disposed under it, then re-added dead on the next hit) nor drop entries silently
12
- // (their GPU buffers would leak). It therefore keeps private copies, serves a fresh clone
13
- // per hit, and disposes an entry whenever it leaves the map.
14
- import * as THREE from 'three';
15
- /**
16
- * Deterministic string key for a set of input values. Object keys are sorted at every
17
- * level so two logically-equal inputs (built in different key order) collide, matching
18
- * the server's stable-input keying intent. Values are plain JSON (numbers, strings,
19
- * booleans, arrays) — the projected solve inputs never contain functions or cycles.
20
- */
21
- export function stableInputKey(values) {
22
- return serialize(values);
23
- }
24
- function serialize(value) {
25
- if (value === null || typeof value !== 'object')
26
- return JSON.stringify(value) ?? 'null';
27
- if (Array.isArray(value))
28
- return `[${value.map(serialize).join(',')}]`;
29
- const obj = value;
30
- const keys = Object.keys(obj).sort();
31
- return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
32
- }
33
- /**
34
- * Deep-clone a solve's scene objects so the caller owns them outright.
35
- *
36
- * `Object3D.clone()` copies the transform hierarchy but SHARES `geometry` and `material`
37
- * by reference — which is exactly the aliasing that makes a naive clone useless here, so
38
- * geometry is copied explicitly. Materials are deliberately left shared: the viewer's
39
- * `clearScene` skips disposing anything in its SHARED_MATERIALS set (module-scope
40
- * singletons reused across solves), and per-mesh materials are cheap to recreate but
41
- * expensive to re-compile as new shader programs.
42
- */
43
- function cloneSceneObjects(meshes) {
44
- return meshes.map((root) => {
45
- const copy = root.clone(true);
46
- const sources = [];
47
- root.traverse((child) => sources.push(child));
48
- let i = 0;
49
- copy.traverse((child) => {
50
- const source = sources[i++];
51
- const target = child;
52
- if (source.geometry)
53
- target.geometry = source.geometry.clone();
54
- });
55
- return copy;
56
- });
57
- }
58
- /**
59
- * Release an entry's GPU buffers. Mirrors `clearScene`'s traversal, minus materials —
60
- * the memo never owns those (see {@link cloneSceneObjects}), so disposing one here would
61
- * free a singleton still referenced by live scene content.
62
- */
63
- function disposeSceneObjects(result) {
64
- result.meshes?.forEach((root) => root.traverse((child) => {
65
- child.geometry?.dispose();
66
- }));
67
- }
68
- /**
69
- * A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
70
- * default is deliberately small — this targets the tight slider-scrub loop, not a durable
71
- * cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
72
- */
73
- export function createSolveMemo(max = 16) {
74
- const entries = new Map();
75
- /** Drop an entry and release its GPU buffers. No-op when the key is absent. */
76
- function evict(key) {
77
- const entry = entries.get(key);
78
- if (entry === undefined)
79
- return;
80
- entries.delete(key);
81
- disposeSceneObjects(entry);
82
- }
83
- return {
84
- get(values) {
85
- const key = stableInputKey(values);
86
- const hit = entries.get(key);
87
- if (hit === undefined)
88
- return undefined;
89
- // Refresh recency: re-insert at the tail.
90
- entries.delete(key);
91
- entries.set(key, hit);
92
- // A memo hit skips the transport entirely, so no other log line fires —
93
- // this line is the only trace it wasn't a fresh solve.
94
- console.info(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
95
- // Clone on the way out: the viewer disposes what it renders, so the retained
96
- // entry must never be the instance handed to it (audit C1).
97
- if (!hit.meshes?.length)
98
- return hit;
99
- return { ...hit, meshes: cloneSceneObjects(hit.meshes) };
100
- },
101
- set(values, result) {
102
- const key = stableInputKey(values);
103
- // Overwriting a key strands the old value's buffers unless it's disposed first.
104
- evict(key);
105
- // Store a private copy for the same reason `get` clones: the caller reports this
106
- // same object to the viewer, which will dispose it on the next scene update.
107
- entries.set(key, result.meshes?.length ? { ...result, meshes: cloneSceneObjects(result.meshes) } : result);
108
- while (entries.size > max) {
109
- const oldest = entries.keys().next().value;
110
- if (oldest === undefined)
111
- break;
112
- evict(oldest);
113
- console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
114
- }
115
- },
116
- clear() {
117
- if (entries.size > 0) {
118
- console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
119
- }
120
- entries.forEach(disposeSceneObjects);
121
- entries.clear();
122
- }
123
- };
124
- }