@kupcad/ui 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,10 +76,12 @@ For Svelte, the component around it (`@kupcad/ui`'s `Viewer`):
76
76
  what is drawn there, so the mark shows on a part of any colour). The face is what its feature edges
77
77
  bound (`faces.js`: triangles joined where they meet at less than the edges' 20°), flat when it
78
78
  faces one way (its area in mm², its way, and `+Z (top)` when along an axis) or curved (its
79
- area). Which triangles meet which is found once a mesh, as the pointer first meets it, in typed
80
- arrays (a third of a second for a million triangles), and each face once, its area kept while
81
- the mesh doesn't move. A ghost, seen through, is under nothing, and neither is what a section
82
- cut away.
79
+ area). Which triangles meet which is found once a mesh, when its edges are drawn, in typed
80
+ arrays (`topology.js`, a fifth of a second for a million triangles; three.js's `EdgesGeometry`
81
+ took eight times as long), so the edges and the faces' borders are one; the triangle under the
82
+ pointer is found through a bounds tree (`hits.js`, three-mesh-bvh), and each face once, its
83
+ area kept while the mesh doesn't move. A ghost, seen through, is under nothing, and neither is
84
+ what a section cut away.
83
85
  * **The view cube** (`ViewCube`, which the Svelte component puts in its corner unless
84
86
  `cube={false}`) is turned as the camera is; its faces (labelled TOP, FRONT, RIGHT, ..., each
85
87
  upright from its view), edges and corners, 26 ways to look, take the camera there when
