@things-factory/figure-ui 10.1.86 → 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.
@@ -6,7 +6,7 @@ import { notify } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement } from '@hatiolab/figure-model'
@@ -1784,9 +1784,15 @@ export class FigureEditor extends LitElement {
1784
1784
  /** The section editor over the canvas, the figure behind it drawing what the working points make. */
1785
1785
  private renderSectionEditor(geometry: readonly { id: string }[]) {
1786
1786
  const part = geometry.find(g => ownerOf(g.id) === this.selected) as { id: string; shape: FigureEvaluatedShape } | undefined
1787
- if (!part?.shape?.extrusion || part.shape.extrusion.section.kind !== 'polygon') return nothing
1787
+ /* A door frame's section is its own width, height, jamb and head: its fields edit it, not an outline. */
1788
+ if (!part?.shape?.extrusion || part.shape.dimensions.jamb !== undefined) return nothing
1789
+ const asset = this.asset() as FigureAsset
1790
+ const measures = figureSectionMeasuresOf(asset, this.selected)
1791
+ const kept = figureScaleAnchorOf(asset, this.selected)?.[part.shape.extrusion.axis]
1788
1792
  return html`<figure-section-editor
1789
1793
  .body=${{ ...part, id: this.selected }}
1794
+ .measures=${measures}
1795
+ .kept=${kept}
1790
1796
  .revision=${this.session?.read().revision ?? 0}
1791
1797
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1792
1798
  @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
@@ -3,10 +3,11 @@ import { customElement, property, state } from 'lit/decorators.js'
3
3
  import { live } from 'lit/directives/live.js'
4
4
 
5
5
  import { i18next, localize } from '@operato/i18n'
6
- import { figureSectionSidesOf } from '@hatiolab/figure-model'
7
- import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSectionPoint } from '@hatiolab/figure-model'
6
+ import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
7
+ import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
- import { extentOf, insertAfter, issueWords, issuesOf, markedBy, movePoint, pointLabel, removePoint, type Points } from './section-drawing.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'
10
11
 
11
12
  type Axis = 'x' | 'y' | 'z'
12
13
 
@@ -17,17 +18,20 @@ const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['
17
18
  export const NARROW_BELOW = 900
18
19
 
19
20
  /**
20
- * 단면 편집 — a drawn section edited point by point (ADR-0100 §3.2, stage 2).
21
+ * 단면 편집 — a pushed part's section and length in one editor (ADR-0100 §3.2, stage 2).
21
22
  *
22
23
  * The outline is drawn flat, looking along the push, in millimetres from the part's origin; the figure behind stays in
23
- * view and shows the solid the working points make (`command-live`, the same preview the section panel uses), so the 2D
24
- * and the 3D stand side by side. A point is dragged or typed; a side pressed gains a point in its middle; a point is
25
- * deleted by its button or the Delete key, never below three. What the kernel would refuse is marked on the outline and
26
- * said in the list; apply is held back, and the solid shows the last valid shape, which the caption names.
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.
27
30
  *
28
- * Apply sends one `command` (one step of the history); cancel or Esc sends nothing and lets the preview go. The undo
29
- * here is the editor's own, for the working points only. If the figure changes while the editor is open, the working
30
- * points are no longer of the figure as it is, and apply is refused until the editor is opened again.
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.
31
35
  */
32
36
  @customElement('figure-section-editor')
33
37
  export class FigureSectionEditor extends localize(i18next)(LitElement) {
@@ -132,6 +136,20 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
132
136
  padding: 8px 14px;
133
137
  border-top: 1px solid var(--md-sys-color-outline-variant);
134
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
+ }
135
153
  ol[points] {
136
154
  list-style: none;
137
155
  margin: 0;
@@ -204,6 +222,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
204
222
 
205
223
  /** The part and its evaluated shape as they stand when the editor opens. */
206
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
207
229
  /** The figure's revision now; the editor remembers the one it opened at. */
208
230
  @property({ type: Number }) revision = 0
209
231
  /** Narrow: the outline or the solid behind. */
@@ -215,28 +237,32 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
215
237
  if (!this.narrow) this.show = 'outline'
216
238
  })
217
239
 
218
- @state() private working: FigureSectionPoint[] = []
240
+ @state() private working: SectionWorking = { points: [], across: {}, length: 0 }
219
241
  @state() private selected: string | null = null
220
242
  @state() private note = ''
221
- private history: FigureSectionPoint[][] = []
222
- private lastValid: FigureSectionPoint[] = []
243
+ private opened!: SectionWorking
244
+ private history: SectionWorking[] = []
245
+ private lastValid!: SectionWorking
223
246
  private openedAt = 0
224
247
  private dragging: string | null = null
225
- /** The points as they were when a drag began: kept once the point moves, so a press alone is not a step back. */
226
- private dragFrom: FigureSectionPoint[] | 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
227
250
  private extent = 200
228
251
 
229
252
  private get axis(): Axis {
230
253
  return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
231
254
  }
255
+ private get kind() {
256
+ return this.body?.shape.extrusion?.section.kind
257
+ }
232
258
 
233
259
  connectedCallback() {
234
260
  super.connectedCallback()
235
- const s = this.body?.shape.extrusion?.section
236
- this.working = s?.kind === 'polygon' ? s.points.map(p => ({ ...p })) : []
261
+ this.working = workingOf(this.body!.shape)
262
+ this.opened = this.working
237
263
  this.lastValid = this.working
238
264
  this.openedAt = this.revision
239
- this.extent = extentOf(this.working, this.axis)
265
+ this.extent = this.extentOf(this.working)
240
266
  window.addEventListener('keydown', this.keys, true)
241
267
  if (this.parentElement) this.room.observe(this.parentElement)
242
268
  }
@@ -247,6 +273,13 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
247
273
  super.disconnectedCallback()
248
274
  }
249
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
+
250
283
  /*
251
284
  * Heard first (capture on the window), and a key the editor takes goes no further: the scene behind listens too, and
252
285
  * would take Esc as "select nothing" and Delete as "delete the part".
@@ -271,11 +304,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
271
304
  return this.revision !== this.openedAt
272
305
  }
273
306
 
274
- private change(next: FigureSectionPoint[]) {
307
+ private valid(w: SectionWorking) {
308
+ return workingIssuesOf(this.body!.shape, w).words.length === 0
309
+ }
310
+
311
+ private change(next: SectionWorking) {
275
312
  this.history.push(this.working)
276
313
  this.working = next
277
314
  this.note = ''
278
- if (!issuesOf(next, this.axis).length) this.lastValid = next
315
+ if (this.valid(next)) this.lastValid = next
279
316
  this.preview()
280
317
  }
281
318
 
@@ -283,33 +320,50 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
283
320
  const before = this.history.pop()
284
321
  if (!before) return
285
322
  this.working = before
286
- if (!issuesOf(before, this.axis).length) this.lastValid = before
323
+ if (this.valid(before)) this.lastValid = before
287
324
  this.preview()
288
325
  }
289
326
 
290
- /** The solid behind shows the working points, or the last valid ones while the working ones are refused. */
327
+ /** The solid behind shows the working state, or the last valid one while the working one is refused. */
291
328
  private preview() {
292
329
  this.dispatchEvent(
293
330
  new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
294
331
  )
295
332
  }
296
333
 
297
- private action(points: Points): FigureAuthoringAction {
298
- return { kind: 'set-section-points', part: this.body!.id, points: points.map(p => ({ ...p })) }
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
+ }
299
346
  }
300
347
 
301
348
  private removePoint(id: string) {
302
- const next = removePoint(this.working, id)
349
+ const next = removePoint(this.working.points, id)
303
350
  if ('refused' in next) {
304
351
  this.note = i18next.t('figure.text.section-issue-too-few')
305
352
  return
306
353
  }
307
354
  this.selected = null
308
- this.change(next)
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 } }
309
363
  }
310
364
 
311
365
  private apply() {
312
- if (this.stale || issuesOf(this.working, this.axis).length) return
366
+ if (this.stale || !this.valid(this.working)) return
313
367
  this.dispatchEvent(new CustomEvent('command', { detail: { action: this.action(this.working) }, bubbles: true, composed: true }))
314
368
  this.close()
315
369
  }
@@ -332,14 +386,22 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
332
386
  return { h: Math.round(p.x), v: Math.round(p.y) }
333
387
  }
334
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
+
335
398
  render() {
336
399
  if (!this.body) return nothing
337
- const issues = issuesOf(this.working, this.axis)
338
- const length = this.body.shape.extrusion ? this.body.shape.extrusion.end - this.body.shape.extrusion.start : 0
400
+ const { issues, words } = workingIssuesOf(this.body.shape, this.working)
339
401
  return html`
340
402
  <header>
341
403
  <h2>${i18next.t('figure.label.section-editor')}</h2>
342
- <span>${this.body.id} · ${i18next.t('figure.text.section-editor-length', { length: String(Math.round(length * 1000) / 1000) })}</span>
404
+ <span>${this.body.id}</span>
343
405
  </header>
344
406
  <nav tabs>
345
407
  ${(['outline', 'solid'] as const).map(
@@ -347,31 +409,52 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
347
409
  )}
348
410
  </nav>
349
411
  <div body>
350
- <div outline>${this.drawing(issues)}</div>
351
- <aside>${this.list(issues)}</aside>
412
+ <div outline>${this.drawing(issues, words.length > 0)}</div>
413
+ <aside>${this.list(issues, words)}</aside>
352
414
  </div>
353
415
  <footer>
354
416
  <button ?disabled=${!this.history.length} @click=${() => this.back()}>${i18next.t('figure.button.section-editor-back')}</button>
355
417
  <span grow ?stale=${this.stale}>
356
- ${this.stale ? i18next.t('figure.text.section-editor-stale') : i18next.t('figure.text.section-editor-hint')}
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')}
357
421
  </span>
358
422
  <button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
359
- <button primary ?disabled=${this.stale || issues.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
423
+ <button primary ?disabled=${this.stale || words.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
360
424
  </footer>
361
425
  `
362
426
  }
363
427
 
364
- private drawing(issues: ReturnType<typeof issuesOf>) {
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) {
365
444
  const [h, v] = SCREEN[this.axis]
366
445
  const e = this.extent
367
446
  const marked = markedBy(issues)
368
- const pts = this.working
369
- const sides = pts.length >= 2 ? figureSectionSidesOf({ kind: 'polygon', points: pts }, this.axis) : []
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) : []
370
451
  const at = (id: string) => pts.find(p => p.id === id)!
371
452
  const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
372
453
  const lines: number[] = []
373
454
  for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
374
- const invalid = issues.length > 0
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'
375
458
  return html`<div caption ?last-valid=${invalid}>
376
459
  ${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
377
460
  </div>
@@ -383,8 +466,11 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
383
466
  if (!this.dragging) return
384
467
  if (this.dragFrom) this.history.push(this.dragFrom), (this.dragFrom = null)
385
468
  const p = this.svgPoint(ev)
386
- this.working = movePoint(this.working, this.dragging, { [h]: p.h, [v]: p.v })
387
- if (!issuesOf(this.working, this.axis).length) this.lastValid = this.working
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
388
474
  this.preview()
389
475
  }}
390
476
  @pointerup=${() => ((this.dragging = null), (this.dragFrom = null))}
@@ -396,10 +482,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
396
482
  <line x1="0" y1=${-e} x2="0" y2=${e} stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
397
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>
398
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>
399
- ${invalid && this.lastValid.length
400
- ? svg`<polygon points=${this.lastValid.map(p => `${p[h]},${p[v]}`).join(' ')} fill="none" stroke="var(--md-sys-color-outline)" stroke-dasharray=${`${e / 50} ${e / 70}`} stroke-width=${e / 250} />`
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)
401
493
  : nothing}
402
- <polygon points=${pts.map(p => `${p[h]},${p[v]}`).join(' ')} fill=${invalid ? 'none' : 'color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent)'} stroke="none" />
403
494
  ${sides.map(s => {
404
495
  const a = at(s.from!),
405
496
  b = at(s.to!)
@@ -410,8 +501,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
410
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"
411
502
  @pointerdown=${(ev: PointerEvent) => {
412
503
  ev.stopPropagation()
413
- const made = insertAfter(this.working, s.from!, this.axis)
414
- this.change(made.points)
504
+ const made = insertAfter(this.working.points, s.from!, this.axis)
505
+ this.change({ ...this.working, points: made.points })
415
506
  this.selected = made.id
416
507
  }}><title>${i18next.t('figure.text.section-editor-add-here', { a: pointLabel(s.from!), b: pointLabel(s.to!) })}</title></line>`
417
508
  })}
@@ -431,53 +522,121 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
431
522
  <text x=${p[h]! + e / 22} y=${p[v]! - e / 22} font-size=${e / 16} font-weight="600"
432
523
  fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
433
524
  </g>`
434
- })}`}
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
+ })}`}
435
545
  </svg>`
436
546
  }
437
547
 
438
- private list(issues: ReturnType<typeof issuesOf>) {
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']) {
439
575
  const [h, v] = SCREEN[this.axis]
440
576
  const marked = markedBy(issues)
441
- return html`<ol points aria-label=${i18next.t('figure.label.section-editor-points')}>
442
- ${this.working.map(
443
- p => html`<li ?selected=${this.selected === p.id} ?marked=${marked.points.has(p.id)} @focusin=${() => (this.selected = p.id)}>
444
- <b>${pointLabel(p.id)}</b>
445
- ${([h, v] as const).map(
446
- axis => html`<input
447
- type="number"
448
- step="any"
449
- inputmode="decimal"
450
- aria-label=${i18next.t('figure.label.section-editor-coordinate', { point: pointLabel(p.id), axis: axis.toUpperCase() })}
451
- .value=${live(String(p[axis] ?? ''))}
452
- @change=${(ev: Event) => {
453
- const input = ev.target as HTMLInputElement
454
- const value = Number(input.value)
455
- if (input.value === '' || !Number.isFinite(value)) {
456
- input.value = String(p[axis] ?? '')
457
- return
458
- }
459
- this.change(movePoint(this.working, p.id, { [axis]: value }))
460
- }}
461
- />`
462
- )}
463
- <button
464
- ?disabled=${this.working.length <= 3}
465
- title=${this.working.length <= 3 ? i18next.t('figure.text.section-issue-too-few') : ''}
466
- aria-label=${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
467
- @click=${() => this.removePoint(p.id)}
468
- >
469
- ${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
470
- </button>
471
- </li>`
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()
472
586
  )}
473
- </ol>
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}
474
636
  ${this.note ? html`<small role="status">${this.note}</small>` : nothing}
