@kupcad/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +281 -2
- package/package.json +65 -3
- package/src/ConsolePanel.svelte +139 -0
- package/src/ExportMenu.svelte +36 -0
- package/src/ParamField.svelte +390 -0
- package/src/ParamsPanel.svelte +216 -0
- package/src/PreviewPanel.svelte +372 -0
- package/src/ShortcutsDialog.svelte +170 -0
- package/src/Splitter.svelte +148 -0
- package/src/Viewer.svelte +175 -0
- package/src/ViewerPane.svelte +745 -0
- package/src/base64.js +16 -0
- package/src/console.js +78 -0
- package/src/controls.css +183 -0
- package/src/index.js +40 -0
- package/src/measures.js +52 -0
- package/src/params.js +348 -0
- package/src/range.js +19 -0
- package/src/recent.js +44 -0
- package/src/run-status.js +78 -0
- package/src/settings.js +82 -0
- package/src/shortcuts.js +59 -0
- package/src/theme.js +131 -0
- package/src/tokens.css +70 -0
- package/src/values.svelte.js +13 -0
- package/src/view-settings.js +81 -0
- package/src/viewer/appearance.js +90 -0
- package/src/viewer/camera.js +100 -0
- package/src/viewer/dispose.js +30 -0
- package/src/viewer/environment.js +145 -0
- package/src/viewer/faces.js +311 -0
- package/src/viewer/hover-mark.js +125 -0
- package/src/viewer/index.js +7 -0
- package/src/viewer/model.js +293 -0
- package/src/viewer/motion.js +96 -0
- package/src/viewer/section.js +166 -0
- package/src/viewer/testing.js +90 -0
- package/src/viewer/theme.js +32 -0
- package/src/viewer/types.d.ts +136 -0
- package/src/viewer/view-cube.js +314 -0
- package/src/viewer/viewer.js +1027 -0
- package/src/viewer/wheel.js +55 -0
- package/src/viewport-socket.js +64 -0
- package/src/vscode.css +49 -0
package/src/params.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// The parameter panel's logic, apart from its markup: which control a parameter (or a part of its
|
|
2
|
+
// value) gets, how the parameters group, which value a control shows, and whether a value given
|
|
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.
|
|
6
|
+
|
|
7
|
+
/** @import { ParamSpec, ParamValue, ParamValues, PathStep, Rule } from '@kupcad/protocol/types' */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A slider (with a number beside it) for a number between a min and a max; a number field for
|
|
11
|
+
* one without; a select for a choice of listed values; a toggle for a boolean; a text field; and
|
|
12
|
+
* for a value of parts: a list (an element's control or block each, added and removed), a tuple
|
|
13
|
+
* (a control per place) or a record (a control per field of a Map).
|
|
14
|
+
* @typedef {'slider' | 'number' | 'select' | 'toggle' | 'text' | 'list' | 'tuple' | 'record'} Control
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {ParamValue} Value */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A value the panel won't give, where in it (`[1, 'depth']`), and why, the place written first
|
|
21
|
+
* (`[1][:depth] must be at least 0.1`).
|
|
22
|
+
* @typedef {{ error: string, path: PathStep[] }} Refusal
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Rule} rule
|
|
27
|
+
* @returns {Control}
|
|
28
|
+
*/
|
|
29
|
+
export function controlOf(rule) {
|
|
30
|
+
if (rule.type === 'array') return rule.tuple ? 'tuple' : 'list';
|
|
31
|
+
if (rule.type === 'map') return 'record';
|
|
32
|
+
if (rule.in) return 'select';
|
|
33
|
+
if (rule.type === 'boolean') return 'toggle';
|
|
34
|
+
if (rule.type === 'string') return 'text';
|
|
35
|
+
return rule.min !== null && rule.max !== null ? 'slider' : 'number';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The places of a value of parts, each with its rule and what labels its control: a tuple's
|
|
40
|
+
* places (their titles, else their indexes) or a Map's fields (their titles, else their names).
|
|
41
|
+
* @param {Rule} rule a tuple's or a Map's
|
|
42
|
+
* @returns {{ step: PathStep, rule: Rule, label: string }[]}
|
|
43
|
+
*/
|
|
44
|
+
export function partsOf(rule) {
|
|
45
|
+
if (rule.tuple) {
|
|
46
|
+
return rule.tuple.map((place, index) => ({
|
|
47
|
+
step: index,
|
|
48
|
+
rule: place,
|
|
49
|
+
label: place.title ?? String(index),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
return (rule.fields ?? []).map((field) => ({
|
|
53
|
+
step: field.name,
|
|
54
|
+
rule: field,
|
|
55
|
+
label: field.title ?? field.name,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A number control's increment: the script's `step:`, else 1 for one between a `min` and a
|
|
61
|
+
* `max` (a slider moved by a hand lands on whole numbers, not on 27.41112), or, for a range
|
|
62
|
+
* narrower than 1, which a step of 1 couldn't move in, the power of ten that gives it at least
|
|
63
|
+
* ten positions; any value for a number with no range.
|
|
64
|
+
* @param {Rule} rule
|
|
65
|
+
* @returns {number | 'any'}
|
|
66
|
+
*/
|
|
67
|
+
export function stepOf(rule) {
|
|
68
|
+
if (rule.step !== null) return rule.step;
|
|
69
|
+
const { min, max } = rule;
|
|
70
|
+
if (min === null || max === null || !(max > min)) return 'any';
|
|
71
|
+
const range = max - min;
|
|
72
|
+
return range >= 1 ? 1 : 10 ** (Math.floor(Math.log10(range)) - 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The parameters by their `group:`, the groups in the order the script first names them: those
|
|
77
|
+
* without one first, under no heading.
|
|
78
|
+
* @param {ParamSpec[]} parameters
|
|
79
|
+
* @returns {{ group: string | null, parameters: ParamSpec[] }[]}
|
|
80
|
+
*/
|
|
81
|
+
export function groupsOf(parameters) {
|
|
82
|
+
/** @type {Map<string | null, ParamSpec[]>} */
|
|
83
|
+
const groups = new Map([[null, []]]);
|
|
84
|
+
for (const spec of parameters) {
|
|
85
|
+
const members = groups.get(spec.group) ?? [];
|
|
86
|
+
members.push(spec);
|
|
87
|
+
groups.set(spec.group, members);
|
|
88
|
+
}
|
|
89
|
+
return [...groups]
|
|
90
|
+
.filter(([, members]) => members.length > 0)
|
|
91
|
+
.map(([group, members]) => ({ group, parameters: members }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The value a parameter's control shows: the one set in the panel, else the one the run took,
|
|
96
|
+
* else its default (null when it has none, or the script computes it and no run took one).
|
|
97
|
+
* @param {ParamSpec} spec
|
|
98
|
+
* @param {ParamValues} overrides
|
|
99
|
+
* @param {ParamValues} values
|
|
100
|
+
* @returns {Value | null}
|
|
101
|
+
*/
|
|
102
|
+
export function shownValue(spec, overrides, values) {
|
|
103
|
+
return ownValue(overrides, spec.name) ?? ownValue(values, spec.name) ?? spec.default_value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether the panel sets the parameter `name`: by its own key, so a parameter named like an
|
|
108
|
+
* Object's member (`constructor`) isn't taken for set.
|
|
109
|
+
* @param {ParamValues} overrides
|
|
110
|
+
* @param {string} name
|
|
111
|
+
*/
|
|
112
|
+
export const isOverridden = (overrides, name) => Object.hasOwn(overrides, name);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The value `values` holds under `name` itself, not one an Object has (`constructor` read a
|
|
116
|
+
* function).
|
|
117
|
+
* @param {ParamValues} values
|
|
118
|
+
* @param {string} name
|
|
119
|
+
* @returns {Value | undefined}
|
|
120
|
+
*/
|
|
121
|
+
const ownValue = (values, name) => (Object.hasOwn(values, name) ? values[name] : undefined);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What a new part of `rule` starts as: its first choice, else what is blank for its kind (a
|
|
125
|
+
* number in its range, each of a tuple's places, each of a Map's fields).
|
|
126
|
+
* @param {Rule} rule
|
|
127
|
+
* @returns {Value}
|
|
128
|
+
*/
|
|
129
|
+
export function blankOf(rule) {
|
|
130
|
+
if (rule.in) return rule.in[0];
|
|
131
|
+
switch (rule.type) {
|
|
132
|
+
case 'number': {
|
|
133
|
+
// Zero, into its range
|
|
134
|
+
return Math.min(Math.max(0, rule.min ?? -Infinity), rule.max ?? Infinity);
|
|
135
|
+
}
|
|
136
|
+
case 'boolean':
|
|
137
|
+
return false;
|
|
138
|
+
case 'array':
|
|
139
|
+
return rule.tuple ? rule.tuple.map(blankOf) : [];
|
|
140
|
+
case 'map':
|
|
141
|
+
return Object.fromEntries((rule.fields ?? []).map((field) => [field.name, blankOf(field)]));
|
|
142
|
+
default:
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a new element of a list starts as: a copy of the last, else a blank one.
|
|
149
|
+
* @param {Rule} rule a list's
|
|
150
|
+
* @param {readonly Value[]} items
|
|
151
|
+
* @returns {Value}
|
|
152
|
+
*/
|
|
153
|
+
export function newElement(rule, items) {
|
|
154
|
+
if (items.length > 0) return structuredClone(items[items.length - 1]);
|
|
155
|
+
return blankOf(/** @type {Rule} */ (rule.items));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a list may have an element added, or removed: its `max_items` and `min_items`.
|
|
160
|
+
* @param {Rule} rule a list's
|
|
161
|
+
* @param {number} count its elements now
|
|
162
|
+
*/
|
|
163
|
+
export const canAdd = (rule, count) => rule.max_items === null || count < rule.max_items;
|
|
164
|
+
/** @param {Rule} rule @param {number} count */
|
|
165
|
+
export const canRemove = (rule, count) => rule.min_items === null || count > rule.min_items;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether `a` and `b` are one value: arrays and objects by what they hold.
|
|
169
|
+
* @param {Value | null | undefined} a
|
|
170
|
+
* @param {Value | null | undefined} b
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function sameValue(a, b) {
|
|
174
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
175
|
+
return (
|
|
176
|
+
Array.isArray(a) &&
|
|
177
|
+
Array.isArray(b) &&
|
|
178
|
+
a.length === b.length &&
|
|
179
|
+
a.every((item, index) => sameValue(item, b[index]))
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (isRecord(a) || isRecord(b)) {
|
|
183
|
+
if (!isRecord(a) || !isRecord(b)) return false;
|
|
184
|
+
const keys = Object.keys(a);
|
|
185
|
+
return (
|
|
186
|
+
keys.length === Object.keys(b).length &&
|
|
187
|
+
keys.every((key) => Object.hasOwn(b, key) && sameValue(a[key], b[key]))
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return a === b;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {value is { [field: string]: Value }}
|
|
196
|
+
*/
|
|
197
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `value` (the whole of it) with the part at `path` given `part`.
|
|
201
|
+
* @param {Value | null} value
|
|
202
|
+
* @param {PathStep[]} path
|
|
203
|
+
* @param {Value} part
|
|
204
|
+
* @returns {Value}
|
|
205
|
+
*/
|
|
206
|
+
export function withPart(value, path, part) {
|
|
207
|
+
if (path.length === 0) return part;
|
|
208
|
+
const [step, ...rest] = path;
|
|
209
|
+
if (typeof step === 'number') {
|
|
210
|
+
const items = Array.isArray(value) ? [...value] : [];
|
|
211
|
+
items[step] = withPart(items[step] ?? null, rest, part);
|
|
212
|
+
return items;
|
|
213
|
+
}
|
|
214
|
+
const fields = isRecord(value) ? { ...value } : {};
|
|
215
|
+
fields[step] = withPart(Object.hasOwn(fields, step) ? fields[step] : null, rest, part);
|
|
216
|
+
return fields;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The part of `value` at `path`; null where it has none.
|
|
221
|
+
* @param {Value | null} value
|
|
222
|
+
* @param {PathStep[]} path
|
|
223
|
+
* @returns {Value | null}
|
|
224
|
+
*/
|
|
225
|
+
export function partAt(value, path) {
|
|
226
|
+
let part = value;
|
|
227
|
+
for (const step of path) {
|
|
228
|
+
if (typeof step === 'number') part = Array.isArray(part) ? (part[step] ?? null) : null;
|
|
229
|
+
else part = isRecord(part) && Object.hasOwn(part, step) ? part[step] : null;
|
|
230
|
+
}
|
|
231
|
+
return part;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The place `path` names, as the core writes it: `[1][:depth]`.
|
|
236
|
+
* @param {PathStep[]} path
|
|
237
|
+
*/
|
|
238
|
+
export const placeOf = (path) =>
|
|
239
|
+
path.map((step) => (typeof step === 'number' ? `[${step}]` : `[:${step}]`)).join('');
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a control gave, as the parameter's value (a part of it, for a part's rule), or why the
|
|
243
|
+
* script wouldn't take it and where.
|
|
244
|
+
* @param {Rule} rule
|
|
245
|
+
* @param {Value | null} input
|
|
246
|
+
* @returns {{ value: Value } | Refusal}
|
|
247
|
+
*/
|
|
248
|
+
export function validate(rule, input) {
|
|
249
|
+
const result = read(rule, input, []);
|
|
250
|
+
if ('value' in result) return result;
|
|
251
|
+
const place = placeOf(result.path);
|
|
252
|
+
return { error: place ? `${place} ${result.error}` : result.error, path: result.path };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {Rule} rule
|
|
257
|
+
* @param {Value | null} input
|
|
258
|
+
* @param {PathStep[]} path where `input` is in the value
|
|
259
|
+
* @returns {{ value: Value } | Refusal}
|
|
260
|
+
*/
|
|
261
|
+
function read(rule, input, path) {
|
|
262
|
+
if (rule.type === 'array') return readArray(rule, input, path);
|
|
263
|
+
if (rule.type === 'map') return readMap(rule, input, path);
|
|
264
|
+
const choices = rule.in;
|
|
265
|
+
if (choices) {
|
|
266
|
+
const choice = choices.find((listed) => String(listed) === String(input));
|
|
267
|
+
return choice === undefined
|
|
268
|
+
? { error: `must be one of ${choices.join(', ')}`, path }
|
|
269
|
+
: { value: choice };
|
|
270
|
+
}
|
|
271
|
+
if (rule.type === 'boolean') return { value: input === true || input === 'true' };
|
|
272
|
+
if (rule.type === 'string') return { value: String(input ?? '') };
|
|
273
|
+
const text = String(input ?? '').trim();
|
|
274
|
+
const number = Number(text);
|
|
275
|
+
if (text === '' || !Number.isFinite(number)) return { error: 'must be a number', path };
|
|
276
|
+
if (rule.min !== null && number < rule.min)
|
|
277
|
+
return { error: `must be at least ${rule.min}`, path };
|
|
278
|
+
if (rule.max !== null && number > rule.max) return { error: `must be at most ${rule.max}`, path };
|
|
279
|
+
return { value: number };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {Rule} rule
|
|
284
|
+
* @param {Value | null} input
|
|
285
|
+
* @param {PathStep[]} path
|
|
286
|
+
* @returns {{ value: Value } | Refusal}
|
|
287
|
+
*/
|
|
288
|
+
function readArray(rule, input, path) {
|
|
289
|
+
if (!Array.isArray(input)) return { error: 'must be a list', path };
|
|
290
|
+
if (rule.tuple && input.length !== rule.tuple.length) {
|
|
291
|
+
return { error: `must have ${rule.tuple.length} items`, path };
|
|
292
|
+
}
|
|
293
|
+
if (rule.min_items !== null && input.length < rule.min_items) {
|
|
294
|
+
return { error: `must have at least ${rule.min_items} items`, path };
|
|
295
|
+
}
|
|
296
|
+
if (rule.max_items !== null && input.length > rule.max_items) {
|
|
297
|
+
return { error: `must have at most ${rule.max_items} items`, path };
|
|
298
|
+
}
|
|
299
|
+
/** @type {Value[]} */
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const [index, item] of input.entries()) {
|
|
302
|
+
const place = rule.tuple ? rule.tuple[index] : /** @type {Rule} */ (rule.items);
|
|
303
|
+
const result = read(place, item, [...path, index]);
|
|
304
|
+
if (!('value' in result)) return result;
|
|
305
|
+
items.push(result.value);
|
|
306
|
+
}
|
|
307
|
+
return { value: items };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {Rule} rule
|
|
312
|
+
* @param {Value | null} input
|
|
313
|
+
* @param {PathStep[]} path
|
|
314
|
+
* @returns {{ value: Value } | Refusal}
|
|
315
|
+
*/
|
|
316
|
+
function readMap(rule, input, path) {
|
|
317
|
+
if (!isRecord(input)) return { error: 'must be a record', path };
|
|
318
|
+
/** @type {{ [field: string]: Value }} */
|
|
319
|
+
const fields = {};
|
|
320
|
+
for (const field of rule.fields ?? []) {
|
|
321
|
+
const given = Object.hasOwn(input, field.name) ? input[field.name] : null;
|
|
322
|
+
const result = read(field, given, [...path, field.name]);
|
|
323
|
+
if (!('value' in result)) return result;
|
|
324
|
+
fields[field.name] = result.value;
|
|
325
|
+
}
|
|
326
|
+
return { value: fields };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* `overrides` with `name` set to `value`.
|
|
331
|
+
* @param {ParamValues} overrides
|
|
332
|
+
* @param {string} name
|
|
333
|
+
* @param {Value} value
|
|
334
|
+
* @returns {ParamValues}
|
|
335
|
+
*/
|
|
336
|
+
export function withOverride(overrides, name, value) {
|
|
337
|
+
return { ...overrides, [name]: value };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `overrides` without `name`: the parameter takes the script's value again.
|
|
342
|
+
* @param {ParamValues} overrides
|
|
343
|
+
* @param {string} name
|
|
344
|
+
* @returns {ParamValues}
|
|
345
|
+
*/
|
|
346
|
+
export function withoutOverride(overrides, name) {
|
|
347
|
+
return Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== name));
|
|
348
|
+
}
|
package/src/range.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// How far along a range input's thumb is, for controls.css's .kc-range: WebKit and Blink fill
|
|
2
|
+
// no track of their own, so the panel sets this as --kc-range-fill as the value changes.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `value`'s place between `min` and `max` as a CSS percentage, held to 0% and 100%. A range
|
|
6
|
+
* input with no value puts its thumb half way, and so does this; a span that is none, at 0%.
|
|
7
|
+
* @param {unknown} value
|
|
8
|
+
* @param {number | string | null} [min] the input's (none: a browser's default, 0)
|
|
9
|
+
* @param {number | string | null} [max] the input's (none: a browser's default, 100)
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function fillOf(value, min, max) {
|
|
13
|
+
const low = Number(min ?? 0);
|
|
14
|
+
const high = Number(max ?? 100);
|
|
15
|
+
if (!Number.isFinite(low) || !Number.isFinite(high) || high <= low) return '0%';
|
|
16
|
+
const at = value === null || value === undefined || value === '' ? NaN : Number(value);
|
|
17
|
+
if (!Number.isFinite(at)) return '50%';
|
|
18
|
+
return `${Math.min(100, Math.max(0, ((at - low) / (high - low)) * 100))}%`;
|
|
19
|
+
}
|
package/src/recent.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What a pane keeps of each thing it showed, for the next time it shows it: the most recently
|
|
2
|
+
// shown kept, as many as it keeps, the least recently shown let go past them. Only for as long
|
|
3
|
+
// as the pane is there: nothing is written anywhere.
|
|
4
|
+
|
|
5
|
+
/** @template T */
|
|
6
|
+
export class Recent {
|
|
7
|
+
/** @type {Map<string, T>} */
|
|
8
|
+
#kept = new Map();
|
|
9
|
+
#most;
|
|
10
|
+
|
|
11
|
+
/** @param {number} most how many it keeps */
|
|
12
|
+
constructor(most) {
|
|
13
|
+
this.#most = most;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Keeps `value` for `key`, the most recent; the least recent goes past the most kept.
|
|
18
|
+
* @param {string} key
|
|
19
|
+
* @param {T} value
|
|
20
|
+
*/
|
|
21
|
+
keep(key, value) {
|
|
22
|
+
this.#kept.delete(key);
|
|
23
|
+
this.#kept.set(key, value);
|
|
24
|
+
for (const oldest of this.#kept.keys()) {
|
|
25
|
+
if (this.#kept.size <= this.#most) break;
|
|
26
|
+
this.#kept.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What is kept for `key`, the most recent now; none for a key not kept, or let go.
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @returns {T | undefined}
|
|
34
|
+
*/
|
|
35
|
+
get(key) {
|
|
36
|
+
const value = this.#kept.get(key);
|
|
37
|
+
if (value !== undefined) this.keep(key, value);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get size() {
|
|
42
|
+
return this.#kept.size;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// How the run a viewer shows went, for its status line, and what it said went wrong: from the
|
|
2
|
+
// preview frame's header, the same for every host.
|
|
3
|
+
|
|
4
|
+
/** @import { FrameHeader } from '@kupcad/protocol/types' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The run of the script shown: running, done, or failed; how long it took (ms), and how many
|
|
8
|
+
* warnings it gave.
|
|
9
|
+
* @typedef {object} RunStatus
|
|
10
|
+
* @property {'running' | 'ready' | 'failed'} state
|
|
11
|
+
* @property {number} [runMs]
|
|
12
|
+
* @property {number} [warnings]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Something the run said went wrong: why it failed, or a warning, and where in the script it
|
|
17
|
+
* was (a 1-based line and column, in UTF-8 bytes), null where it wasn't running.
|
|
18
|
+
* @typedef {object} Problem
|
|
19
|
+
* @property {'error' | 'warning'} severity
|
|
20
|
+
* @property {string} message
|
|
21
|
+
* @property {number | null} line
|
|
22
|
+
* @property {number | null} column
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @type {Record<RunStatus['state'], string>} */
|
|
26
|
+
const STATES = { running: 'Running…', ready: 'Ready', failed: 'Failed' };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The run whose frame is `header`: running while none came (null).
|
|
30
|
+
* @param {FrameHeader | null} header
|
|
31
|
+
* @returns {RunStatus}
|
|
32
|
+
*/
|
|
33
|
+
export function runStatusOf(header) {
|
|
34
|
+
if (!header) return { state: 'running' };
|
|
35
|
+
return {
|
|
36
|
+
state: header.error ? 'failed' : 'ready',
|
|
37
|
+
runMs: header.timing.runMs,
|
|
38
|
+
warnings: header.warnings.length,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the status line says of `run`: its state, then its time and its warnings.
|
|
44
|
+
* @param {RunStatus} run
|
|
45
|
+
*/
|
|
46
|
+
export function describeRun(run) {
|
|
47
|
+
const said = [STATES[run.state]];
|
|
48
|
+
if (run.runMs !== undefined) said.push(`${Math.round(run.runMs)} ms`);
|
|
49
|
+
if (run.warnings) said.push(`${run.warnings} warning${run.warnings === 1 ? '' : 's'}`);
|
|
50
|
+
return said.join(' · ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the run whose frame is `header` said went wrong: why it failed first, then its warnings;
|
|
55
|
+
* nothing while none came (null).
|
|
56
|
+
* @param {FrameHeader | null} header
|
|
57
|
+
* @returns {Problem[]}
|
|
58
|
+
*/
|
|
59
|
+
export function problemsOf(header) {
|
|
60
|
+
if (!header) return [];
|
|
61
|
+
/** @type {Problem[]} */
|
|
62
|
+
const problems = header.warnings.map(({ message, line, column }) => ({
|
|
63
|
+
severity: 'warning',
|
|
64
|
+
message,
|
|
65
|
+
line,
|
|
66
|
+
column,
|
|
67
|
+
}));
|
|
68
|
+
const { error } = header;
|
|
69
|
+
if (error) {
|
|
70
|
+
problems.unshift({
|
|
71
|
+
severity: 'error',
|
|
72
|
+
message: error.message,
|
|
73
|
+
line: error.line,
|
|
74
|
+
column: error.column,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return problems;
|
|
78
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What a person set in a host's panels (the panes' sizes, whether they show), kept where the
|
|
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.
|
|
5
|
+
//
|
|
6
|
+
// Settings are reactive: a pane reads its value in a $derived, and follows it as it is set,
|
|
7
|
+
// wherever from. A size dragged is the pane's own until the drag is done, and set then, once.
|
|
8
|
+
// A key not kept yet is followed as the map is (a SvelteMap's reader of a key it doesn't hold
|
|
9
|
+
// reads again as any key is added): a size never kept, mid-drag as another key is kept for the
|
|
10
|
+
// first time, goes back to its own. Svelte doesn't let a reaction follow state it made itself,
|
|
11
|
+
// so a cell of a key's own, made as the key is first read, would never be followed.
|
|
12
|
+
|
|
13
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A host's settings, by key: what `get` reads in a reaction (a $derived, an effect, the markup)
|
|
17
|
+
* it reads again when `set` changes it. A host's own are built on `memorySettings`.
|
|
18
|
+
* @typedef {object} Settings
|
|
19
|
+
* @property {(key: string) => unknown} get the value kept for `key`; undefined for none
|
|
20
|
+
* @property {(key: string, value: unknown) => void} set keeps `value` for `key`
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Settings kept in memory alone, from `values`: gone with the page.
|
|
25
|
+
* @param {Record<string, unknown>} [values]
|
|
26
|
+
* @returns {Settings}
|
|
27
|
+
*/
|
|
28
|
+
export function memorySettings(values = {}) {
|
|
29
|
+
const kept = new SvelteMap(Object.entries(values));
|
|
30
|
+
return { get: (key) => kept.get(key), set: (key, value) => void kept.set(key, value) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Settings kept in `storage` (the page's localStorage) as one JSON object under `name`. A page
|
|
35
|
+
* that may not keep anything there (a private window's quota, a policy), or finds no JSON
|
|
36
|
+
* object under the name, keeps them in memory for the page's life.
|
|
37
|
+
* @param {string} [name]
|
|
38
|
+
* @param {Pick<Storage, 'getItem' | 'setItem'> | undefined} [storage]
|
|
39
|
+
* @returns {Settings}
|
|
40
|
+
*/
|
|
41
|
+
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
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
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
|
|
61
|
+
* @returns {Record<string, unknown>}
|
|
62
|
+
*/
|
|
63
|
+
function read(storage, name) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(storage?.getItem(name) ?? '{}');
|
|
66
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A pane's size kept under `key`: a finite number of pixels, else `fallback` (none kept, or
|
|
74
|
+
* what was kept is no size).
|
|
75
|
+
* @param {Settings} settings
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @param {number} fallback
|
|
78
|
+
*/
|
|
79
|
+
export function sizeSetting(settings, key, fallback) {
|
|
80
|
+
const kept = settings.get(key);
|
|
81
|
+
return typeof kept === 'number' && Number.isFinite(kept) && kept > 0 ? kept : fallback;
|
|
82
|
+
}
|
package/src/shortcuts.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The keys and the pointer's moves a person can't see on the screen, listed in ShortcutsDialog:
|
|
2
|
+
// the viewer's (VIEWER_SHORTCUTS), and a host's own groups before them. Named as the system
|
|
3
|
+
// names its keys: ⌘ and ⌥ on a Mac, Ctrl and Alt elsewhere.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A group of shortcuts under its title: each a key, or keys pressed together, and what it does.
|
|
7
|
+
* @typedef {object} ShortcutGroup
|
|
8
|
+
* @property {string} title
|
|
9
|
+
* @property {readonly (readonly [keys: string, what: string])[]} keys
|
|
10
|
+
* @property {string} [note] what a person needs to know first (where the keys work)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @typedef {'mac' | 'windows' | 'other'} KeyboardSystem */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The system the page runs on, as its keys are named.
|
|
17
|
+
* @param {string} [agent] the browser's user agent
|
|
18
|
+
* @returns {KeyboardSystem}
|
|
19
|
+
*/
|
|
20
|
+
export function keyboardSystem(agent = globalThis.navigator?.userAgent ?? '') {
|
|
21
|
+
if (/Mac|iPhone|iPad/.test(agent)) return 'mac';
|
|
22
|
+
return agent.includes('Windows') ? 'windows' : 'other';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The viewer's shortcuts, its keys named as `system` names them. Cmd is the Meta key, which
|
|
27
|
+
* Windows calls Win and Linux Super.
|
|
28
|
+
* @param {KeyboardSystem} system
|
|
29
|
+
* @returns {ShortcutGroup}
|
|
30
|
+
*/
|
|
31
|
+
export function viewerShortcuts(system) {
|
|
32
|
+
const alt = system === 'mac' ? '⌥' : 'Alt';
|
|
33
|
+
const meta = system === 'mac' ? '⌘' : system === 'windows' ? 'Win' : 'Super';
|
|
34
|
+
return {
|
|
35
|
+
title: 'Viewer',
|
|
36
|
+
note: 'Keys once the model has the focus: a click on it gives it.',
|
|
37
|
+
keys: [
|
|
38
|
+
['0', 'Iso view'],
|
|
39
|
+
['1', 'Top view'],
|
|
40
|
+
['2', 'Bottom view'],
|
|
41
|
+
['3', 'Front view'],
|
|
42
|
+
['4', 'Back view'],
|
|
43
|
+
['5', 'Left view'],
|
|
44
|
+
['6', 'Right view'],
|
|
45
|
+
['F', 'Fit the model'],
|
|
46
|
+
['I', 'Zoom in'],
|
|
47
|
+
['O', 'Zoom out'],
|
|
48
|
+
['Drag', 'Turn the view'],
|
|
49
|
+
['Right or middle drag', 'Pan'],
|
|
50
|
+
['Wheel, pinch', 'Zoom'],
|
|
51
|
+
['Shift + wheel', 'Pan'],
|
|
52
|
+
[`${alt} + wheel`, 'Move the section'],
|
|
53
|
+
[`${meta} + wheel`, 'Turn the view'],
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The viewer's shortcuts on the system the page runs on. */
|
|
59
|
+
export const VIEWER_SHORTCUTS = viewerShortcuts(keyboardSystem());
|