@selvajs/ui 5.0.0-beta.1 → 5.0.0-beta.2

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.
@@ -18,7 +18,11 @@
18
18
  // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
19
19
  const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
20
20
 
21
- const getSceneObjects = () => {
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.
25
+ const sceneObjects = $derived.by(() => {
22
26
  void sceneVersion;
23
27
  return scene.children.filter(
24
28
  (obj) =>
@@ -26,20 +30,19 @@
26
30
  !(obj instanceof THREE.Light) &&
27
31
  !HELPER_IDS.has(obj.userData?.id)
28
32
  );
29
- };
33
+ });
30
34
 
31
- const getLayerGroups = () => {
32
- const objects = getSceneObjects();
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
+ const layerGroups = $derived.by(() => {
33
38
  const groups = new SvelteMap<string, THREE.Object3D[]>();
34
-
35
- for (const obj of objects) {
39
+ for (const obj of sceneObjects) {
36
40
  const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
37
41
  if (!groups.has(layer)) groups.set(layer, []);
38
42
  groups.get(layer)!.push(obj);
39
43
  }
40
-
41
44
  return groups;
42
- };
45
+ });
43
46
 
44
47
  let hiddenUuids = new SvelteSet<string>();
45
48
 
@@ -59,8 +62,7 @@
59
62
 
60
63
  const toggleObject = (object: THREE.Object3D) => {
61
64
  if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
62
- const allObjects = getSceneObjects();
63
- const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
65
+ const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
64
66
  const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
65
67
  for (const o of selected) setObjectVisible(o, allHidden);
66
68
  } else {
@@ -104,23 +106,25 @@
104
106
 
105
107
  let searchQuery = $state('');
106
108
 
107
- const getFilteredLayerGroups = () => {
108
- const groups = getLayerGroups();
109
- if (!searchQuery.trim()) return groups;
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;
110
114
  const q = searchQuery.toLowerCase();
111
115
  const filtered = new SvelteMap<string, THREE.Object3D[]>();
112
- for (const [layerName, objects] of groups) {
116
+ for (const [layerName, objects] of layerGroups) {
113
117
  const matchingObjects = layerName.toLowerCase().includes(q)
114
118
  ? objects
115
119
  : objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
116
120
  if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
117
121
  }
118
122
  return filtered;
119
- };
123
+ });
120
124
 
121
125
  const getFlatVisibleUuids = (): string[] => {
122
126
  const result: string[] = [];
123
- for (const [layerName, objects] of getFilteredLayerGroups()) {
127
+ for (const [layerName, objects] of filteredLayerGroups) {
124
128
  if (!collapsedLayers.has(layerName)) {
125
129
  for (const obj of objects) result.push(obj.uuid);
126
130
  }
@@ -182,7 +186,7 @@
182
186
  </div>
183
187
 
184
188
  <div class="py-1 flex-1 overflow-y-auto">
185
- {#each [...getFilteredLayerGroups()] as [layerName, objects] (layerName)}
189
+ {#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
186
190
  {@const layerHidden = isLayerHidden(objects)}
187
191
  {@const layerPartial = isLayerPartial(objects)}
188
192
  {@const collapsed = collapsedLayers.has(layerName)}
@@ -285,12 +289,12 @@
285
289
  {/each}
286
290
 
287
291
  <!-- Empty state -->
288
- {#if getSceneObjects().length === 0}
292
+ {#if sceneObjects.length === 0}
289
293
  <div class="py-12 flex flex-col items-center justify-center text-center">
290
294
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
291
295
  <p class="text-xs text-muted-foreground">{t.noObjects}</p>
292
296
  </div>
293
- {:else if getFilteredLayerGroups().size === 0}
297
+ {:else if filteredLayerGroups.size === 0}
294
298
  <div class="py-12 flex flex-col items-center justify-center text-center">
295
299
  <Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
296
300
  <p class="text-xs text-muted-foreground">
@@ -107,6 +107,7 @@
107
107
  let cameraController: CameraController | null = null;
108
108
  let measureTool: MeasureTool | null = null;
109
109
  let grid: Grid | null = null;
110
+ let applyEdges: ((root: THREE.Object3D) => void) | null = null;
110
111
  let fitToView: (() => void) | null = null;
111
112
  let viewerInitialized = false;
112
113
  let sceneVersion = $state(0);
@@ -143,12 +144,15 @@
143
144
  onMount(() => {
144
145
  if (!canvas) return;
145
146
 
147
+ // Only options that differ from the library defaults. The default look is already 'technical'
148
+ // (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
149
+ // sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
146
150
  const opts: ThreeInitializerOptions = {
151
+ lighting: { enableSunlight: false },
152
+ render: { enableShadows: false },
147
153
  environment: { backgroundColor: config.backgroundColor },
148
- controls: {},
149
154
  // Build the grid so it can be toggled at runtime, but start hidden (off by default).
150
155
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
151
- gizmo: { enabled: false },
152
156
  measure: { enabled: config.showToolsMenu },
153
157
  events: {
154
158
  onMeshMetadataClicked: config.enableMeshClick
@@ -170,6 +174,7 @@
170
174
  measureTool = init.measureTool;
171
175
  grid = init.grid;
172
176
  grid?.setVisible(gridVisible);
177
+ applyEdges = init.applyEdges;
173
178
  fitToView = init.fitToView;
174
179
  projection = init.cameraController.getProjection();
175
180
 
@@ -208,6 +213,9 @@
208
213
  $effect(() => {
209
214
  if (scene && camera && controls) {
210
215
  updateScene(scene, meshes, camera, controls, viewerInitialized);
216
+ // Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
217
+ // each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
218
+ applyEdges?.(scene);
211
219
  untrack(() => sceneVersion++);
212
220
 
213
221
  if (!viewerInitialized && meshes.length > 0) {
@@ -24,7 +24,12 @@ export function createComputeThrottle(computeFn, options = {}) {
24
24
  abortCurrent();
25
25
  currentAbortController = new AbortController();
26
26
  const { signal } = currentAbortController;
27
- const timeoutId = setTimeout(() => currentAbortController?.abort(), timeout);
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);
28
33
  isComputing = true;
29
34
  try {
30
35
  await computeFn(values, signal);
@@ -50,6 +55,10 @@ export function createComputeThrottle(computeFn, options = {}) {
50
55
  }
51
56
  function trigger(values) {
52
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
+ }
53
62
  pendingValues = values;
54
63
  }
55
64
  else {
@@ -123,14 +123,22 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
123
123
  }
124
124
  try {
125
125
  const result = await onSolve(values, signal);
126
- if (signal.aborted)
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');
127
129
  return;
130
+ }
128
131
  memo.set(values, result);
129
132
  getReporter().report(result);
130
133
  }
131
134
  catch (err) {
132
- if (signal.aborted)
135
+ if (signal.aborted) {
136
+ console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
133
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);
134
142
  getReporter().reportError(err instanceof Error ? err.message : String(err));
135
143
  }
136
144
  }, options);
@@ -38,6 +38,9 @@ export function createSolveMemo(max = 16) {
38
38
  // Refresh recency: re-insert at the tail.
39
39
  entries.delete(key);
40
40
  entries.set(key, hit);
41
+ // A memo hit skips the transport entirely, so no other log line fires —
42
+ // this debug line is the only trace it wasn't a fresh solve.
43
+ console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
41
44
  return hit;
42
45
  },
43
46
  set(values, result) {
@@ -49,9 +52,13 @@ export function createSolveMemo(max = 16) {
49
52
  if (oldest === undefined)
50
53
  break;
51
54
  entries.delete(oldest);
55
+ console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
52
56
  }
53
57
  },
54
58
  clear() {
59
+ if (entries.size > 0) {
60
+ console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
61
+ }
55
62
  entries.clear();
56
63
  }
57
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.2",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,11 @@
18
18
  // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
19
19
  const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
20
20
 
21
- const getSceneObjects = () => {
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.
25
+ const sceneObjects = $derived.by(() => {
22
26
  void sceneVersion;
23
27
  return scene.children.filter(
24
28
  (obj) =>
@@ -26,20 +30,19 @@
26
30
  !(obj instanceof THREE.Light) &&
27
31
  !HELPER_IDS.has(obj.userData?.id)
28
32
  );
29
- };
33
+ });
30
34
 
31
- const getLayerGroups = () => {
32
- const objects = getSceneObjects();
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
+ const layerGroups = $derived.by(() => {
33
38
  const groups = new SvelteMap<string, THREE.Object3D[]>();
34
-
35
- for (const obj of objects) {
39
+ for (const obj of sceneObjects) {
36
40
  const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
37
41
  if (!groups.has(layer)) groups.set(layer, []);
38
42
  groups.get(layer)!.push(obj);
39
43
  }
40
-
41
44
  return groups;
42
- };
45
+ });
43
46
 
44
47
  let hiddenUuids = new SvelteSet<string>();
45
48
 
@@ -59,8 +62,7 @@
59
62
 
60
63
  const toggleObject = (object: THREE.Object3D) => {
61
64
  if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
62
- const allObjects = getSceneObjects();
63
- const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
65
+ const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
64
66
  const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
65
67
  for (const o of selected) setObjectVisible(o, allHidden);
66
68
  } else {
@@ -104,23 +106,25 @@
104
106
 
105
107
  let searchQuery = $state('');
106
108
 
107
- const getFilteredLayerGroups = () => {
108
- const groups = getLayerGroups();
109
- if (!searchQuery.trim()) return groups;
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;
110
114
  const q = searchQuery.toLowerCase();
111
115
  const filtered = new SvelteMap<string, THREE.Object3D[]>();
112
- for (const [layerName, objects] of groups) {
116
+ for (const [layerName, objects] of layerGroups) {
113
117
  const matchingObjects = layerName.toLowerCase().includes(q)
114
118
  ? objects
115
119
  : objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
116
120
  if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
117
121
  }
118
122
  return filtered;
119
- };
123
+ });
120
124
 
121
125
  const getFlatVisibleUuids = (): string[] => {
122
126
  const result: string[] = [];
123
- for (const [layerName, objects] of getFilteredLayerGroups()) {
127
+ for (const [layerName, objects] of filteredLayerGroups) {
124
128
  if (!collapsedLayers.has(layerName)) {
125
129
  for (const obj of objects) result.push(obj.uuid);
126
130
  }
@@ -182,7 +186,7 @@
182
186
  </div>
183
187
 
184
188
  <div class="py-1 flex-1 overflow-y-auto">
185
- {#each [...getFilteredLayerGroups()] as [layerName, objects] (layerName)}
189
+ {#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
186
190
  {@const layerHidden = isLayerHidden(objects)}
187
191
  {@const layerPartial = isLayerPartial(objects)}
188
192
  {@const collapsed = collapsedLayers.has(layerName)}
@@ -285,12 +289,12 @@
285
289
  {/each}
286
290
 
287
291
  <!-- Empty state -->
288
- {#if getSceneObjects().length === 0}
292
+ {#if sceneObjects.length === 0}
289
293
  <div class="py-12 flex flex-col items-center justify-center text-center">
290
294
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
291
295
  <p class="text-xs text-muted-foreground">{t.noObjects}</p>
292
296
  </div>
293
- {:else if getFilteredLayerGroups().size === 0}
297
+ {:else if filteredLayerGroups.size === 0}
294
298
  <div class="py-12 flex flex-col items-center justify-center text-center">
295
299
  <Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
296
300
  <p class="text-xs text-muted-foreground">
@@ -107,6 +107,7 @@
107
107
  let cameraController: CameraController | null = null;
108
108
  let measureTool: MeasureTool | null = null;
109
109
  let grid: Grid | null = null;
110
+ let applyEdges: ((root: THREE.Object3D) => void) | null = null;
110
111
  let fitToView: (() => void) | null = null;
111
112
  let viewerInitialized = false;
112
113
  let sceneVersion = $state(0);
@@ -143,12 +144,15 @@
143
144
  onMount(() => {
144
145
  if (!canvas) return;
145
146
 
147
+ // Only options that differ from the library defaults. The default look is already 'technical'
148
+ // (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
149
+ // sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
146
150
  const opts: ThreeInitializerOptions = {
151
+ lighting: { enableSunlight: false },
152
+ render: { enableShadows: false },
147
153
  environment: { backgroundColor: config.backgroundColor },
148
- controls: {},
149
154
  // Build the grid so it can be toggled at runtime, but start hidden (off by default).
150
155
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
151
- gizmo: { enabled: false },
152
156
  measure: { enabled: config.showToolsMenu },
153
157
  events: {
154
158
  onMeshMetadataClicked: config.enableMeshClick
@@ -170,6 +174,7 @@
170
174
  measureTool = init.measureTool;
171
175
  grid = init.grid;
172
176
  grid?.setVisible(gridVisible);
177
+ applyEdges = init.applyEdges;
173
178
  fitToView = init.fitToView;
174
179
  projection = init.cameraController.getProjection();
175
180
 
@@ -208,6 +213,9 @@
208
213
  $effect(() => {
209
214
  if (scene && camera && controls) {
210
215
  updateScene(scene, meshes, camera, controls, viewerInitialized);
216
+ // Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
217
+ // each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
218
+ applyEdges?.(scene);
211
219
  untrack(() => sceneVersion++);
212
220
 
213
221
  if (!viewerInitialized && meshes.length > 0) {
@@ -49,7 +49,12 @@ export function createComputeThrottle<T>(
49
49
 
50
50
  currentAbortController = new AbortController();
51
51
  const { signal } = currentAbortController;
52
- const timeoutId = setTimeout(() => currentAbortController?.abort(), timeout);
52
+ const timeoutId = setTimeout(() => {
53
+ // Cleared in `finally` on every other path, so firing means a genuine
54
+ // timeout — the only signal for it (the abort itself is swallowed below).
55
+ console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
56
+ currentAbortController?.abort();
57
+ }, timeout);
53
58
 
54
59
  isComputing = true;
55
60
  try {
@@ -76,6 +81,10 @@ export function createComputeThrottle<T>(
76
81
 
77
82
  function trigger(values: T) {
78
83
  if (isComputing) {
84
+ if (pendingValues !== null) {
85
+ // Latest-wins: the previously-queued values are dropped, not solved.
86
+ console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
87
+ }
79
88
  pendingValues = values;
80
89
  } else {
81
90
  executeCompute(values);
@@ -203,11 +203,21 @@ export function createRequestResponseDriver(
203
203
  }
204
204
  try {
205
205
  const result = await onSolve(values, signal);
206
- if (signal.aborted) return;
206
+ if (signal.aborted) {
207
+ // Discarded on purpose (superseded/cancelled) — never memoized or reported.
208
+ console.debug('[Compute/session] solve completed after abort — result discarded');
209
+ return;
210
+ }
207
211
  memo.set(values, result);
208
212
  getReporter().report(result);
209
213
  } catch (err) {
210
- if (signal.aborted) return;
214
+ if (signal.aborted) {
215
+ console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
216
+ return;
217
+ }
218
+ // reportError only sets reactive state; without this line a transport
219
+ // failure leaves no console trace at all.
220
+ console.warn('[Compute/session] solve failed:', err);
211
221
  getReporter().reportError(err instanceof Error ? err.message : String(err));
212
222
  }
213
223
  }, options);
@@ -50,6 +50,9 @@ export function createSolveMemo(max = 16): SolveMemo {
50
50
  // Refresh recency: re-insert at the tail.
51
51
  entries.delete(key);
52
52
  entries.set(key, hit);
53
+ // A memo hit skips the transport entirely, so no other log line fires —
54
+ // this debug line is the only trace it wasn't a fresh solve.
55
+ console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
53
56
  return hit;
54
57
  },
55
58
  set(values, result) {
@@ -60,9 +63,13 @@ export function createSolveMemo(max = 16): SolveMemo {
60
63
  const oldest = entries.keys().next().value;
61
64
  if (oldest === undefined) break;
62
65
  entries.delete(oldest);
66
+ console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
63
67
  }
64
68
  },
65
69
  clear() {
70
+ if (entries.size > 0) {
71
+ console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
72
+ }
66
73
  entries.clear();
67
74
  }
68
75
  };