@kupcad/ui 0.0.0-stage → 0.1.1
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
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The viewer, its view cube in a corner and what is under the pointer by it (the part, its
|
|
4
|
+
copies, volume and size, the face and its area). Beside it a panel, which the toolbar's Panel
|
|
5
|
+
button hides: the view's controls (the render mode; edges, axes, the floor; fit, perspective,
|
|
6
|
+
the views) over the model's parts (a click on one selecting its copies, as a click in the
|
|
7
|
+
viewer does), half of it each. A bar above: a section through the model, a screenshot and a
|
|
8
|
+
host's own actions (which take the controls' look by .kc-control), the Panel button, and a
|
|
9
|
+
host's toggles of its own panes beside this one. Below: how its run went, and what it said
|
|
10
|
+
went wrong, one with a line clicked given to the host (`onproblem`), and a button that lists
|
|
11
|
+
the keyboard shortcuts (the host's own, then the viewer's). How the view looks (the render mode,
|
|
12
|
+
edges, axes, floor, projection) is the host's `settings`' view.* keys (`viewChoice`), written
|
|
13
|
+
as the person chooses, and followed as they are set wherever from. A model of another
|
|
14
|
+
`subject` (another script) is framed: as its camera was left when it was last shown, else
|
|
15
|
+
whole, from where the camera looks; the same subject's next model (an edit, a parameter)
|
|
16
|
+
keeps the camera. The cameras are kept while the pane is there, the least recently shown
|
|
17
|
+
let go past CAMERAS_KEPT. Themed by the --kc-viewer-* tokens unless given a `theme`.
|
|
18
|
+
-->
|
|
19
|
+
<script>
|
|
20
|
+
import { onMount, untrack } from 'svelte';
|
|
21
|
+
import { Recent } from './recent.js';
|
|
22
|
+
import { RENDER_MODES } from './viewer/index.js';
|
|
23
|
+
import Viewer from './Viewer.svelte';
|
|
24
|
+
import { faceLine, partMeasures, partTitle } from './measures.js';
|
|
25
|
+
import { fillOf } from './range.js';
|
|
26
|
+
import { describeRun } from './run-status.js';
|
|
27
|
+
import { memorySettings } from './settings.js';
|
|
28
|
+
import { VIEWER_SHORTCUTS } from './shortcuts.js';
|
|
29
|
+
import ShortcutsDialog from './ShortcutsDialog.svelte';
|
|
30
|
+
import { watchTokens } from './theme.js';
|
|
31
|
+
import { VIEW_KEYS, viewChoice } from './view-settings.js';
|
|
32
|
+
|
|
33
|
+
/** @import { Model, Part } from '@kupcad/protocol/types' */
|
|
34
|
+
/** @import { Axis, Bounds, CameraState, Environment, HoverDetail, RenderMode, Section, Theme, View, ViewerOptions } from './viewer/types.js' */
|
|
35
|
+
/** @import { Viewer as ViewerClass } from './viewer/index.js' */
|
|
36
|
+
/** @import { Snippet } from 'svelte' */
|
|
37
|
+
/** @import { Problem, RunStatus } from './run-status.js' */
|
|
38
|
+
/** @import { Settings } from './settings.js' */
|
|
39
|
+
/** @import { ShortcutGroup } from './shortcuts.js' */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} Props
|
|
43
|
+
* @property {Model | null} [model]
|
|
44
|
+
* @property {Theme} [theme] by default the --kc-viewer-* tokens', followed as they change
|
|
45
|
+
* @property {string | null} [selected] the part selected; bindable
|
|
46
|
+
* @property {ViewerClass | null} [viewer] the viewer, once mounted; bindable
|
|
47
|
+
* @property {boolean} [panel] whether the panel beside the viewer shows (the Panel button's);
|
|
48
|
+
* bindable, for a host to keep
|
|
49
|
+
* @property {ViewerOptions} [options] what the viewer is made with
|
|
50
|
+
* @property {(image: Blob) => void} [onscreenshot] a screenshot taken; without it, no button
|
|
51
|
+
* @property {Snippet} [actions] a host's own buttons, at the toolbar's end (export)
|
|
52
|
+
* @property {Snippet} [toggles] a host's toggles of its panes beside this one (its
|
|
53
|
+
* parameters'), after the Panel button: at the end nearest them
|
|
54
|
+
* @property {RunStatus | null} [status] how the run shown went, or none to say
|
|
55
|
+
* @property {Problem[]} [problems] what the run shown said went wrong (`problemsOf(header)`)
|
|
56
|
+
* @property {(problem: Problem) => void} [onproblem] a problem with a line clicked: the host
|
|
57
|
+
* shows the script there; without it, none can be clicked
|
|
58
|
+
* @property {(error: unknown) => void} [onerror] a model the viewer couldn't show
|
|
59
|
+
* @property {Settings} [settings] where how the view looks is kept (`VIEW_KEYS`); by default
|
|
60
|
+
* the page's memory
|
|
61
|
+
* @property {readonly ShortcutGroup[]} [shortcuts] the host's own, listed before the viewer's
|
|
62
|
+
* @property {boolean} [shortcutsOpen] whether the shortcuts' dialog shows; bindable, for a
|
|
63
|
+
* host to open it (a menu's item)
|
|
64
|
+
* @property {string | null} [subject] what the model is of (a script's URI): another's is
|
|
65
|
+
* framed, as its camera was left or whole; none, and every model keeps the camera
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** @type {Props} */
|
|
69
|
+
let {
|
|
70
|
+
model = null,
|
|
71
|
+
theme,
|
|
72
|
+
selected = $bindable(null),
|
|
73
|
+
viewer = $bindable(null),
|
|
74
|
+
panel = $bindable(true),
|
|
75
|
+
options = {},
|
|
76
|
+
onscreenshot,
|
|
77
|
+
actions,
|
|
78
|
+
toggles,
|
|
79
|
+
status = null,
|
|
80
|
+
problems = [],
|
|
81
|
+
onproblem,
|
|
82
|
+
onerror,
|
|
83
|
+
settings = memorySettings(),
|
|
84
|
+
shortcuts = [],
|
|
85
|
+
shortcutsOpen = $bindable(false),
|
|
86
|
+
subject = null,
|
|
87
|
+
} = $props();
|
|
88
|
+
|
|
89
|
+
/** How many subjects' cameras the pane keeps, the least recently shown let go past them. */
|
|
90
|
+
const CAMERAS_KEPT = 32;
|
|
91
|
+
/** Each subject's camera as it was left, while the pane is there. */
|
|
92
|
+
const cameras = /** @type {Recent<CameraState>} */ (new Recent(CAMERAS_KEPT));
|
|
93
|
+
/** The subject whose model the viewer shows, framed; none while another's is awaited. */
|
|
94
|
+
let showing = /** @type {string | null} */ (null);
|
|
95
|
+
/** The subject last given (none yet, at first): one that changes keeps the last one's camera. */
|
|
96
|
+
let given = /** @type {string | null | undefined} */ (undefined);
|
|
97
|
+
|
|
98
|
+
// Another subject: the camera of the one shown kept before the next model moves it (a
|
|
99
|
+
// drawing's turns it to the top)
|
|
100
|
+
$effect.pre(() => {
|
|
101
|
+
const next = subject;
|
|
102
|
+
untrack(() => {
|
|
103
|
+
if (next === given) return;
|
|
104
|
+
given = next;
|
|
105
|
+
if (showing !== null && viewer?.kind) cameras.keep(showing, viewer.cameraState());
|
|
106
|
+
showing = null;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Each render mode's button: its text, and its name when the text is short of it.
|
|
112
|
+
* @type {Record<RenderMode, { text: string, name: string }>}
|
|
113
|
+
*/
|
|
114
|
+
const MODES = {
|
|
115
|
+
solid: { text: 'Solid', name: 'Solid' },
|
|
116
|
+
xray: { text: 'X-ray', name: 'X-ray' },
|
|
117
|
+
wireframe: { text: 'Wire', name: 'Wireframe' },
|
|
118
|
+
};
|
|
119
|
+
/** What a problem's kind is called. @type {Record<Problem['severity'], string>} */
|
|
120
|
+
const SEVERITIES = { error: 'Error', warning: 'Warning' };
|
|
121
|
+
/**
|
|
122
|
+
* The views with a button: the cube, and the keys, give the rest.
|
|
123
|
+
* @type {{ view: View, text: string }[]}
|
|
124
|
+
*/
|
|
125
|
+
const VIEW_BUTTONS = [
|
|
126
|
+
{ view: 'iso', text: 'Iso' },
|
|
127
|
+
{ view: 'top', text: 'Top' },
|
|
128
|
+
{ view: 'front', text: 'Front' },
|
|
129
|
+
{ view: 'right', text: 'Right' },
|
|
130
|
+
];
|
|
131
|
+
/** @type {Axis[]} */
|
|
132
|
+
const AXES = ['x', 'y', 'z'];
|
|
133
|
+
/** A section's slider takes this many steps across the model. */
|
|
134
|
+
const SECTION_STEPS = 200;
|
|
135
|
+
/** The tooltip stands this far right of and below the pointer. */
|
|
136
|
+
const TOOLTIP_OFFSET_PX = 14;
|
|
137
|
+
|
|
138
|
+
/** The panel's and its groups' ids: this pane's own. */
|
|
139
|
+
const id = $props.id();
|
|
140
|
+
|
|
141
|
+
/** @type {HTMLDivElement} */
|
|
142
|
+
let root;
|
|
143
|
+
let tokenTheme = $state(/** @type {Theme | undefined} */ (undefined));
|
|
144
|
+
// As the settings have them, each read by its own key, and set as the person chooses
|
|
145
|
+
const renderMode = $derived(viewChoice(settings, 'renderMode'));
|
|
146
|
+
const edges = $derived(viewChoice(settings, 'edges'));
|
|
147
|
+
const axes = $derived(viewChoice(settings, 'axes'));
|
|
148
|
+
const environment = $derived(viewChoice(settings, 'environment'));
|
|
149
|
+
/**
|
|
150
|
+
* How a drawing shown is seen (from the top, orthographic) until the person chooses: no choice
|
|
151
|
+
* of theirs, so kept apart from the settings; null while the view is as chosen.
|
|
152
|
+
*/
|
|
153
|
+
let drawingOrthographic = $state(/** @type {boolean | null} */ (null));
|
|
154
|
+
const orthographic = $derived(
|
|
155
|
+
drawingOrthographic ?? viewChoice(settings, 'projection') === 'orthographic',
|
|
156
|
+
);
|
|
157
|
+
/** The named view the viewer looks from: none once it was turned by hand. */
|
|
158
|
+
let view = $state(/** @type {View | null} */ ('iso'));
|
|
159
|
+
let sectioned = $state(false);
|
|
160
|
+
let axis = $state(/** @type {Axis} */ ('z'));
|
|
161
|
+
let offset = $state(0);
|
|
162
|
+
let bounds = $state(/** @type {Bounds | null} */ (null));
|
|
163
|
+
/** The model shown is a solid's: a drawing isn't cut, and offered no section. */
|
|
164
|
+
let solid = $state(false);
|
|
165
|
+
/** What the pointer is over, as the viewer says. */
|
|
166
|
+
let hovered = $state(/** @type {HoverDetail} */ (null));
|
|
167
|
+
|
|
168
|
+
const parts = $derived(/** @type {Part[]} */ (model?.kind === '3d' ? model.parts : []));
|
|
169
|
+
const along = $derived(AXES.indexOf(axis));
|
|
170
|
+
const span = $derived(
|
|
171
|
+
solid && bounds ? { min: bounds.min[along], max: bounds.max[along] } : null,
|
|
172
|
+
);
|
|
173
|
+
const section = $derived(sectioned && span ? { axis, offset } : null);
|
|
174
|
+
const statusText = $derived(status ? describeRun(status) : '');
|
|
175
|
+
const hoveredPart = $derived(parts.find((part) => part.name === hovered?.part) ?? null);
|
|
176
|
+
const hoveredMeasures = $derived(hoveredPart ? partMeasures(hoveredPart) : null);
|
|
177
|
+
const shortcutGroups = $derived([...shortcuts, VIEWER_SHORTCUTS]);
|
|
178
|
+
|
|
179
|
+
onMount(() => watchTokens(root, (next) => (tokenTheme = next)));
|
|
180
|
+
|
|
181
|
+
// The view follows the viewer's (its keys, and a turn by hand, change it too), the section
|
|
182
|
+
// too (the wheel with Option moves it), and the tooltip what the pointer is over
|
|
183
|
+
$effect(() => {
|
|
184
|
+
const shown = viewer;
|
|
185
|
+
if (!shown) return;
|
|
186
|
+
shown.addEventListener('view', followView);
|
|
187
|
+
shown.addEventListener('section', followSection);
|
|
188
|
+
shown.addEventListener('hover', followHover);
|
|
189
|
+
return () => {
|
|
190
|
+
shown.removeEventListener('view', followView);
|
|
191
|
+
shown.removeEventListener('section', followSection);
|
|
192
|
+
shown.removeEventListener('hover', followHover);
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** @param {Event} event the viewer's 'view' */
|
|
197
|
+
function followView(event) {
|
|
198
|
+
view = /** @type {CustomEvent<{ view: View | null }>} */ (event).detail.view;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @param {Event} event the viewer's 'section' */
|
|
202
|
+
function followSection(event) {
|
|
203
|
+
const moved = /** @type {CustomEvent<{ section: Section }>} */ (event).detail.section;
|
|
204
|
+
sectioned = true;
|
|
205
|
+
axis = moved.axis;
|
|
206
|
+
offset = moved.offset;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param {Event} event the viewer's 'hover' */
|
|
210
|
+
function followHover(event) {
|
|
211
|
+
hovered = /** @type {CustomEvent<HoverDetail>} */ (event).detail;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A model is shown: the toolbar says how the viewer looks at it, and the section is put across
|
|
216
|
+
* its middle, unless one is on where the model still reaches (an edit of the script keeps the
|
|
217
|
+
* cut where the user put it). A drawing is seen from the top, orthographic, the choice kept for
|
|
218
|
+
* the next solid; a solid as chosen, though the viewer, after a drawing, brings back how it saw
|
|
219
|
+
* the last one (the choice may have changed since).
|
|
220
|
+
* @param {ViewerClass} shown
|
|
221
|
+
*/
|
|
222
|
+
function modelShown(shown) {
|
|
223
|
+
if (shown.kind === '3d') {
|
|
224
|
+
const chosen = viewChoice(settings, 'projection');
|
|
225
|
+
if (shown.projection !== chosen) shown.setCamera({ projection: chosen });
|
|
226
|
+
drawingOrthographic = null;
|
|
227
|
+
} else {
|
|
228
|
+
drawingOrthographic = shown.projection === 'orthographic';
|
|
229
|
+
}
|
|
230
|
+
// The first model of another subject: as its camera was left, else whole
|
|
231
|
+
if (shown.kind !== null && subject !== null && subject !== showing) {
|
|
232
|
+
const kept = cameras.get(subject);
|
|
233
|
+
if (kept) shown.restoreCamera(kept);
|
|
234
|
+
else shown.fit({ instant: true });
|
|
235
|
+
showing = subject;
|
|
236
|
+
}
|
|
237
|
+
view = shown.view;
|
|
238
|
+
bounds = shown.bounds;
|
|
239
|
+
solid = shown.kind === '3d';
|
|
240
|
+
const kept = sectioned && span && offset >= span.min && offset <= span.max;
|
|
241
|
+
if (!kept) centreSection();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Puts the section across the middle of the model, along its axis. */
|
|
245
|
+
function centreSection() {
|
|
246
|
+
if (span) offset = (span.min + span.max) / 2;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function screenshot() {
|
|
250
|
+
const image = await viewer?.screenshot();
|
|
251
|
+
if (image) onscreenshot?.(image);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The floor `floor` is, or none when it is already: its button toggles it.
|
|
256
|
+
* @param {Environment} floor
|
|
257
|
+
*/
|
|
258
|
+
function toggleFloor(floor) {
|
|
259
|
+
settings.set(VIEW_KEYS.environment, environment === floor ? 'none' : floor);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Orthographic or not, as the person chose: what the settings keep, and the view, a drawing's
|
|
264
|
+
* too, shows.
|
|
265
|
+
* @param {boolean} chosen
|
|
266
|
+
*/
|
|
267
|
+
function chooseOrthographic(chosen) {
|
|
268
|
+
drawingOrthographic = null;
|
|
269
|
+
settings.set(VIEW_KEYS.projection, chosen ? 'orthographic' : 'perspective');
|
|
270
|
+
}
|
|
271
|
+
</script>
|
|
272
|
+
|
|
273
|
+
<div class="kc-viewer-pane" bind:this={root}>
|
|
274
|
+
<div class="toolbar">
|
|
275
|
+
<!-- A section of a solid alone: a drawing isn't cut, and no model has none -->
|
|
276
|
+
{#if solid}
|
|
277
|
+
<label
|
|
278
|
+
><input class="kc-check" type="checkbox" bind:checked={sectioned} disabled={!span} /> Section</label
|
|
279
|
+
>
|
|
280
|
+
{/if}
|
|
281
|
+
{#if sectioned && span}
|
|
282
|
+
<select
|
|
283
|
+
class="kc-control"
|
|
284
|
+
aria-label="Section axis"
|
|
285
|
+
bind:value={axis}
|
|
286
|
+
onchange={centreSection}
|
|
287
|
+
>
|
|
288
|
+
{#each AXES as name (name)}
|
|
289
|
+
<option value={name}>{name.toUpperCase()}</option>
|
|
290
|
+
{/each}
|
|
291
|
+
</select>
|
|
292
|
+
<input
|
|
293
|
+
class="kc-range"
|
|
294
|
+
type="range"
|
|
295
|
+
aria-label="Section offset"
|
|
296
|
+
min={span.min}
|
|
297
|
+
max={span.max}
|
|
298
|
+
step={(span.max - span.min) / SECTION_STEPS || 'any'}
|
|
299
|
+
style:--kc-range-fill={fillOf(offset, span.min, span.max)}
|
|
300
|
+
bind:value={offset}
|
|
301
|
+
/>
|
|
302
|
+
{/if}
|
|
303
|
+
<span class="spacer"></span>
|
|
304
|
+
{#if onscreenshot}
|
|
305
|
+
<button type="button" class="kc-control" onclick={screenshot}>Screenshot</button>
|
|
306
|
+
{/if}
|
|
307
|
+
{@render actions?.()}
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
class="kc-control"
|
|
311
|
+
aria-controls="{id}-panel"
|
|
312
|
+
aria-expanded={panel}
|
|
313
|
+
onclick={() => (panel = !panel)}
|
|
314
|
+
>
|
|
315
|
+
Panel
|
|
316
|
+
</button>
|
|
317
|
+
{@render toggles?.()}
|
|
318
|
+
</div>
|
|
319
|
+
<div class="body">
|
|
320
|
+
<div class="canvas">
|
|
321
|
+
<Viewer
|
|
322
|
+
{model}
|
|
323
|
+
theme={theme ?? tokenTheme}
|
|
324
|
+
{environment}
|
|
325
|
+
{renderMode}
|
|
326
|
+
{edges}
|
|
327
|
+
{axes}
|
|
328
|
+
projection={orthographic ? 'orthographic' : 'perspective'}
|
|
329
|
+
{section}
|
|
330
|
+
bind:selected
|
|
331
|
+
bind:viewer
|
|
332
|
+
{options}
|
|
333
|
+
onmodel={modelShown}
|
|
334
|
+
{onerror}
|
|
335
|
+
/>
|
|
336
|
+
{#if hovered && (hovered.part || hovered.face)}
|
|
337
|
+
<div
|
|
338
|
+
class="tooltip"
|
|
339
|
+
role="tooltip"
|
|
340
|
+
style:left="{hovered.x + TOOLTIP_OFFSET_PX}px"
|
|
341
|
+
style:top="{hovered.y + TOOLTIP_OFFSET_PX}px"
|
|
342
|
+
>
|
|
343
|
+
{#if hovered.part}
|
|
344
|
+
<p class="title">{hoveredPart ? partTitle(hoveredPart) : hovered.part}</p>
|
|
345
|
+
{#if hoveredMeasures}<p>{hoveredMeasures}</p>{/if}
|
|
346
|
+
{/if}
|
|
347
|
+
{#if hovered.face}<p>{faceLine(hovered.face)}</p>{/if}
|
|
348
|
+
</div>
|
|
349
|
+
{/if}
|
|
350
|
+
</div>
|
|
351
|
+
<aside class="panel" id="{id}-panel" aria-label="View and parts" hidden={!panel}>
|
|
352
|
+
<div class="controls" role="toolbar" aria-label="View" aria-orientation="vertical">
|
|
353
|
+
<div class="group modes" role="group" aria-labelledby="{id}-mode">
|
|
354
|
+
<p class="caption" id="{id}-mode">Render mode</p>
|
|
355
|
+
{#each RENDER_MODES as mode (mode)}
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
class="kc-control"
|
|
359
|
+
aria-label={MODES[mode].name}
|
|
360
|
+
aria-pressed={renderMode === mode}
|
|
361
|
+
onclick={() => settings.set(VIEW_KEYS.renderMode, mode)}
|
|
362
|
+
>
|
|
363
|
+
{MODES[mode].text}
|
|
364
|
+
</button>
|
|
365
|
+
{/each}
|
|
366
|
+
</div>
|
|
367
|
+
<div class="group" role="group" aria-labelledby="{id}-show">
|
|
368
|
+
<p class="caption" id="{id}-show">Show</p>
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
class="kc-control"
|
|
372
|
+
aria-pressed={edges}
|
|
373
|
+
onclick={() => settings.set(VIEW_KEYS.edges, !edges)}
|
|
374
|
+
>
|
|
375
|
+
Edges
|
|
376
|
+
</button>
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
class="kc-control"
|
|
380
|
+
aria-pressed={axes}
|
|
381
|
+
onclick={() => settings.set(VIEW_KEYS.axes, !axes)}
|
|
382
|
+
>
|
|
383
|
+
Axes
|
|
384
|
+
</button>
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
class="kc-control"
|
|
388
|
+
aria-pressed={environment === 'grid'}
|
|
389
|
+
onclick={() => toggleFloor('grid')}
|
|
390
|
+
>
|
|
391
|
+
Grid
|
|
392
|
+
</button>
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
class="kc-control"
|
|
396
|
+
aria-pressed={environment === 'plate'}
|
|
397
|
+
onclick={() => toggleFloor('plate')}
|
|
398
|
+
>
|
|
399
|
+
Plate
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="group" role="group" aria-labelledby="{id}-camera">
|
|
403
|
+
<p class="caption" id="{id}-camera">Camera</p>
|
|
404
|
+
<button type="button" class="kc-control" onclick={() => viewer?.fit()}>Fit</button>
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
class="kc-control"
|
|
408
|
+
aria-label="Perspective"
|
|
409
|
+
aria-pressed={!orthographic}
|
|
410
|
+
onclick={() => chooseOrthographic(!orthographic)}
|
|
411
|
+
>
|
|
412
|
+
Persp
|
|
413
|
+
</button>
|
|
414
|
+
{#each VIEW_BUTTONS as button (button.view)}
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
class="kc-control"
|
|
418
|
+
aria-pressed={view === button.view}
|
|
419
|
+
onclick={() => viewer?.setCamera({ view: button.view })}
|
|
420
|
+
>
|
|
421
|
+
{button.text}
|
|
422
|
+
</button>
|
|
423
|
+
{/each}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<div class="parts">
|
|
427
|
+
<p class="caption" id="{id}-parts">Parts</p>
|
|
428
|
+
{#if parts.length > 0}
|
|
429
|
+
<ul aria-labelledby="{id}-parts">
|
|
430
|
+
{#each parts as part (part.name)}
|
|
431
|
+
<li>
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
aria-pressed={selected === part.name}
|
|
435
|
+
onclick={() => (selected = selected === part.name ? null : part.name)}
|
|
436
|
+
>
|
|
437
|
+
<span class="swatch" style:background={part.color ?? 'var(--kc-muted)'}></span>
|
|
438
|
+
<span class="name">{part.name}</span>
|
|
439
|
+
{#if part.count > 1}<span class="count">×{part.count}</span>{/if}
|
|
440
|
+
</button>
|
|
441
|
+
</li>
|
|
442
|
+
{/each}
|
|
443
|
+
</ul>
|
|
444
|
+
{:else}
|
|
445
|
+
<p class="none">None</p>
|
|
446
|
+
{/if}
|
|
447
|
+
</div>
|
|
448
|
+
</aside>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="run">
|
|
451
|
+
<div class="line">
|
|
452
|
+
{#if status}
|
|
453
|
+
<div class="status" role="status">
|
|
454
|
+
<span class="dot {status.state}"></span>
|
|
455
|
+
{statusText}
|
|
456
|
+
</div>
|
|
457
|
+
{/if}
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
class="shortcuts"
|
|
461
|
+
aria-label="Keyboard shortcuts"
|
|
462
|
+
aria-haspopup="dialog"
|
|
463
|
+
title="Keyboard shortcuts"
|
|
464
|
+
onclick={() => (shortcutsOpen = true)}
|
|
465
|
+
>
|
|
466
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
467
|
+
<rect x="2.5" y="6" width="19" height="12" rx="2" />
|
|
468
|
+
<path d="M6.5 10h1M10.5 10h1M14.5 10h1M17.5 10h.5M7.5 14h9" />
|
|
469
|
+
</svg>
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
{#if problems.length > 0}
|
|
473
|
+
<ul class="problems" aria-label="Problems">
|
|
474
|
+
{#each problems as problem, index (index)}
|
|
475
|
+
<li class={problem.severity}>
|
|
476
|
+
{#if onproblem && problem.line !== null}
|
|
477
|
+
<button type="button" onclick={() => onproblem(problem)}>
|
|
478
|
+
{@render said(problem)}
|
|
479
|
+
</button>
|
|
480
|
+
{:else}
|
|
481
|
+
{@render said(problem)}
|
|
482
|
+
{/if}
|
|
483
|
+
</li>
|
|
484
|
+
{/each}
|
|
485
|
+
</ul>
|
|
486
|
+
{/if}
|
|
487
|
+
</div>
|
|
488
|
+
<ShortcutsDialog groups={shortcutGroups} bind:open={shortcutsOpen} />
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
{#snippet said(/** @type {Problem} */ problem)}
|
|
492
|
+
<span class="severity">{SEVERITIES[problem.severity]}</span>
|
|
493
|
+
<span class="message">{problem.message}</span>
|
|
494
|
+
{#if problem.line !== null}<span class="place">line {problem.line}</span>{/if}
|
|
495
|
+
{/snippet}
|
|
496
|
+
|
|
497
|
+
<style>
|
|
498
|
+
.kc-viewer-pane {
|
|
499
|
+
display: flex;
|
|
500
|
+
flex-direction: column;
|
|
501
|
+
height: 100%;
|
|
502
|
+
min-height: 0;
|
|
503
|
+
font-family: var(--kc-font);
|
|
504
|
+
font-size: var(--kc-font-size);
|
|
505
|
+
color: var(--kc-fg);
|
|
506
|
+
background: var(--kc-bg);
|
|
507
|
+
}
|
|
508
|
+
.toolbar {
|
|
509
|
+
display: flex;
|
|
510
|
+
flex-wrap: wrap;
|
|
511
|
+
align-items: center;
|
|
512
|
+
gap: var(--kc-gap);
|
|
513
|
+
padding: 4px var(--kc-gap);
|
|
514
|
+
border-bottom: 1px solid var(--kc-border);
|
|
515
|
+
}
|
|
516
|
+
.spacer {
|
|
517
|
+
flex: 1;
|
|
518
|
+
}
|
|
519
|
+
label {
|
|
520
|
+
display: flex;
|
|
521
|
+
align-items: center;
|
|
522
|
+
gap: 4px;
|
|
523
|
+
}
|
|
524
|
+
.body {
|
|
525
|
+
display: flex;
|
|
526
|
+
flex: 1;
|
|
527
|
+
min-height: 0;
|
|
528
|
+
}
|
|
529
|
+
.canvas {
|
|
530
|
+
position: relative;
|
|
531
|
+
flex: 1;
|
|
532
|
+
min-width: 0;
|
|
533
|
+
}
|
|
534
|
+
.tooltip {
|
|
535
|
+
position: absolute;
|
|
536
|
+
max-width: 22em;
|
|
537
|
+
padding: 4px 8px;
|
|
538
|
+
border: 1px solid var(--kc-border);
|
|
539
|
+
border-radius: var(--kc-radius);
|
|
540
|
+
background: var(--kc-bg);
|
|
541
|
+
pointer-events: none;
|
|
542
|
+
}
|
|
543
|
+
.tooltip p {
|
|
544
|
+
margin: 0;
|
|
545
|
+
white-space: nowrap;
|
|
546
|
+
}
|
|
547
|
+
.tooltip .title {
|
|
548
|
+
font-weight: 600;
|
|
549
|
+
}
|
|
550
|
+
/* The controls over the parts, half of it each */
|
|
551
|
+
.panel {
|
|
552
|
+
display: flex;
|
|
553
|
+
flex-direction: column;
|
|
554
|
+
width: 12em;
|
|
555
|
+
min-height: 0;
|
|
556
|
+
border-left: 1px solid var(--kc-border);
|
|
557
|
+
}
|
|
558
|
+
.panel[hidden] {
|
|
559
|
+
display: none;
|
|
560
|
+
}
|
|
561
|
+
.controls,
|
|
562
|
+
.parts {
|
|
563
|
+
flex: 1 1 50%;
|
|
564
|
+
min-height: 0;
|
|
565
|
+
overflow: auto;
|
|
566
|
+
padding: var(--kc-gap);
|
|
567
|
+
}
|
|
568
|
+
.controls {
|
|
569
|
+
display: flex;
|
|
570
|
+
flex-direction: column;
|
|
571
|
+
gap: var(--kc-gap);
|
|
572
|
+
}
|
|
573
|
+
.parts {
|
|
574
|
+
border-top: 1px solid var(--kc-border);
|
|
575
|
+
}
|
|
576
|
+
.caption {
|
|
577
|
+
margin: 0 0 2px;
|
|
578
|
+
color: var(--kc-muted);
|
|
579
|
+
font-size: 0.92em;
|
|
580
|
+
}
|
|
581
|
+
.group {
|
|
582
|
+
display: grid;
|
|
583
|
+
grid-template-columns: repeat(auto-fill, minmax(4.5em, 1fr));
|
|
584
|
+
gap: 2px;
|
|
585
|
+
}
|
|
586
|
+
.group.modes {
|
|
587
|
+
grid-template-columns: repeat(3, 1fr);
|
|
588
|
+
}
|
|
589
|
+
.group .caption {
|
|
590
|
+
grid-column: 1 / -1;
|
|
591
|
+
}
|
|
592
|
+
.group button {
|
|
593
|
+
padding: 2px 4px;
|
|
594
|
+
font-size: 0.92em;
|
|
595
|
+
}
|
|
596
|
+
.parts ul {
|
|
597
|
+
list-style: none;
|
|
598
|
+
margin: 0;
|
|
599
|
+
padding: 0;
|
|
600
|
+
}
|
|
601
|
+
.parts button {
|
|
602
|
+
display: flex;
|
|
603
|
+
align-items: center;
|
|
604
|
+
gap: 6px;
|
|
605
|
+
width: 100%;
|
|
606
|
+
font: inherit;
|
|
607
|
+
color: var(--kc-fg);
|
|
608
|
+
border: 1px solid transparent;
|
|
609
|
+
border-radius: var(--kc-radius);
|
|
610
|
+
padding: 2px 8px;
|
|
611
|
+
background: none;
|
|
612
|
+
text-align: left;
|
|
613
|
+
cursor: pointer;
|
|
614
|
+
}
|
|
615
|
+
/* Tinted as a list's choice is (the desktop app's tree too), its edge in the accent */
|
|
616
|
+
.parts button[aria-pressed='true'] {
|
|
617
|
+
background: var(--kc-accent-subtle);
|
|
618
|
+
border-color: var(--kc-accent);
|
|
619
|
+
}
|
|
620
|
+
.none {
|
|
621
|
+
margin: 0;
|
|
622
|
+
color: var(--kc-muted);
|
|
623
|
+
}
|
|
624
|
+
.swatch {
|
|
625
|
+
width: 10px;
|
|
626
|
+
height: 10px;
|
|
627
|
+
border-radius: 2px;
|
|
628
|
+
flex: none;
|
|
629
|
+
}
|
|
630
|
+
.name {
|
|
631
|
+
flex: 1;
|
|
632
|
+
}
|
|
633
|
+
.count {
|
|
634
|
+
color: var(--kc-muted);
|
|
635
|
+
}
|
|
636
|
+
/* How the run went, and what it said went wrong: a few lines, scrolled past them */
|
|
637
|
+
.run {
|
|
638
|
+
border-top: 1px solid var(--kc-border);
|
|
639
|
+
}
|
|
640
|
+
/* The run's status, the shortcuts' button at its end */
|
|
641
|
+
.line {
|
|
642
|
+
display: flex;
|
|
643
|
+
align-items: center;
|
|
644
|
+
gap: var(--kc-gap);
|
|
645
|
+
min-height: 22px;
|
|
646
|
+
padding: 1px var(--kc-gap);
|
|
647
|
+
}
|
|
648
|
+
.status {
|
|
649
|
+
display: flex;
|
|
650
|
+
align-items: center;
|
|
651
|
+
gap: 6px;
|
|
652
|
+
color: var(--kc-muted);
|
|
653
|
+
}
|
|
654
|
+
.shortcuts {
|
|
655
|
+
display: grid;
|
|
656
|
+
place-items: center;
|
|
657
|
+
width: 24px;
|
|
658
|
+
height: 20px;
|
|
659
|
+
margin-left: auto;
|
|
660
|
+
padding: 0;
|
|
661
|
+
color: var(--kc-muted);
|
|
662
|
+
background: none;
|
|
663
|
+
border: none;
|
|
664
|
+
border-radius: var(--kc-radius);
|
|
665
|
+
cursor: pointer;
|
|
666
|
+
}
|
|
667
|
+
.shortcuts:hover {
|
|
668
|
+
color: var(--kc-fg);
|
|
669
|
+
background: var(--kc-hover);
|
|
670
|
+
}
|
|
671
|
+
.shortcuts:focus-visible {
|
|
672
|
+
outline: 2px solid var(--kc-focus);
|
|
673
|
+
outline-offset: -2px;
|
|
674
|
+
}
|
|
675
|
+
.shortcuts svg {
|
|
676
|
+
width: 16px;
|
|
677
|
+
height: 16px;
|
|
678
|
+
fill: none;
|
|
679
|
+
stroke: currentColor;
|
|
680
|
+
stroke-width: 1.5;
|
|
681
|
+
stroke-linecap: round;
|
|
682
|
+
stroke-linejoin: round;
|
|
683
|
+
}
|
|
684
|
+
.dot {
|
|
685
|
+
width: 8px;
|
|
686
|
+
height: 8px;
|
|
687
|
+
border-radius: 50%;
|
|
688
|
+
background: var(--kc-muted);
|
|
689
|
+
}
|
|
690
|
+
.dot.ready {
|
|
691
|
+
background: var(--kc-accent);
|
|
692
|
+
}
|
|
693
|
+
.dot.failed {
|
|
694
|
+
background: var(--kc-error);
|
|
695
|
+
}
|
|
696
|
+
.problems {
|
|
697
|
+
list-style: none;
|
|
698
|
+
margin: 0;
|
|
699
|
+
padding: 0 0 2px;
|
|
700
|
+
max-height: 6.5em;
|
|
701
|
+
overflow: auto;
|
|
702
|
+
}
|
|
703
|
+
.problems li {
|
|
704
|
+
display: flex;
|
|
705
|
+
gap: 6px;
|
|
706
|
+
padding: 1px var(--kc-gap);
|
|
707
|
+
}
|
|
708
|
+
.problems button {
|
|
709
|
+
display: flex;
|
|
710
|
+
flex: 1;
|
|
711
|
+
gap: 6px;
|
|
712
|
+
min-width: 0;
|
|
713
|
+
padding: 0;
|
|
714
|
+
font: inherit;
|
|
715
|
+
color: inherit;
|
|
716
|
+
border: none;
|
|
717
|
+
background: none;
|
|
718
|
+
text-align: left;
|
|
719
|
+
cursor: pointer;
|
|
720
|
+
}
|
|
721
|
+
.problems button:hover .message {
|
|
722
|
+
text-decoration: underline;
|
|
723
|
+
}
|
|
724
|
+
.severity {
|
|
725
|
+
flex: none;
|
|
726
|
+
font-weight: 600;
|
|
727
|
+
}
|
|
728
|
+
.error .severity {
|
|
729
|
+
color: var(--kc-error);
|
|
730
|
+
}
|
|
731
|
+
.warning .severity {
|
|
732
|
+
color: var(--kc-warning);
|
|
733
|
+
}
|
|
734
|
+
.message {
|
|
735
|
+
flex: 1;
|
|
736
|
+
min-width: 0;
|
|
737
|
+
overflow: hidden;
|
|
738
|
+
text-overflow: ellipsis;
|
|
739
|
+
white-space: nowrap;
|
|
740
|
+
}
|
|
741
|
+
.place {
|
|
742
|
+
flex: none;
|
|
743
|
+
color: var(--kc-muted);
|
|
744
|
+
}
|
|
745
|
+
</style>
|