475
- ${issues.length
637
+ ${words.length
476
638
  ? html`<ul issues role="alert">
477
- ${issues.map(i => {
478
- const w = issueWords(i, this.working, this.axis)
479
- return html`<li>${i18next.t(w.key, w.params)}</li>`
480
- })}
639
+ ${words.map(w => html`<li>${i18next.t(w.key, w.params)}</li>`)}
481
640
  </ul>`
482
641
  : nothing}`
483
642
  }
@@ -99,19 +99,19 @@ 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)
105
- : html`${kind === 'polygon' && !body.shape.cavity && body.shape.dimensions.jamb === undefined
106
- ? html`<div field>
107
- <button act ?disabled=${this.locked} @click=${() => this.dispatchEvent(new CustomEvent('section-editor-open', { bubbles: true, composed: true }))}>
108
- ${i18next.t('figure.button.section-editor-open')}
109
- </button>
110
- </div>`
111
- : nothing}
112
- <div field>
113
- <small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
114
- </div>`}
112
+ : html`<div field>
113
+ <small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
114
+ </div>`}
115
115
  </section>`
116
116
  }
117
117
 
@@ -1,5 +1,5 @@
1
- import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureSectionSidesOf } from '@hatiolab/figure-model'
2
- import type { FigureSectionIssue, FigureSectionPoint } from '@hatiolab/figure-model'
1
+ import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureExtrusionFeaturesOf, figureSectionSidesOf } from '@hatiolab/figure-model'
2
+ import type { FigureEvaluatedShape, FigureSection, FigureSectionIssue, FigureSectionPoint } from '@hatiolab/figure-model'
3
3
 
