@things-factory/figure-ui 10.1.85 → 10.1.87

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.
@@ -0,0 +1,643 @@
1
+ import { css, html, LitElement, nothing, svg } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { live } from 'lit/directives/live.js'
4
+
5
+ import { i18next, localize } from '@operato/i18n'
6
+ import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
7
+ import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
8
+ import { SidePanelStyles } from './side-panel-styles.js'
9
+ import { insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
10
+ import { END_FACE } from './section-controls.js'
11
+
12
+ type Axis = 'x' | 'y' | 'z'
13
+
14
+ /** How the section is laid on the screen, by the axis it is pushed along: across to the right, then down. */
15
+ const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['x', 'y'] }
16
+
17
+ /** The editor's width and room for the solid beside it: below this the two take turns. */
18
+ export const NARROW_BELOW = 900
19
+
20
+ /**
21
+ * 단면 편집 — a pushed part's section and length in one editor (ADR-0100 §3.2, stage 2).
22
+ *
23
+ * The outline is drawn flat, looking along the push, in millimetres from the part's origin; the figure behind stays in
24
+ * view and shows the solid the working state makes (`command-live`, the same preview the section panel uses), so the 2D
25
+ * and the 3D stand side by side. A drawn section's point is dragged or typed, a side pressed gains a point in its
26
+ * middle, a point is deleted, never below three. A rectangle's width and depth and an ellipse's diameters are dragged by
27
+ * their handles or typed; a circle's two are one. The push's length is typed, and the face it keeps is the one the
28
+ * part's placement or fastening keeps (§4.1: read here, changed elsewhere). A measure that follows another value is
29
+ * shown and not offered.
30
+ *
31
+ * What the kernel would refuse is marked on the outline and said in the list; apply is held back, and the solid shows
32
+ * the last valid state, which the caption names. Apply sends one `set-section` command (one step of the history); cancel
33
+ * or Esc sends nothing and lets the preview go. The undo here is the editor's own. If the figure changes while the editor
34
+ * is open, apply is refused until it is opened again.
35
+ */
36
+ @customElement('figure-section-editor')
37
+ export class FigureSectionEditor extends localize(i18next)(LitElement) {
38
+ static styles = [SidePanelStyles, css`
39
+ :host {
40
+ position: absolute;
41
+ left: 12px;
42
+ top: 12px;
43
+ bottom: 12px;
44
+ z-index: 5;
45
+ display: grid;
46
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
47
+ width: min(560px, calc(100% - 24px));
48
+ background: var(--md-sys-color-surface);
49
+ color: var(--md-sys-color-on-surface);
50
+ border: 1px solid var(--md-sys-color-outline-variant);
51
+ border-radius: 12px;
52
+ box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
53
+ overflow: hidden;
54
+ }
55
+ /* Each in its own row: the tabs are hidden on a wide screen, and without the rows named the body fell into theirs. */
56
+ header {
57
+ grid-row: 1;
58
+ }
59
+ nav[tabs] {
60
+ grid-row: 2;
61
+ }
62
+ div[body] {
63
+ grid-row: 3;
64
+ }
65
+ footer {
66
+ grid-row: 4;
67
+ }
68
+ header {
69
+ display: flex;
70
+ align-items: baseline;
71
+ gap: 10px;
72
+ padding: 10px 14px 4px;
73
+ }
74
+ header h2 {
75
+ margin: 0;
76
+ font-size: var(--ui-t-body);
77
+ }
78
+ header span {
79
+ font-size: 0.72rem;
80
+ color: var(--md-sys-color-on-surface-variant);
81
+ }
82
+ nav[tabs] {
83
+ display: none;
84
+ gap: 4px;
85
+ padding: 0 14px 6px;
86
+ }
87
+ nav[tabs] button,
88
+ footer button,
89
+ li button {
90
+ font: inherit;
91
+ font-size: 0.78rem;
92
+ min-height: 30px;
93
+ padding: 0 12px;
94
+ border-radius: 15px;
95
+ border: 1px solid var(--md-sys-color-outline-variant);
96
+ background: var(--md-sys-color-surface-container-lowest);
97
+ color: var(--md-sys-color-on-surface);
98
+ cursor: pointer;
99
+ }
100
+ nav[tabs] button[on] {
101
+ background: var(--md-sys-color-secondary-container);
102
+ color: var(--md-sys-color-on-secondary-container);
103
+ }
104
+ div[body] {
105
+ display: grid;
106
+ grid-template-rows: minmax(160px, 1fr) auto;
107
+ min-height: 0;
108
+ border-top: 1px solid var(--md-sys-color-outline-variant);
109
+ }
110
+ div[outline] {
111
+ position: relative;
112
+ min-height: 0;
113
+ }
114
+ svg {
115
+ width: 100%;
116
+ height: 100%;
117
+ display: block;
118
+ touch-action: none;
119
+ user-select: none;
120
+ }
121
+ div[caption] {
122
+ position: absolute;
123
+ left: 8px;
124
+ top: 6px;
125
+ font-size: 0.68rem;
126
+ color: var(--md-sys-color-on-surface-variant);
127
+ pointer-events: none;
128
+ }
129
+ div[caption][last-valid] {
130
+ color: var(--md-sys-color-error);
131
+ font-weight: 600;
132
+ }
133
+ aside {
134
+ max-height: 34vh;
135
+ overflow: auto;
136
+ padding: 8px 14px;
137
+ border-top: 1px solid var(--md-sys-color-outline-variant);
138
+ }
139
+ div[sizes] {
140
+ display: grid;
141
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
142
+ gap: 6px;
143
+ margin-bottom: 8px;
144
+ }
145
+ label[mm] {
146
+ display: grid;
147
+ gap: 2px;
148
+ }
149
+ label[mm] input {
150
+ min-width: 0;
151
+ font: inherit;
152
+ }
153
+ ol[points] {
154
+ list-style: none;
155
+ margin: 0;
156
+ padding: 0;
157
+ display: grid;
158
+ gap: 4px;
159
+ }
160
+ ol[points] li {
161
+ display: grid;
162
+ grid-template-columns: 2.2em minmax(0, 1fr) minmax(0, 1fr) auto;
163
+ gap: 6px;
164
+ align-items: center;
165
+ padding: 2px 4px;
166
+ border-radius: 6px;
167
+ }
168
+ ol[points] li[selected] {
169
+ background: var(--md-sys-color-secondary-container);
170
+ }
171
+ ol[points] li[marked] b {
172
+ color: var(--md-sys-color-error);
173
+ }
174
+ ol[points] input {
175
+ min-width: 0;
176
+ font: inherit;
177
+ }
178
+ ul[issues] {
179
+ margin: 8px 0 0;
180
+ padding: 0 0 0 16px;
181
+ color: var(--md-sys-color-error);
182
+ font-size: 0.76rem;
183
+ }
184
+ small {
185
+ color: var(--md-sys-color-on-surface-variant);
186
+ }
187
+ footer {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 8px;
191
+ padding: 8px 14px;
192
+ border-top: 1px solid var(--md-sys-color-outline-variant);
193
+ }
194
+ footer span[grow] {
195
+ flex: 1;
196
+ font-size: 0.72rem;
197
+ color: var(--md-sys-color-on-surface-variant);
198
+ }
199
+ footer span[grow][stale] {
200
+ color: var(--md-sys-color-error);
201
+ }
202
+ footer button[primary] {
203
+ background: var(--md-sys-color-primary);
204
+ color: var(--md-sys-color-on-primary);
205
+ border-color: transparent;
206
+ }
207
+ button:disabled {
208
+ opacity: 0.45;
209
+ cursor: default;
210
+ }
211
+ /* Too little room beside the editor for the solid: the outline and the solid take turns (§7). */
212
+ :host([narrow]) nav[tabs] {
213
+ display: flex;
214
+ }
215
+ :host([narrow][show='solid']) div[body] {
216
+ display: none;
217
+ }
218
+ :host([narrow][show='solid']) {
219
+ bottom: auto;
220
+ }
221
+ `]
222
+
223
+ /** The part and its evaluated shape as they stand when the editor opens. */
224
+ @property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
225
+ /** Which of its measures can be set (figure-model's `figureSectionMeasuresOf`). */
226
+ @property({ attribute: false }) measures?: { measures: Record<Axis, FigureSectionMeasure>; round: boolean }
227
+ /** Along the push, the face a length change keeps: 1 its start, -1 its end, 0 its centre; undefined when it cannot be said. */
228
+ @property({ attribute: false }) kept?: -1 | 0 | 1
229
+ /** The figure's revision now; the editor remembers the one it opened at. */
230
+ @property({ type: Number }) revision = 0
231
+ /** Narrow: the outline or the solid behind. */
232
+ @property({ reflect: true }) show: 'outline' | 'solid' = 'outline'
233
+ /** Set when the space the editor stands in leaves too little beside it to see the solid. */
234
+ @property({ type: Boolean, reflect: true }) narrow = false
235
+ private room = new ResizeObserver(([entry]) => {
236
+ this.narrow = (entry?.contentRect.width ?? 0) < NARROW_BELOW
237
+ if (!this.narrow) this.show = 'outline'
238
+ })
239
+
240
+ @state() private working: SectionWorking = { points: [], across: {}, length: 0 }
241
+ @state() private selected: string | null = null
242
+ @state() private note = ''
243
+ private opened!: SectionWorking
244
+ private history: SectionWorking[] = []
245
+ private lastValid!: SectionWorking
246
+ private openedAt = 0
247
+ private dragging: string | null = null
248
+ /** The state as it was when a drag began: kept once something moves, so a press alone is not a step back. */
249
+ private dragFrom: SectionWorking | null = null
250
+ private extent = 200
251
+
252
+ private get axis(): Axis {
253
+ return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
254
+ }
255
+ private get kind() {
256
+ return this.body?.shape.extrusion?.section.kind
257
+ }
258
+
259
+ connectedCallback() {
260
+ super.connectedCallback()
261
+ this.working = workingOf(this.body!.shape)
262
+ this.opened = this.working
263
+ this.lastValid = this.working
264
+ this.openedAt = this.revision
265
+ this.extent = this.extentOf(this.working)
266
+ window.addEventListener('keydown', this.keys, true)
267
+ if (this.parentElement) this.room.observe(this.parentElement)
268
+ }
269
+
270
+ disconnectedCallback() {
271
+ window.removeEventListener('keydown', this.keys, true)
272
+ this.room.disconnect()
273
+ super.disconnectedCallback()
274
+ }
275
+
276
+ /** How far the drawing reaches from the origin: the outline's farthest point with room around, in 50 mm steps. */
277
+ private extentOf(w: SectionWorking): number {
278
+ const [h, v] = SCREEN[this.axis]
279
+ const far = Math.max(1, ...w.points.flatMap(p => [Math.abs(p[h] ?? 0), Math.abs(p[v] ?? 0)]), ...Object.values(w.across).map(a => (a ?? 0) / 2))
280
+ return Math.ceil((far * 1.4 + 40) / 50) * 50
281
+ }
282
+
283
+ /*
284
+ * Heard first (capture on the window), and a key the editor takes goes no further: the scene behind listens too, and
285
+ * would take Esc as "select nothing" and Delete as "delete the part".
286
+ */
287
+ private keys = (e: KeyboardEvent) => {
288
+ const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
289
+ const take = () => (e.preventDefault(), e.stopImmediatePropagation())
290
+ if (e.key === 'Escape') {
291
+ take()
292
+ this.cancel()
293
+ } else if ((e.key === 'Delete' || e.key === 'Backspace') && !inField && this.selected) {
294
+ /* In a field the key deletes a character; outside, the chosen point. */
295
+ take()
296
+ this.removePoint(this.selected)
297
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !inField) {
298
+ take()
299
+ this.back()
300
+ }
301
+ }
302
+
303
+ private get stale() {
304
+ return this.revision !== this.openedAt
305
+ }
306
+
307
+ private valid(w: SectionWorking) {
308
+ return workingIssuesOf(this.body!.shape, w).words.length === 0
309
+ }
310
+
311
+ private change(next: SectionWorking) {
312
+ this.history.push(this.working)
313
+ this.working = next
314
+ this.note = ''
315
+ if (this.valid(next)) this.lastValid = next
316
+ this.preview()
317
+ }
318
+
319
+ private back() {
320
+ const before = this.history.pop()
321
+ if (!before) return
322
+ this.working = before
323
+ if (this.valid(before)) this.lastValid = before
324
+ this.preview()
325
+ }
326
+
327
+ /** The solid behind shows the working state, or the last valid one while the working one is refused. */
328
+ private preview() {
329
+ this.dispatchEvent(
330
+ new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
331
+ )
332
+ }
333
+
334
+ /** One command for everything changed since the editor opened; a measure not changed is not sent (it may follow another). */
335
+ private action(w: SectionWorking): FigureAuthoringAction {
336
+ const o = this.opened
337
+ const changed = (a: number | undefined, b: number | undefined) => Math.abs((a ?? 0) - (b ?? 0)) > 1e-9
338
+ const across = Object.fromEntries(Object.entries(w.across).filter(([k, v]) => changed(v, o.across[k as Axis])))
339
+ return {
340
+ kind: 'set-section',
341
+ part: this.body!.id,
342
+ ...(this.kind === 'polygon' ? { points: w.points.map(p => ({ ...p })) } : {}),
343
+ ...(Object.keys(across).length ? { across } : {}),
344
+ ...(changed(w.length, o.length) ? { length: w.length } : {})
345
+ }
346
+ }
347
+
348
+ private removePoint(id: string) {
349
+ const next = removePoint(this.working.points, id)
350
+ if ('refused' in next) {
351
+ this.note = i18next.t('figure.text.section-issue-too-few')
352
+ return
353
+ }
354
+ this.selected = null
355
+ this.change({ ...this.working, points: next })
356
+ }
357
+
358
+ /** A width across set: a circle's two are one. */
359
+ private across(axis: Axis, value: number): SectionWorking {
360
+ const [u, v] = SCREEN[this.axis]
361
+ const both = this.measures?.round ? { [u]: value, [v]: value } : { [axis]: value }
362
+ return { ...this.working, across: { ...this.working.across, ...both } }
363
+ }
364
+
365
+ private apply() {
366
+ if (this.stale || !this.valid(this.working)) return
367
+ this.dispatchEvent(new CustomEvent('command', { detail: { action: this.action(this.working) }, bubbles: true, composed: true }))
368
+ this.close()
369
+ }
370
+
371
+ private cancel() {
372
+ this.dispatchEvent(new CustomEvent('command-live', { detail: { action: null }, bubbles: true, composed: true }))
373
+ this.close()
374
+ }
375
+
376
+ private close() {
377
+ this.dispatchEvent(new CustomEvent('section-editor-close', { bubbles: true, composed: true }))
378
+ }
379
+
380
+ private svgPoint(e: PointerEvent): { h: number; v: number } {
381
+ const el = this.renderRoot.querySelector('svg')!
382
+ const pt = el.createSVGPoint()
383
+ pt.x = e.clientX
384
+ pt.y = e.clientY
385
+ const p = pt.matrixTransform(el.getScreenCTM()!.inverse())
386
+ return { h: Math.round(p.x), v: Math.round(p.y) }
387
+ }
388
+
389
+ /** Words for the face a length change keeps, by the part's relations; nothing when they cannot say. */
390
+ private keptWords(): string {
391
+ if (this.kept === undefined) return ''
392
+ if (this.kept === 0) return i18next.t('figure.text.section-editor-keeps-centre')
393
+ return i18next.t('figure.text.section-editor-keeps', {
394
+ face: i18next.t('figure.label.face-' + END_FACE(this.axis, this.kept === 1 ? 'start' : 'end'))
395
+ })
396
+ }
397
+
398
+ render() {
399
+ if (!this.body) return nothing
400
+ const { issues, words } = workingIssuesOf(this.body.shape, this.working)
401
+ return html`
402
+ <header>
403
+ <h2>${i18next.t('figure.label.section-editor')}</h2>
404
+ <span>${this.body.id}</span>
405
+ </header>
406
+ <nav tabs>
407
+ ${(['outline', 'solid'] as const).map(
408
+ one => html`<button ?on=${this.show === one} @click=${() => (this.show = one)}>${i18next.t(`figure.label.section-editor-${one}`)}</button>`
409
+ )}
410
+ </nav>
411
+ <div body>
412
+ <div outline>${this.drawing(issues, words.length > 0)}</div>
413
+ <aside>${this.list(issues, words)}</aside>
414
+ </div>
415
+ <footer>
416
+ <button ?disabled=${!this.history.length} @click=${() => this.back()}>${i18next.t('figure.button.section-editor-back')}</button>
417
+ <span grow ?stale=${this.stale}>
418
+ ${this.stale
419
+ ? i18next.t('figure.text.section-editor-stale')
420
+ : i18next.t(this.kind === 'polygon' ? 'figure.text.section-editor-hint' : 'figure.text.section-editor-hint-sized')}
421
+ </span>
422
+ <button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
423
+ <button primary ?disabled=${this.stale || words.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
424
+ </footer>
425
+ `
426
+ }
427
+
428
+ /** A section drawn as the screen lays it: across to the right, then down. */
429
+ private outline(section: FigureSection, attrs: { fill: string; stroke: string; width: number; dash?: string }, centre: Partial<Record<Axis, number>> = {}) {
430
+ const [h, v] = SCREEN[this.axis]
431
+ const ch = centre[h] ?? 0,
432
+ cv = centre[v] ?? 0
433
+ if (section.kind === 'polygon')
434
+ return svg`<polygon points=${section.points.map(p => `${p[h]},${p[v]}`).join(' ')} fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
435
+ if (section.kind === 'ellipse')
436
+ return svg`<ellipse cx=${ch} cy=${cv} rx=${section.radius[h]} ry=${section.radius[v]} fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
437
+ const w = section.size[h]!,
438
+ d = section.size[v]!
439
+ return svg`<rect x=${ch - w / 2} y=${cv - d / 2} width=${w} height=${d} rx=${section.corner?.[h] ?? 0} ry=${section.corner?.[v] ?? 0}
440
+ fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
441
+ }
442
+
443
+ private drawing(issues: ReturnType<typeof workingIssuesOf>['issues'], invalid: boolean) {
444
+ const [h, v] = SCREEN[this.axis]
445
+ const e = this.extent
446
+ const marked = markedBy(issues)
447
+ const w = this.working
448
+ const section = sectionOf(this.body!.shape, w)
449
+ const pts = w.points
450
+ const sides = this.kind === 'polygon' && pts.length >= 2 ? figureSectionSidesOf({ kind: 'polygon', points: pts }, this.axis) : []
451
+ const at = (id: string) => pts.find(p => p.id === id)!
452
+ const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
453
+ const lines: number[] = []
454
+ for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
455
+ const cavity = this.body!.shape.cavity
456
+ const inside = cavity && !invalid ? figureCavitySectionOf(section, this.axis, cavity.wall) : undefined
457
+ const sized = (axis: Axis) => this.measures?.measures[axis] === 'editable'
458
+ return html`<div caption ?last-valid=${invalid}>
459
+ ${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
460
+ </div>
461
+ <svg
462
+ viewBox=${`${-e} ${-e} ${2 * e} ${2 * e}`}
463
+ role="img"
464
+ aria-label=${i18next.t('figure.label.section-editor')}
465
+ @pointermove=${(ev: PointerEvent) => {
466
+ if (!this.dragging) return
467
+ if (this.dragFrom) this.history.push(this.dragFrom), (this.dragFrom = null)
468
+ const p = this.svgPoint(ev)
469
+ const handle = /^handle:(.)$/.exec(this.dragging)
470
+ this.working = handle
471
+ ? this.across(handle[1] as Axis, Math.max(1, 2 * Math.abs(handle[1] === h ? p.h : p.v)))
472
+ : { ...this.working, points: movePoint(this.working.points, this.dragging, { [h]: p.h, [v]: p.v }) }
473
+ if (this.valid(this.working)) this.lastValid = this.working
474
+ this.preview()
475
+ }}
476
+ @pointerup=${() => ((this.dragging = null), (this.dragFrom = null))}
477
+ @pointerleave=${() => ((this.dragging = null), (this.dragFrom = null))}
478
+ >
479
+ ${svg`${lines.map(g => svg`<line x1=${g} y1=${-e} x2=${g} y2=${e} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />
480
+ <line x1=${-e} y1=${g} x2=${e} y2=${g} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />`)}
481
+ <line x1=${-e} y1="0" x2=${e} y2="0" stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
482
+ <line x1="0" y1=${-e} x2="0" y2=${e} stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
483
+ <text x=${e * 0.92} y=${-e * 0.02} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${h.toUpperCase()}</text>
484
+ <text x=${e * 0.02} y=${e * 0.95} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${v.toUpperCase()}</text>
485
+ ${invalid ? this.outline(sectionOf(this.body!.shape, this.lastValid), { fill: 'none', stroke: 'var(--md-sys-color-outline)', width: e / 250, dash: `${e / 50} ${e / 70}` }) : nothing}
486
+ ${this.outline(section, {
487
+ fill: invalid ? 'none' : 'color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent)',
488
+ stroke: this.kind === 'polygon' ? 'none' : invalid ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)',
489
+ width: e / 130
490
+ })}
491
+ ${inside && 'section' in inside
492
+ ? this.outline(inside.section, { fill: 'var(--md-sys-color-surface)', stroke: 'var(--md-sys-color-primary)', width: e / 250, dash: `${e / 60} ${e / 90}` }, inside.centre)
493
+ : nothing}
494
+ ${sides.map(s => {
495
+ const a = at(s.from!),
496
+ b = at(s.to!)
497
+ const bad = marked.sides.has(s.id)
498
+ return svg`<line x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]}
499
+ stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / (bad ? 90 : 130)}
500
+ stroke-dasharray=${bad ? `${e / 40} ${e / 80}` : ''} />
501
+ <line side=${s.id} x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]} stroke="transparent" stroke-width=${e / 25} style="cursor: copy"
502
+ @pointerdown=${(ev: PointerEvent) => {
503
+ ev.stopPropagation()
504
+ const made = insertAfter(this.working.points, s.from!, this.axis)
505
+ this.change({ ...this.working, points: made.points })
506
+ this.selected = made.id
507
+ }}><title>${i18next.t('figure.text.section-editor-add-here', { a: pointLabel(s.from!), b: pointLabel(s.to!) })}</title></line>`
508
+ })}
509
+ ${pts.map(p => {
510
+ const bad = marked.points.has(p.id),
511
+ on = this.selected === p.id
512
+ return svg`<g point=${p.id} style="cursor: grab"
513
+ @pointerdown=${(ev: PointerEvent) => {
514
+ ev.stopPropagation()
515
+ ;(ev.target as Element).setPointerCapture?.(ev.pointerId)
516
+ this.dragFrom = this.working
517
+ this.selected = p.id
518
+ this.dragging = p.id
519
+ }}>
520
+ <circle cx=${p[h]} cy=${p[v]} r=${e / (on ? 28 : 36)} fill=${bad ? 'var(--md-sys-color-error)' : on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface)'}
521
+ stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / 250} />
522
+ <text x=${p[h]! + e / 22} y=${p[v]! - e / 22} font-size=${e / 16} font-weight="600"
523
+ fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
524
+ </g>`
525
+ })}
526
+ ${this.kind === 'polygon'
527
+ ? nothing
528
+ : ([h, v] as const).filter(sized).map(axis => {
529
+ const half = (w.across[axis] ?? 0) / 2
530
+ const cx = axis === h ? half : 0,
531
+ cy = axis === h ? 0 : half
532
+ return svg`<g handle=${axis} style=${`cursor: ${axis === h ? 'ew-resize' : 'ns-resize'}`}
533
+ @pointerdown=${(ev: PointerEvent) => {
534
+ ev.stopPropagation()
535
+ ;(ev.target as Element).setPointerCapture?.(ev.pointerId)
536
+ this.dragFrom = this.working
537
+ this.dragging = `handle:${axis}`
538
+ }}>
539
+ <rect x=${cx - e / 40} y=${cy - e / 40} width=${e / 20} height=${e / 20} rx=${e / 120}
540
+ fill="var(--md-sys-color-surface)" stroke="var(--md-sys-color-primary)" stroke-width=${e / 200} />
541
+ <text x=${cx + e / 28} y=${cy - e / 28} font-size=${e / 18} fill="var(--md-sys-color-on-surface)">${Math.round((w.across[axis] ?? 0) * 10) / 10}</text>
542
+ <title>${i18next.t('figure.label.section-editor-across', { axis: axis.toUpperCase() })}</title>
543
+ </g>`
544
+ })}`}
545
+ </svg>`
546
+ }
547
+
548
+ /** A millimetre field: committed to the working state on change, a bad number put back. */
549
+ private field(label: string, now: number, set: (value: number) => void, disabled = false, note = '') {
550
+ return html`<label mm>
551
+ <span>${label}</span>
552
+ <input
553
+ type="number"
554
+ step="any"
555
+ min="0"
556
+ inputmode="decimal"
557
+ aria-label=${label}
558
+ .value=${live(String(Math.round(now * 1000) / 1000))}
559
+ ?disabled=${disabled}
560
+ @change=${(ev: Event) => {
561
+ const input = ev.target as HTMLInputElement
562
+ const value = Number(input.value)
563
+ if (input.value === '' || !Number.isFinite(value)) {
564
+ input.value = String(now)
565
+ return
566
+ }
567
+ set(value)
568
+ }}
569
+ />
570
+ ${note ? html`<small>${note}</small>` : nothing}
571
+ </label>`
572
+ }
573
+
574
+ private list(issues: ReturnType<typeof workingIssuesOf>['issues'], words: ReturnType<typeof workingIssuesOf>['words']) {
575
+ const [h, v] = SCREEN[this.axis]
576
+ const marked = markedBy(issues)
577
+ const m = this.measures?.measures
578
+ const linked = i18next.t('figure.text.section-editor-linked')
579
+ return html`<div sizes>
580
+ ${this.field(
581
+ i18next.t('figure.label.section-editor-length'),
582
+ this.working.length,
583
+ value => this.change({ ...this.working, length: value }),
584
+ m?.[this.axis] !== 'editable',
585
+ m?.[this.axis] === 'linked' ? linked : this.keptWords()
586
+ )}
587
+ ${this.kind === 'polygon'
588
+ ? nothing
589
+ : this.measures?.round
590
+ ? this.field(i18next.t('figure.label.section-editor-diameter'), this.working.across[h] ?? 0, value => this.change(this.across(h, value)), m?.[h] !== 'editable', m?.[h] === 'linked' ? linked : '')
591
+ : ([h, v] as const).map(axis =>
592
+ this.field(
593
+ i18next.t('figure.label.section-editor-across', { axis: axis.toUpperCase() }),
594
+ this.working.across[axis] ?? 0,
595
+ value => this.change(this.across(axis, value)),
596
+ m?.[axis] !== 'editable',
597
+ m?.[axis] === 'linked' ? linked : ''
598
+ )
599
+ )}
600
+ </div>
601
+ ${this.kind === 'polygon'
602
+ ? html`<ol points aria-label=${i18next.t('figure.label.section-editor-points')}>
603
+ ${this.working.points.map(
604
+ p => html`<li ?selected=${this.selected === p.id} ?marked=${marked.points.has(p.id)} @focusin=${() => (this.selected = p.id)}>
605
+ <b>${pointLabel(p.id)}</b>
606
+ ${([h, v] as const).map(
607
+ axis => html`<input
608
+ type="number"
609
+ step="any"
610
+ inputmode="decimal"
611
+ aria-label=${i18next.t('figure.label.section-editor-coordinate', { point: pointLabel(p.id), axis: axis.toUpperCase() })}
612
+ .value=${live(String(p[axis] ?? ''))}
613
+ @change=${(ev: Event) => {
614
+ const input = ev.target as HTMLInputElement
615
+ const value = Number(input.value)
616
+ if (input.value === '' || !Number.isFinite(value)) {
617
+ input.value = String(p[axis] ?? '')
618
+ return
619
+ }
620
+ this.change({ ...this.working, points: movePoint(this.working.points, p.id, { [axis]: value }) })
621
+ }}
622
+ />`
623
+ )}
624
+ <button
625
+ ?disabled=${this.working.points.length <= 3}
626
+ title=${this.working.points.length <= 3 ? i18next.t('figure.text.section-issue-too-few') : ''}
627
+ aria-label=${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
628
+ @click=${() => this.removePoint(p.id)}
629
+ >
630
+ ${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
631
+ </button>
632
+ </li>`
633
+ )}
634
+ </ol>`
635
+ : nothing}
636
+ ${this.note ? html`<small role="status">${this.note}</small>` : nothing}
637
+ ${words.length
638
+ ? html`<ul issues role="alert">
639
+ ${words.map(w => html`<li>${i18next.t(w.key, w.params)}</li>`)}
640
+ </ul>`
641
+ : nothing}`
642
+ }
643
+ }
@@ -99,6 +99,13 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
99
99
  const kind = e.section.kind
100
100
  return html`<section>
101
101
  <h3>${i18next.t('figure.label.group-section')}</h3>
102
+ ${body.shape.dimensions.jamb === undefined
103
+ ? html`<div field>
104
+ <button act ?disabled=${this.locked} @click=${() => this.dispatchEvent(new CustomEvent('section-editor-open', { bubbles: true, composed: true }))}>
105
+ ${i18next.t('figure.button.section-editor-open')}
106
+ </button>
107
+ </div>`
108
+ : nothing}
102
109
  ${kind === 'rectangle' ? this.renderCorner(body) : nothing}
103
110
  ${kind === 'rectangle' || kind === 'ellipse'
104
111
  ? this.renderCavity(body, e.axis)
@@ -57,6 +57,11 @@ export function livePartsOf(asset: FigureAsset, live: LiveValues): FigureRenderP
57
57
  if (live.action) next = applyFigureAuthoring(next, live.action)
58
58
  /* Read against the figure the edit makes: a command can take an input away (one wall becomes one per side). */
59
59
  const { design, state } = liveOverridesOf(next, live.values)
60
+ /*
61
+ * An input the edit or the command in hand wrote takes its value: the values as they stand hold its old one (a
62
+ * point's coordinate before it was moved, a corner's radius before it was typed again) and would put it back.
63
+ */
64
+ for (const id of Object.keys(design)) if (next.designInputs[id] !== asset.designInputs[id]) delete design[id]
60
65
  if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
61
66
  return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
62
67
  } catch {