@@ -98,10 +100,14 @@ For Svelte, the component around it (`@kupcad/ui`'s `Viewer`):
98
100
  Cmd it turns the view, as a drag does. Ctrl is a pinch's: a browser sends one as the wheel
99
101
  with Ctrl, so Ctrl and the wheel zoom too.
100
102
  * **Rendering** is on demand: when the model, a setting or the camera changes, once a frame. A
101
- solid shows its feature edges; x-ray sees through it; a wireframe is the edges alone.
103
+ solid shows its feature edges; x-ray sees through it; a wireframe is the edges alone. A
104
+ drawing's shapes are drawn in the SVG's order, each over the floor and the ones before it, as
105
+ SVG draws them (at z = 0 all, depth can't order them), a see-through one too; the axes over
106
+ it.
102
107
  * **A section** clips every material of a solid's model at its plane and caps the cut where it
103
108
  runs inside a solid (three.js's stencil technique), but in a wireframe; a drawing isn't cut. A
104
- part cut away isn't picked.
109
+ face at the offset is kept, so a cut at the bottom shows the footprint and one at the top the
110
+ top face. A part cut away isn't picked.
105
111
  * **Draco:** a model built with `--draco` needs `dracoDecoderPath`, where three.js's decoder files
106
112
  are; the decoder is loaded only when a model needs it.
107
113
  * **Themes** are colours (`LIGHT`, `DARK`, or a host's own, as the VS Code webview maps its
@@ -154,7 +160,8 @@ webview and the desktop app to the language server.
154
160
  Edges, Axes, Grid and Plate toggles; Fit, Persp, and the Iso, Top, Front and Right views, the
155
161
  one looked from pressed, a turn by hand pressing none), the model's parts in its bottom half,
156
162
  each with its colour and copies (a click on one selects its copies, as a click in the viewer
157
- does: `bind:selected`). In a pane narrower than 36em (a phone's) the panel is under the model,
163
+ does: `bind:selected`), and, with an `onsource`, the line that names it (`named`, the
164
+ frame's `parts[].source`, a library's with its file), for the host to show. In a pane narrower than 36em (a phone's) the panel is under the model,
158
165
  at most half the pane. The model fills what the rest leave, no less than
159
166
  `--kc-model-min-height` (none by default), which a host whose pane is as tall as its content
160
167
  sets: the model has no height of its own. A bar above: a section through the model along an axis (a solid's: a drawing, which isn't cut, or no model offers none), screenshot, an
@@ -285,5 +292,9 @@ The viewer's scene logic in Node (jsdom), with a stand-in for WebGL and models t
285
292
  makes through `@kupcad/wasm`; the components mounted under jsdom (the `testing` setup and its
286
293
  `stubRenderer`), with the schemas and models the real core gives. And a smoke test in headless
287
294
  Chromium (`pnpm test:browser`) that draws what the core makes in the page and reads the pixels
288
- back: the part's colour, the background, a section cutting it all away, and a cap; and the view
289
- cube, which turns the camera to the part clicked.
295
+ back: the part's colour, the background, a section cutting it all away, and a cap, and one
296
+ on a face (at the bottom, the top, and from below) without streaks; a drawing's shapes in
297
+ order over the grid; the view
298
+ cube, which turns the camera to the part clicked; and the `ViewerPane` laid out by the browser,
299
+ its panel beside the model and, in a pane narrower than 36em, under it (a container query,
300
+ which jsdom doesn't apply), a white part's swatch framed on the light panel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kupcad/ui",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "KupCAD's UI: a three.js viewer of a model the core made, for any host (@kupcad/ui/viewer), and the Svelte panels around it (the viewer's pane, parameters, console, a language server's preview), styled by --kc-* tokens.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -36,18 +36,19 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@kupcad/protocol": "0.1.7",
40
- "three": "^0.186.1"
39
+ "@kupcad/protocol": "0.1.9",
40
+ "three": "^0.187.0",
41
+ "three-mesh-bvh": "^0.9.17"
41
42
  },
42
43
  "devDependencies": {
43
- "@kupcad/wasm": "0.1.7",
44
+ "@kupcad/wasm": "0.1.9",
44
45
  "@playwright/test": "^1.64.1",
45
46
  "@sveltejs/vite-plugin-svelte": "^7.3.1",
46
47
  "@testing-library/svelte": "^5.4.2",
47
48
  "@types/node": "^26.6.5",
48
49
  "@types/three": "~0.186.0",
49
50
  "jsdom": "^30.1.3",
50
- "svelte": "^5.57.2",
51
+ "svelte": "^5.57.3",
51
52
  "svelte-check": "^4.7.7",
52
53
  "vite": "^8.3.5",
53
54
  "vitest": "^5.0.5"
@@ -56,7 +56,8 @@
56
56
  <li class={line.kind}>
57
57
  {#if line.line !== null && onreveal}
58
58
  <button type="button" class="place" onclick={() => onreveal(line)}>
59
- {line.text} <span class="at">line {line.line}</span>
59
+ {line.text}
60
+ <span class="at">{line.file ? `${line.file} ` : ''}line {line.line}</span>
60
61
  </button>
61
62
  {:else}
62
63
  {line.text}
@@ -22,14 +22,14 @@
22
22
  import { appendLines, linesOfLog, linesOfRun } from './console.js';
23
23
  import { plainValues } from './values.svelte.js';
24
24
  import ParamsPanel from './ParamsPanel.svelte';
25
- import { memorySettings, sizeSetting } from './settings.js';
25
+ import { memorySettings, paneKeys, sizeSetting } from './settings.js';
26
26
  import Splitter from './Splitter.svelte';
27
27
  import ViewerPane from './ViewerPane.svelte';
28
28
  import { toBase64 } from './base64.js';
29
29
  import { problemsOf, runStatusOf } from './run-status.js';
30
30
  import { connectViewport } from './viewport-socket.js';
31
31
 
32
- /** @import { ExportFormat, ParamValues, Preview, ShownDocument, ViewerHost } from '@kupcad/protocol/types' */
32
+ /** @import { ExportFormat, ParamValues, Part, Preview, ShownDocument, ViewerHost } from '@kupcad/protocol/types' */
33
33
  /** @import { ViewerOptions } from './viewer/types.js' */
34
34
  /** @import { ConsoleLine } from './console.js' */
35
35
  /** @import { Problem } from './run-status.js' */
@@ -67,13 +67,7 @@
67
67
  * The settings' keys: the parameters' width and whether they show, whether the view's panel
68
68
  * shows, the console's height and whether it is open.
69
69
  */
70
- const KEYS = {
71
- params: 'preview.params',
72
- paramsOpen: 'preview.paramsOpen',
73
- panelOpen: 'preview.panelOpen',
74
- console: 'preview.console',
75
- open: 'preview.consoleOpen',
76
- };
70
+ const KEYS = paneKeys('preview');
77
71
  const id = $props.id();
78
72
  // As the settings have them; a size dragged is the pane's own until the drag is done
79
73
  let paramsWidth = $derived(sizeSetting(settings, KEYS.params, 288));
@@ -191,7 +185,7 @@
191
185
  */
192
186
  function revealProblem(problem) {
193
187
  if (!shown || problem.line === null) return;
194
- host.post({ type: 'reveal', uri: shown.uri, line: problem.line, column: problem.column ?? 1 });
188
+ host.post({ type: 'reveal', uri: shown.uri, ...placeOf(problem) });
195
189
  }
196
190
 
197
191
  /**
@@ -200,7 +194,26 @@
200
194
  */
201
195
  function revealLine(line) {
202
196
  if (!shown || line.line === null) return;
203
- host.post({ type: 'reveal', uri: shown.uri, line: line.line, column: line.column ?? 1 });
197
+ host.post({ type: 'reveal', uri: shown.uri, ...placeOf(line) });
198
+ }
199
+
200
+ /**
201
+ * Shows the script where `part` is named, in its editor (a library's, opened).
202
+ * @param {Part} part
203
+ */
204
+ function revealSource(part) {
205
+ if (!shown || !part.source) return;
206
+ host.post({ type: 'reveal', uri: shown.uri, ...placeOf({ file: null, ...part.source }) });
207
+ }
208
+
209
+ /**
210
+ * Where a problem or a console line is, as `reveal` says it: in a library, its `file`, which
211
+ * the host opens in place of the script shown.
212
+ * @param {{ line: number | null, column: number | null, file: string | null }} at
213
+ */
214
+ function placeOf(at) {
215
+ const place = { line: at.line ?? 1, column: at.column ?? 1 };
216
+ return at.file ? { ...place, file: at.file } : place;
204
217
  }
205
218
 
206
219
  /** @param {Blob} image */
@@ -229,6 +242,7 @@
229
242
  status={run}
230
243
  {problems}
231
244
  onproblem={revealProblem}
245
+ onsource={revealSource}
232
246
  onscreenshot={screenshot}
233
247
  onerror={failed}
234
248
  >
@@ -56,6 +56,8 @@
56
56
  * parameters'), after the Panel button: at the end nearest them
57
57
  * @property {RunStatus | null} [status] how the run shown went, or none to say
58
58
  * @property {Problem[]} [problems] what the run shown said went wrong (`problemsOf(header)`)
59
+ * @property {(part: Part) => void} [onsource] a part's line clicked (where it is named, its
60
+ * `source`): the host shows it; without it, no line is offered
59
61
  * @property {(problem: Problem) => void} [onproblem] a problem with a line clicked: the host
60
62
  * shows the script there; without it, none can be clicked
61
63
  * @property {(error: unknown) => void} [onerror] a model the viewer couldn't show
@@ -82,6 +84,7 @@
82
84
  status = null,
83
85
  problems = [],
84
86
  onproblem,
87
+ onsource,
85
88
  onerror,
86
89
  settings = memorySettings(),
87
90
  shortcuts = [],
@@ -441,6 +444,15 @@
441
444
  <span class="name">{part.name}</span>
442
445
  {#if part.count > 1}<span class="count">×{part.count}</span>{/if}
443
446
  </button>
447
+ {#if onsource && part.source}
448
+ <button
449
+ type="button"
450
+ class="source"
451
+ title="Show where {part.name} is named"
452
+ onclick={() => onsource(part)}
453
+ >{part.source.file ? `${part.source.file} ` : ''}line {part.source.line}</button
454
+ >
455
+ {/if}
444
456
  </li>
445
457
  {/each}
446
458
  </ul>
@@ -494,7 +506,9 @@
494
506
  {#snippet said(/** @type {Problem} */ problem)}
495
507
  <span class="severity">{SEVERITIES[problem.severity]}</span>
496
508
  <span class="message">{problem.message}</span>
497
- {#if problem.line !== null}<span class="place">line {problem.line}</span>{/if}
509
+ {#if problem.line !== null}<span class="place"
510
+ >{problem.file ? `${problem.file} ` : ''}line {problem.line}</span
511
+ >{/if}
498
512
  {/snippet}
499
513
 
500
514
  <style>
@@ -623,6 +637,20 @@
623
637
  margin: 0;
624
638
  padding: 0;
625
639
  }
640
+ /* The part, and the line that names it beside it */
641
+ .parts li {
642
+ display: flex;
643
+ }
644
+ .parts li > button {
645
+ flex: 1;
646
+ min-width: 0;
647
+ }
648
+ .parts .source {
649
+ flex: none;
650
+ width: auto;
651
+ color: var(--kc-muted);
652
+ font-size: 0.92em;
653
+ }
626
654
  .parts button {
627
655
  display: flex;
628
656
  align-items: center;
@@ -651,6 +679,8 @@
651
679
  height: 10px;
652
680
  border-radius: 2px;
653
681
  flex: none;
682
+ /* Framed, so a part the colour of the panel (white in the light theme) still shows */
683
+ box-shadow: inset 0 0 0 1px var(--kc-border);
654
684
  }
655
685
  .name {
656
686
  flex: 1;
package/src/console.js CHANGED
@@ -19,6 +19,8 @@ export const MAX_LINES = 2000;
19
19
  * @property {string} text
20
20
  * @property {number | null} line
21
21
  * @property {number | null} column
22
+ * @property {string | null} file the module the line and column are in (its path in the
23
+ * project), null for the script's own
22
24
  */
23
25
 
24
26
  /**
@@ -26,9 +28,16 @@ export const MAX_LINES = 2000;
26
28
  * @param {string} text
27
29
  * @param {number | null} [line]
28
30
  * @param {number | null} [column]
31
+ * @param {string | null} [file]
29
32
  * @returns {ConsoleLine}
30
33
  */
31
- const lineOf = (kind, text, line = null, column = null) => ({ kind, text, line, column });
34
+ const lineOf = (kind, text, line = null, column = null, file = null) => ({
35
+ kind,
36
+ text,
37
+ line,
38
+ column,
39
+ file,
40
+ });
32
41
 
33
42
  /**
34
43
  * `text`'s lines, without the end its last one has.
@@ -48,8 +57,8 @@ export function linesOfRun(header, name) {
48
57
  if (header.outputCut) {
49
58
  lines.push(lineOf('log', 'It printed more than a run keeps: the rest is left out.'));
50
59
  }
51
- for (const { message, line, column } of header.warnings) {
52
- lines.push(lineOf('warning', `Warning: ${message}`, line, column));
60
+ for (const { message, line, column, file } of header.warnings) {
61
+ lines.push(lineOf('warning', `Warning: ${message}`, line, column, file ?? null));
53
62
  }
54
63
  const { error } = header;
55
64
  if (error) lines.push(lineOf('error', `Error: ${error.message}`, error.line, error.column));
package/src/index.js CHANGED
@@ -15,7 +15,14 @@ export { default as ViewerPane } from './ViewerPane.svelte';
15
15
  export { appendLines, linesOfLog, linesOfRun } from './console.js';
16
16
  export { fillOf } from './range.js';
17
17
  export { problemsOf, runStatusOf } from './run-status.js';
18
- export { localSettings, memorySettings, sizeSetting } from './settings.js';
18
+ export {
19
+ keptSettings,
20
+ localSettings,
21
+ memorySettings,
22
+ paneKeys,
23
+ settingsObject,
24
+ sizeSetting,
25
+ } from './settings.js';
19
26
  export { VIEWER_SHORTCUTS } from './shortcuts.js';
20
27
  export { watchTokens } from './theme.js';
21
28
  export { plainValues } from './values.svelte.js';
package/src/params.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // The parameter panel's logic, apart from its markup: which control a parameter (or a part of its
2
2
  // value) gets, how the parameters group, which value a control shows, and whether a value given
3
3
  // in the panel is one the script takes (its schema's rules, core/src/core/param_rules.zig's),
4
- // checked before it leaves the panel; the run is the final check, and a frame names a value it
5
- // refused, and where in it.
4
+ // checked before it leaves the panel as the core's `check` reads it (a number's text as
5
+ // std.fmt.parseFloat reads it, true or false as 1 and 0 too, a Map's fields all of them and no
6
+ // other, a choice compared once read): the panel accepted values the run then refused, and the
7
+ // other way round. The run is the final check, and a frame names a value it refused, and where.
6
8
 
7
9
  /** @import { ParamSpec, ParamValue, ParamValues, PathStep, Rule } from '@kupcad/protocol/types' */
8
10
 
@@ -288,24 +290,95 @@ export function validate(rule, input) {
288
290
  function read(rule, input, path) {
289
291
  if (rule.type === 'array') return readArray(rule, input, path);
290
292
  if (rule.type === 'map') return readMap(rule, input, path);
293
+ const result = readScalar(rule, input, path);
294
+ if (!('value' in result)) return result;
295
+ // Once read, as the core compares a choice: 20.0 is the choice 20, " 20" no number
291
296
  const choices = rule.in;
292
- if (choices) {
293
- const choice = choices.find((listed) => String(listed) === String(input));
294
- return choice === undefined
295
- ? { error: `must be one of ${choices.join(', ')}`, path }
296
- : { value: choice };
297
+ if (choices && !choices.some((listed) => listed === result.value)) {
298
+ return { error: `must be one of ${choices.join(', ')}`, path };
297
299
  }
298
- if (rule.type === 'boolean') return { value: input === true || input === 'true' };
299
- if (rule.type === 'string') return { value: String(input ?? '') };
300
- const text = String(input ?? '').trim();
301
- const number = Number(text);
302
- if (text === '' || !Number.isFinite(number)) return { error: 'must be a number', path };
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * @param {Rule} rule a number's, a boolean's or text's
305
+ * @param {Value | null} input
306
+ * @param {PathStep[]} path
307
+ * @returns {{ value: Value } | Refusal}
308
+ */
309
+ function readScalar(rule, input, path) {
310
+ if (rule.type === 'boolean') {
311
+ const value = booleanOf(input);
312
+ return value === null ? { error: 'must be true or false', path } : { value };
313
+ }
314
+ if (rule.type === 'string') {
315
+ return typeof input === 'string' ? { value: input } : { error: 'must be text', path };
316
+ }
317
+ const number = numberOf(input);
318
+ if (number === null) return { error: 'must be a number', path };
303
319
  if (rule.min !== null && number < rule.min)
304
320
  return { error: `must be at least ${rule.min}`, path };
305
321
  if (rule.max !== null && number > rule.max) return { error: `must be at most ${rule.max}`, path };
306
322
  return { value: number };
307
323
  }
308
324
 
325
+ /**
326
+ * A finite number, or text that reads as one as the core reads it (Zig's std.fmt.parseFloat:
327
+ * a decimal with a fraction and an exponent, digits joined by single underscores, a hex number
328
+ * with a binary exponent; no spaces around it, no `inf`); null for anything else.
329
+ * @param {Value | null} input
330
+ * @returns {number | null}
331
+ */
332
+ export function numberOf(input) {
333
+ if (typeof input === 'number') return Number.isFinite(input) ? input : null;
334
+ if (typeof input !== 'string') return null;
335
+ const hex = HEX_NUMBER.exec(input);
336
+ const number = hex
337
+ ? hexNumber(hex)
338
+ : DECIMAL_NUMBER.test(input)
339
+ ? Number(input.replaceAll('_', ''))
340
+ : NaN;
341
+ return Number.isFinite(number) ? number : null;
342
+ }
343
+
344
+ /** Digits joined by single underscores. */
345
+ const DIGITS = String.raw`\d+(?:_\d+)*`;
346
+ const HEX_DIGITS = String.raw`[0-9a-fA-F]+(?:_[0-9a-fA-F]+)*`;
347
+ /** `12`, `1_000`, `1.`, `.5`, `1.5e-3`, with a sign. */
348
+ const DECIMAL_NUMBER = new RegExp(
349
+ String.raw`^[+-]?(?:${DIGITS}(?:\.(?:${DIGITS})?)?|\.${DIGITS})(?:[eE][+-]?${DIGITS})?$`,
350
+ );
351
+ /** `0x10`, `0x1.8p1`, with a sign: the sign, the whole, the fraction and the exponent. */
352
+ const HEX_NUMBER = new RegExp(
353
+ String.raw`^([+-]?)0[xX](?:(${HEX_DIGITS})(?:\.(${HEX_DIGITS})?)?|\.(${HEX_DIGITS}))(?:[pP]([+-]?${DIGITS}))?$`,
354
+ );
355
+
356
+ /** @param {RegExpExecArray} match HEX_NUMBER's */
357
+ function hexNumber(match) {
358
+ const sign = match[1];
359
+ const whole = match[2] ?? '';
360
+ const fraction = match[3] ?? match[4] ?? '';
361
+ const exponent = match[5] ?? '0';
362
+ const digits = `${whole}${fraction}`.replaceAll('_', '');
363
+ const places = fraction.replaceAll('_', '').length;
364
+ const magnitude =
365
+ Number.parseInt(digits || '0', 16) * 2 ** (Number(exponent.replaceAll('_', '')) - 4 * places);
366
+ return sign === '-' ? -magnitude : magnitude;
367
+ }
368
+
369
+ /**
370
+ * true or false, as the core reads them: so, as the numbers 1 and 0, or as the text `true`,
371
+ * `false`, `1` or `0`; null for anything else (`yes`, `True`, an empty field).
372
+ * @param {Value | null} input
373
+ * @returns {boolean | null}
374
+ */
375
+ export function booleanOf(input) {
376
+ if (typeof input === 'boolean') return input;
377
+ if (input === 1 || input === 'true' || input === '1') return true;
378
+ if (input === 0 || input === 'false' || input === '0') return false;
379
+ return null;
380
+ }
381
+
309
382
  /**
310
383
  * @param {Rule} rule
311
384
  * @param {Value | null} input
@@ -342,6 +415,12 @@ function readArray(rule, input, path) {
342
415
  */
343
416
  function readMap(rule, input, path) {
344
417
  if (!isRecord(input)) return { error: 'must be a record', path };
418
+ const names = (rule.fields ?? []).map((field) => field.name);
419
+ // Its fields, all of them (one missing is read as nothing, which no field takes) and no other
420
+ const stranger = Object.keys(input).find((key) => !names.includes(key));
421
+ if (stranger !== undefined) {
422
+ return { error: `has no field ${stranger}: only ${names.join(', ')}`, path };
423
+ }
345
424
  /** @type {{ [field: string]: Value }} */
346
425
  const fields = {};
347
426
  for (const field of rule.fields ?? []) {
package/src/run-status.js CHANGED
@@ -20,6 +20,8 @@
20
20
  * @property {string} message
21
21
  * @property {number | null} line
22
22
  * @property {number | null} column
23
+ * @property {string | null} file the path in the project of the module whose code it is in (a
24
+ * library's function), its line and column that module's; null for the script shown
23
25
  */
24
26
 
25
27
  /** @type {Record<RunStatus['state'], string>} */
@@ -59,11 +61,12 @@ export function describeRun(run) {
59
61
  export function problemsOf(header) {
60
62
  if (!header) return [];
61
63
  /** @type {Problem[]} */
62
- const problems = header.warnings.map(({ message, line, column }) => ({
64
+ const problems = header.warnings.map(({ message, line, column, file }) => ({
63
65
  severity: 'warning',
64
66
  message,
65
67
  line,
66
68
  column,
69
+ file: file ?? null,
67
70
  }));
68
71
  const { error } = header;
69
72
  if (error) {
@@ -72,6 +75,7 @@ export function problemsOf(header) {
72
75
  message: error.message,
73
76
  line: error.line,
74
77
  column: error.column,
78
+ file: null,
75
79
  });
76
80
  }
77
81
  return problems;
package/src/settings.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What a person set in a host's panels (the panes' sizes, whether they show), kept where the
2
2
  // host keeps such things. The playground keeps it in the page's localStorage (localSettings),
3
- // the VS Code extension and the desktop app their own way, each on memorySettings; a host that
4
- // keeps nothing gives memorySettings alone.
3
+ // the VS Code extension and the desktop app their own way, each on keptSettings (memorySettings
4
+ // that tell the host each change); a host that keeps nothing gives memorySettings alone.
5
5
  //
6
6
  // Settings are reactive: a pane reads its value in a $derived, and follows it as it is set,
7
7
  // wherever from. A size dragged is the pane's own until the drag is done, and set then, once.
@@ -30,6 +30,25 @@ export function memorySettings(values = {}) {
30
30
  return { get: (key) => kept.get(key), set: (key, value) => void kept.set(key, value) };
31
31
  }
32
32
 
33
+ /**
34
+ * Settings a host keeps: `memorySettings` from `values`, the host told each change through
35
+ * `keep` (the VS Code extension's page sends it to the extension, the desktop app writes it to
36
+ * its settings file). Each host wrapped memorySettings this way itself.
37
+ * @param {Record<string, unknown>} values what the host kept
38
+ * @param {(key: string, value: unknown) => void} keep
39
+ * @returns {Settings}
40
+ */
41
+ export function keptSettings(values, keep) {
42
+ const settings = memorySettings(values);
43
+ return {
44
+ get: settings.get,
45
+ set(key, value) {
46
+ settings.set(key, value);
47
+ keep(key, value);
48
+ },
49
+ };
50
+ }
51
+
33
52
  /**
34
53
  * Settings kept in `storage` (the page's localStorage) as one JSON object under `name`. A page
35
54
  * that may not keep anything there (a private window's quota, a policy), or finds no JSON
@@ -39,36 +58,51 @@ export function memorySettings(values = {}) {
39
58
  * @returns {Settings}
40
59
  */
41
60
  export function localSettings(name = 'kupcad.settings', storage = globalThis.localStorage) {
42
- const settings = memorySettings(read(storage, name));
43
- return {
44
- get: settings.get,
45
- set(key, value) {
46
- settings.set(key, value);
47
- try {
48
- const all = { ...read(storage, name), [key]: value };
49
- storage?.setItem(name, JSON.stringify(all));
50
- } catch {
51
- // Kept in memory: the page may not store anything
52
- }
53
- },
61
+ const read = () => {
62
+ try {
63
+ return settingsObject(storage?.getItem(name));
64
+ } catch {
65
+ return {};
66
+ }
54
67
  };
68
+ return keptSettings(read(), (key, value) => {
69
+ try {
70
+ storage?.setItem(name, JSON.stringify({ ...read(), [key]: value }));
71
+ } catch {
72
+ // Kept in memory: the page may not store anything
73
+ }
74
+ });
55
75
  }
56
76
 
57
77
  /**
58
- * The JSON object `storage` keeps under `name`; an empty one for none, or for what isn't one.
59
- * @param {Pick<Storage, 'getItem'> | undefined} storage
60
- * @param {string} name
78
+ * The settings `text` holds as JSON: its object; an empty one for no text, text that isn't
79
+ * JSON, or JSON that isn't an object (the extension's page and the playground each read it so).
80
+ * @param {string | null | undefined} text
61
81
  * @returns {Record<string, unknown>}
62
82
  */
63
- function read(storage, name) {
83
+ export function settingsObject(text) {
64
84
  try {
65
- const parsed = JSON.parse(storage?.getItem(name) ?? '{}');
85
+ const parsed = JSON.parse(text || '{}');
66
86
  return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
67
87
  } catch {
68
88
  return {};
69
89
  }
70
90
  }
71
91
 
92
+ /**
93
+ * The keys a preview panel keeps its layout under, each with `prefix` (the preview's are
94
+ * `preview.params`, the playground's `playground.params`): the parameters pane's width, whether
95
+ * it and the panel show, the console's height and whether it is open. Two panels listed them.
96
+ * @param {string} prefix
97
+ */
98
+ export const paneKeys = (prefix) => ({
99
+ params: `${prefix}.params`,
100
+ paramsOpen: `${prefix}.paramsOpen`,
101
+ panelOpen: `${prefix}.panelOpen`,
102
+ console: `${prefix}.console`,
103
+ open: `${prefix}.consoleOpen`,
104
+ });
105
+
72
106
  /**
73
107
  * A pane's size kept under `key`: a finite number of pixels, else `fallback` (none kept, or
74
108
  * what was kept is no size).
@@ -37,7 +37,8 @@ export function applyRenderMode(model, mode, edges, theme) {
37
37
  const material = /** @type {MeshStandardMaterial} */ (shape.mesh.material);
38
38
  const xray = mode === 'xray';
39
39
  material.visible = mode !== 'wireframe';
40
- material.transparent = xray || shape.opacity < 1;
40
+ // A drawing's shapes always: drawn in their order with its outlines (model.js)
41
+ material.transparent = model.kind === '2d' || xray || shape.opacity < 1;
41
42
  material.opacity = xray ? Math.min(shape.opacity, XRAY_OPACITY) : shape.opacity;
42
43
  material.depthWrite = !xray;
43
44
  material.needsUpdate = true;
@@ -0,0 +1,13 @@
1
+ // The KupCAD brand's colours, which tokens.css paints the panels in and the viewer's theme
2
+ // paints a picked part and the edges in: one place for both, in the viewer, which any host may
3
+ // take alone (tokens.test.js holds the CSS, which can't import it, to this).
4
+
5
+ export const BRAND = Object.freeze({
6
+ /** Orangered: the accent, a picked part. */
7
+ orange: '#ff5f14',
8
+ /** The text, the dark background, the edges. */
9
+ black: '#1a1b1c',
10
+ white: '#ffffff',
11
+ /** Gainsboro: the surfaces, the light text on dark. */
12
+ gainsboro: '#e3e7eb',
13
+ });
@@ -15,6 +15,12 @@ import {
15
15
  /** @import { Box3 } from 'three' */
16
16
  /** @import { Environment, Plate, Theme } from './types.js' */
17
17
 
18
+ /**
19
+ * Groups drawn in this order, whatever their objects' own: the floor, a drawing lying on it,
20
+ * then the axes over both. A drawing is flat on the floor, where depth can't tell its shapes
21
+ * apart, so they are drawn in order too (model.js).
22
+ */
23
+ export const LAYERS = { floor: 0, drawing: 1, axes: 2 };
18
24
  /** A grid has this many cells along its side. */
19
25
  const GRID_CELLS = 20;
20
26
  /** A plate's grid has a line every this many millimetres. */
@@ -40,6 +46,7 @@ const AXIS_COLORS = /** @type {const} */ (['#e5484d', '#30a46c', '#3e63dd']);
40
46
  */
41
47
  export function environmentOf(kind, box, plate, theme) {
42
48
  const group = new Group();
49
+ group.renderOrder = LAYERS.floor;
43
50
  if (kind === 'grid') {
44
51
  const side = gridSide(box);
45
52
  group.add(lines(side, side, side / GRID_CELLS, theme.grid));
@@ -71,6 +78,7 @@ export function axesOf(kind, box, plate) {
71
78
  [0, 0, AXES_LIFT_MM, 0, 0, side * Z_AXIS_SHARE],
72
79
  ];
73
80
  const group = new Group();
81
+ group.renderOrder = LAYERS.axes;
74
82
  for (const [i, positions] of ends.entries()) {
75
83
  const geometry = new BufferGeometry().setAttribute(
76
84
  'position',