4
4
  /*
5
5
  * What the section editor does to a drawn section's points, and how it says what is wrong with them (ADR-0100 §3.2).
@@ -68,6 +68,8 @@ export function issueWords(issue: FigureSectionIssue, points: Points, axis: Axis
68
68
  return { key: 'figure.text.section-issue-too-few', params: {} }
69
69
  case 'TOO_MANY_POINTS':
70
70
  return { key: 'figure.text.section-issue-too-many', params: { limit: String(issue.limit) } }
71
+ case 'CORNER_TOO_LARGE':
72
+ return { key: 'figure.text.section-issue-corner', params: { max: String(Math.round(issue.max * 1000) / 1000) } }
71
73
  default:
72
74
  return { key: 'figure.text.section-issue-number', params: {} }
73
75
  }
@@ -95,3 +97,54 @@ export function extentOf(points: Points, axis: Axis): number {
95
97
  }
96
98
 
97
99
  export const POINT_LIMIT = FIGURE_SECTION_POINT_LIMIT
100
+
101
+ /** What the section editor holds while it is open: the points of a drawn section, the widths across, the length. */
102
+ export interface SectionWorking {
103
+ points: FigureSectionPoint[]
104
+ /** Along each plane axis: a rectangle's size, an ellipse's diameter. Empty for a drawn section. */
105
+ across: Partial<Record<Axis, number>>
106
+ length: number
107
+ }
108
+
109
+ /** The working state of a pushed part as it stands. */
110
+ export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
111
+ const e = shape.extrusion!
112
+ const s = e.section
113
+ const [u, v] = FIGURE_SECTION_PLANE[e.axis]
114
+ const across =
115
+ s.kind === 'rectangle'
116
+ ? { [u]: s.size[u]!, [v]: s.size[v]! }
117
+ : s.kind === 'ellipse'
118
+ ? { [u]: 2 * s.radius[u]!, [v]: 2 * s.radius[v]! }
119
+ : {}
120
+ return { points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [], across, length: e.end - e.start }
121
+ }
122
+
123
+ /** The section the working state makes, of the part's own kind: its corners kept, its points or widths the working ones. */
124
+ export function sectionOf(shape: FigureEvaluatedShape, w: SectionWorking): FigureSection {
125
+ const s = shape.extrusion!.section
126
+ const [u, v] = FIGURE_SECTION_PLANE[shape.extrusion!.axis]
127
+ if (s.kind === 'polygon') return { kind: 'polygon', points: w.points }
128
+ if (s.kind === 'ellipse') return { kind: 'ellipse', radius: { [u]: w.across[u]! / 2, [v]: w.across[v]! / 2 } }
129
+ return { kind: 'rectangle', size: { [u]: w.across[u]!, [v]: w.across[v]! }, ...(s.corner ? { corner: s.corner } : {}) }
130
+ }
131
+
132
+ /** What would be refused, as words, with the section's own issues for marking points and sides. By the kernel's checks. */
133
+ export function workingIssuesOf(
134
+ shape: FigureEvaluatedShape,
135
+ w: SectionWorking
136
+ ): { issues: FigureSectionIssue[]; words: Array<{ key: string; params: Record<string, string> }> } {
137
+ const e = shape.extrusion!
138
+ const section = sectionOf(shape, w)
139
+ const issues = checkFigureSection(section, e.axis)
140
+ const words = issues.map(i => issueWords(i, w.points, e.axis))
141
+ if (!(Number.isFinite(w.length) && w.length > 0)) words.push({ key: 'figure.text.section-issue-length', params: {} })
142
+ if (!issues.length && words.length === 0 && shape.cavity) {
143
+ const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [shape.cavity])
144
+ if ('refused' in made) {
145
+ const r = made.refused
146
+ words.push({ key: r.code === 'CAVITY_NO_LENGTH' ? 'figure.text.section-issue-cavity-length' : 'figure.text.section-issue-cavity-walls', params: {} })
147
+ }
148
+ }
149
+ return { issues, words }
150
+ }