@things-factory/figure-ui 10.1.105 → 10.1.107

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.
Files changed (39) hide show
  1. package/client/modeller/canvas.ts +6 -13
  2. package/client/modeller/editor.ts +13 -2
  3. package/client/modeller/figure-cavity-drawing.ts +16 -10
  4. package/client/modeller/figure-links.ts +49 -35
  5. package/client/modeller/figure-parts.ts +3 -3
  6. package/client/modeller/figure-section-editor.ts +106 -11
  7. package/client/modeller/section-drawing.ts +27 -4
  8. package/dist-client/modeller/canvas.d.ts +0 -2
  9. package/dist-client/modeller/canvas.js +6 -17
  10. package/dist-client/modeller/canvas.js.map +1 -1
  11. package/dist-client/modeller/clips.d.ts +1 -1
  12. package/dist-client/modeller/editor.js +15 -2
  13. package/dist-client/modeller/editor.js.map +1 -1
  14. package/dist-client/modeller/figure-cavity-drawing.js +14 -8
  15. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  16. package/dist-client/modeller/figure-links.d.ts +2 -2
  17. package/dist-client/modeller/figure-links.js +49 -34
  18. package/dist-client/modeller/figure-links.js.map +1 -1
  19. package/dist-client/modeller/figure-parts.d.ts +2 -1
  20. package/dist-client/modeller/figure-parts.js +2 -2
  21. package/dist-client/modeller/figure-parts.js.map +1 -1
  22. package/dist-client/modeller/figure-section-editor.d.ts +14 -1
  23. package/dist-client/modeller/figure-section-editor.js +109 -9
  24. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  25. package/dist-client/modeller/section-drawing.d.ts +12 -1
  26. package/dist-client/modeller/section-drawing.js +23 -3
  27. package/dist-client/modeller/section-drawing.js.map +1 -1
  28. package/dist-client/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +6 -6
  30. package/test/cavity-drawing.test.ts +5 -0
  31. package/test/command-preview.test.ts +11 -4
  32. package/test/first-minute.test.ts +6 -2
  33. package/test/layout.test.ts +11 -0
  34. package/test/section-editor.test.ts +29 -2
  35. package/translations/en.json +2 -9
  36. package/translations/ja.json +2 -9
  37. package/translations/ko.json +2 -9
  38. package/translations/ms.json +2 -9
  39. package/translations/zh.json +2 -9
@@ -1,6 +1,5 @@
1
- import { LitElement, html, css, nothing, unsafeCSS, type PropertyValues } from 'lit'
1
+ import { LitElement, html, css, unsafeCSS, type PropertyValues } from 'lit'
2
2
  import { customElement, property, query } from 'lit/decorators.js'
3
- import { i18next } from '@operato/i18n'
4
3
  import { create, Model, niceGridStep } from '@hatiolab/things-scene'
5
4
 
6
5
  import type { GizmoReach } from './gizmo.js'
@@ -71,14 +70,6 @@ export class FigureCanvas extends LitElement {
71
70
  width: 100%;
72
71
  height: 100%;
73
72
  }
74
- p {
75
- position: absolute;
76
- inset: 45% 1rem auto;
77
- margin: 0;
78
- text-align: center;
79
- pointer-events: none;
80
- color: #f4f4f4;
81
- }
82
73
  `
83
74
  @property({ attribute: false }) parts: FigureRenderPart[] = []
84
75
  @property({ attribute: false }) palette: Record<string, string> = {}
@@ -96,8 +87,6 @@ export class FigureCanvas extends LitElement {
96
87
  * would reach the editor as an edit.
97
88
  */
98
89
  @property({ type: Boolean }) viewOnly = false
99
- /** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
100
- @property({ type: Boolean }) startShown = false
101
90
  /** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
102
91
  @property({ attribute: false }) fan: FigureRenderPart | null = null
103
92
  /**
@@ -117,7 +106,11 @@ export class FigureCanvas extends LitElement {
117
106
  private writing = false
118
107
 
119
108
  render() {
120
- return html`<div stage></div>${this.parts.length || this.startShown ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
109
+ /*
110
+ * No line of its own over an empty stage: a new figure's start card says how to begin, and an empty stage otherwise
111
+ * is one loading, or with every part hidden -- where 「도형을 추가해…」 flashed and said the wrong thing (2026-10-02).
112
+ */
113
+ return html`<div stage></div>`
121
114
  }
122
115
 
123
116
  firstUpdated() {
@@ -60,6 +60,17 @@ import { startingShapeKindOfBody } from './starting-shape.js'
60
60
  */
61
61
  type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
62
62
 
63
+ /** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
64
+ function foldSaid(): boolean {
65
+ try {
66
+ if (localStorage.getItem('figure-fold-said')) return true
67
+ localStorage.setItem('figure-fold-said', '1')
68
+ } catch {
69
+ /* No storage here: said every time, as it was. */
70
+ }
71
+ return false
72
+ }
73
+
63
74
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
64
75
  @customElement('figure-editor')
65
76
  export class FigureEditor extends LitElement {
@@ -506,6 +517,7 @@ export class FigureEditor extends LitElement {
506
517
  Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
507
518
  FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
508
519
  bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
520
+ Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
509
521
  */
510
522
  private static readonly FOLD_START_UNDER = 1100
511
523
  private foldedForWidth = false
@@ -519,7 +531,7 @@ export class FigureEditor extends LitElement {
519
531
  if (!panes.foldStart && !this.keptOpenByAuthor) {
520
532
  panes.foldStart = true
521
533
  this.foldedForWidth = true
522
- notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
534
+ if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
523
535
  }
524
536
  } else {
525
537
  if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
@@ -2016,7 +2028,6 @@ export class FigureEditor extends LitElement {
2016
2028
  ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
2017
2029
  ${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
2018
2030
  <figure-canvas
2019
- ?startShown=${view.geometry.length === 0 && !unavailable}
2020
2031
  .view=${this.view}
2021
2032
  .parts=${this.drawnParts(view.geometry, asset)}
2022
2033
  .anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
@@ -175,7 +175,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
175
175
  const W = 300,
176
176
  H = 170,
177
177
  pad = 26
178
- const k = Math.min((W - 2 * pad) / across(h), (H - 2 * pad) / across(v))
178
+ /* Wider room left and right: a wall's number stands outside the section there. */
179
+ const k = Math.min((W - 2 * (pad + 10)) / across(h), (H - 2 * pad) / across(v))
179
180
  const ox = W / 2,
180
181
  oy = H / 2
181
182
  const inside = figureCavitySectionOf(s as FigureSection, e.axis, w.wall)
@@ -193,18 +194,23 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
193
194
  const handles = [`${h}-`, `${h}+`, `${v}-`, `${v}+`].map(side => {
194
195
  const a = side[0] as Axis,
195
196
  low = side[1] === '-'
196
- const edge = (low ? -1 : 1) * (half[a] - w.wall[side]! * k)
197
- const x = a === h ? ox + edge : ox,
198
- y = a === v ? oy + edge : oy
199
- /* The number stands in the wall itself, half way through it; a wall too thin for it has it just outside. */
200
- const band = w.wall[side]! * k
201
- const mid = (low ? -1 : 1) * (half[a] - (band < 16 ? -9 : band / 2))
202
- const tx = a === h ? ox + mid : x + 14,
203
- ty = a === v ? oy + mid + 4 : y - 10
197
+ const sign = low ? -1 : 1
198
+ const edge = sign * (half[a] - w.wall[side]! * k)
199
+ /* The two handles across a narrow inside would stand on each other: then each steps aside, the low one first. */
200
+ const room = 2 * half[a] - (w.wall[`${a}-`]! + w.wall[`${a}+`]!) * k
201
+ const aside = room < 16 ? sign * 9 : 0
202
+ const x = a === h ? ox + edge : ox + aside,
203
+ y = a === v ? oy + edge : oy + aside
204
+ /*
205
+ The number stands just past the section's outer edge on its own side, so the opposite wall's never meets it
206
+ however thin the walls or shallow the section (2026-10-02: at 70 mm deep the two walls' numbers lay on each other).
207
+ */
208
+ const tx = a === h ? ox + sign * (half[a] + 5) : ox,
209
+ ty = a === v ? oy + sign * (half[a] + (low ? 5 : 13)) : oy + 4
204
210
  return svg`<g wall=${side} style=${`cursor:${a === h ? 'ew-resize' : 'ns-resize'}`}
205
211
  @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'wall', side })}>
206
212
  <rect x=${x - 6} y=${y - 6} width="12" height="12" rx="2" style="fill:var(--md-sys-color-surface);stroke:var(--md-sys-color-primary);stroke-width:1.5" />
207
- <text typed x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? 'middle' : 'start'} style="fill:var(--md-sys-color-on-surface)"
213
+ <text typed x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? (low ? 'end' : 'start') : 'middle'} style="fill:var(--md-sys-color-on-surface)"
208
214
  @pointerdown=${(ev: PointerEvent) => this.type(ev, { kind: 'wall', side }, 'top', w.wall[side]!)}>${mm(w.wall[side]!)}</text>
209
215
  <title>${i18next.t('figure.label.face-' + SIDE_FACE[side])} ${i18next.t('figure.label.section-wall')}</title>
210
216
  </g>`
@@ -121,6 +121,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
121
121
  p[fasten-mode] { margin: 0; font-weight: 600; }
122
122
  section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
123
123
  details { margin-top: 8px; }
124
+ details[row-fold] { margin: 0; }
125
+ details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
126
+ details[row-fold] > summary h3 { margin: 0; }
127
+ details[row-fold][open] > summary { margin-bottom: 8px; }
124
128
  /* What the command would do, said beside the result drawn on the canvas. */
125
129
  ul[preview-words] { margin: 8px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
126
130
  p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
@@ -399,15 +403,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
399
403
  * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
400
404
  * holding places instead).
401
405
  */
402
- /** The fastening's details open, focus in them: where the gap and the place across the face are changed. */
403
- private async openDetail() {
404
- const detail = this.renderRoot.querySelector<HTMLDetailsElement>('details[fasten-detail]')
405
- if (!detail) return
406
- detail.open = true
406
+ /** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
407
+ private async showWhere() {
407
408
  this.working = 'fasten'
408
409
  await this.updateComplete
409
- detail.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
410
- detail.querySelector<HTMLElement>('select, input')?.focus({ preventScroll: true })
410
+ const where = this.renderRoot.querySelector('figure-fasten-drawing')?.shadowRoot?.querySelector<HTMLElement>('div[where]')
411
+ if (!where) return
412
+ where.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
413
+ where.focus({ preventScroll: true })
411
414
  }
412
415
 
413
416
  /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
@@ -555,19 +558,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
555
558
  } as FigureAuthoringAction)
556
559
  : undefined
557
560
  if (action) this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) }
558
- /* What the details hold that is not their default, and where on the face it sits: said while they are folded. */
561
+ /*
562
+ What the folded details hold that is not their default, said while folded; the way and the place on the face are
563
+ the drawing's, and said there (user 2026-10-02: the details had selects repeating the drawing). Nothing is said
564
+ for the default.
565
+ */
559
566
  const detail = [
560
- ...(f.facing === 'flush' && !f.toPlane ? [i18next.t('figure.label.facing-flush')] : []),
561
- ...Object.entries(align ?? {}).map(([axis, one]) =>
562
- one === 'min' || one === 'max'
563
- ? i18next.t(`figure.text.fasten-summary-edge-${one}`, { axis: axis.toUpperCase() })
564
- : typeof one.mm === 'number'
565
- ? i18next.t('figure.text.fasten-summary-off-centre', { axis: axis.toUpperCase(), mm: String(Math.round(one.mm * 10) / 10) })
566
- : i18next.t('figure.text.fasten-summary-follows', { axis: axis.toUpperCase() })
567
- ),
568
567
  ...(f.gap ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })] : []),
569
568
  ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
570
569
  ]
570
+ /* Across the face, a row only where the drawing cannot set it: no drawing of the face, or a share or a field to follow. */
571
+ const view = f.toPlane || f.target ? faceViewOf(asset, this.partId, aim) : undefined
572
+ const alignRows = (f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : []).filter(
573
+ ({ axis }) => !view || !view.axes.includes(axis) || !!f.alignInput?.[axis] || hasInstanceSize(asset, axis)
574
+ )
575
+ /* A fastening the part has is changed as soon as something is chosen, as the drawing does; a new one waits for 붙이기. */
576
+ const settle = () => this.onFastenRelease(!!held)
571
577
  /*
572
578
  Fastening it anew is said, so the form is not taken for the one it has (designer 2026-09-27). The one it has is
573
579
  said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
@@ -590,7 +596,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
590
596
  ? html`<div overhang role="status">
591
597
  ${this.overhangWords.map(line => html`<p>${line}</p>`)}
592
598
  ${current
593
- ? html`<button act @click=${() => this.openDetail()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
599
+ ? html`<button act @click=${() => this.showWhere()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
594
600
  : nothing}
595
601
  </div>`
596
602
  : nothing}
@@ -607,6 +613,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
607
613
  <select @change=${(e: Event) => {
608
614
  const value = (e.target as HTMLSelectElement).value
609
615
  this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })
616
+ if (value) settle()
610
617
  }}>
611
618
  <option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
612
619
  <option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
@@ -626,7 +633,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
626
633
  .face=${targetFace}
627
634
  .modes=${f.toPlane ? (['meet'] as FastenMode[]) : ([...(['meet', 'flush'] as FastenMode[]), ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])])}
628
635
  .mode=${mode}
629
- .view=${faceViewOf(asset, this.partId, aim)}
636
+ .view=${view}
630
637
  .align=${f.align}
631
638
  .alignMm=${f.alignMm}
632
639
  .gap=${f.gap}
@@ -638,19 +645,21 @@ export class FigureLinks extends localize(i18next)(LitElement) {
638
645
  ></figure-fasten-drawing>`
639
646
  : html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
640
647
  ${(f.toPlane || f.target) ? html`<details fasten-detail>
641
- <summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
642
- ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
648
+ <summary>${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}</summary>
649
+ ${alignRows.length ? this.renderAlignment(alignRows, settle) : nothing}
643
650
  <div form>
644
651
  <span>${i18next.t('figure.label.gap')}</span>
645
- <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
652
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => ((this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
646
653
  <span>${i18next.t('figure.label.twist')}</span>
647
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
654
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => ((this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
648
655
  </div>
649
656
  </details>
650
- ${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
651
- <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
652
- <md-icon>link</md-icon>${i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')}
653
- </button>` : nothing}
657
+ ${/* What 붙이기 would do; a fastening the part has is changed at once, and the canvas shows it. */ action && !held && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
658
+ ${held
659
+ ? nothing
660
+ : html`<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
661
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
662
+ </button>`}` : nothing}
654
663
  `
655
664
  }
656
665
 
@@ -690,10 +699,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
690
699
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
691
700
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
692
701
  */
693
- private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
702
+ private renderAlignment(axes: { axis: Axis; edges: boolean }[], settle: () => void) {
694
703
  const f = this.fasten
695
- const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
696
- const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
704
+ const set = (axis: Axis, how: Alignment) => ((this.fasten = { ...f, align: { ...f.align, [axis]: how } }), settle())
705
+ const setMm = (axis: Axis, mm: number) => ((this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } }), settle())
697
706
  return html`<div form>
698
707
  ${axes.map(({ axis, edges }) => {
699
708
  const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
@@ -739,14 +748,19 @@ export class FigureLinks extends localize(i18next)(LitElement) {
739
748
  const blocked = rowBlockOf(asset, this.partId)
740
749
  const axes = rowAxesOf(asset, this.partId)
741
750
  const r = this.row
751
+ /*
752
+ Folded, open while it is being worked (2026-10-02): a row is made now and then, and most parts stand on the plane,
753
+ where the section said its reason open on every one. Opened, it says why or asks how.
754
+ */
755
+ const head = html`<summary><h3>${i18next.t('figure.label.repeat')}</h3></summary>`
742
756
  if (blocked || !axes.length)
743
- return html`<section row>
744
- <h3>${i18next.t('figure.label.repeat')}</h3>
757
+ return html`<section row><details row-fold>
758
+ ${head}
745
759
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
746
760
  ${blocked === 'unfastened'
747
761
  ? html`<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
748
762
  : nothing}
749
- </section>`
763
+ </details></section>`
750
764
  const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
751
765
  const ok = r.fills ? r.pitch > 0 : Number.isSafeInteger(r.count) && r.count >= 1
752
766
  const action = ok
@@ -754,8 +768,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
754
768
  : undefined
755
769
  this.rowCandidate = action
756
770
  const set = (next: typeof r) => ((this.row = next), (this.working = 'row'))
757
- return html`<section row @focusin=${() => (this.working = 'row')}>
758
- <h3>${i18next.t('figure.label.repeat')}</h3>
771
+ return html`<section row @focusin=${() => (this.working = 'row')}><details row-fold ?open=${this.working === 'row'}>
772
+ ${head}
759
773
  <div form>
760
774
  <span>${i18next.t('figure.label.repeat-axis')}</span>
761
775
  <select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
@@ -780,7 +794,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
780
794
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
781
795
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
782
796
  </button>
783
- </section>`
797
+ </details></section>`
784
798
  }
785
799
 
786
800
  /**
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from 'lit'
3
3
  import { customElement, property } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { ScrollbarStyles } from '@operato/styles'
6
- import { PART_LIMIT } from '@hatiolab/figure-model'
6
+ import { DETAIL_LEVELS, PART_LIMIT, type DetailLevel } from '@hatiolab/figure-model'
7
7
  import type { FigurePrimitiveCreation } from '@hatiolab/figure-model'
8
8
  import type { FigureBudgetLine } from './maturity.js'
9
9
  import { partLabelOf } from './fields.js'
@@ -414,7 +414,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
414
414
  * 디테일 등급(S/M/L)의 부품 한도(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
415
415
  * 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
416
416
  */
417
- @property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
417
+ @property({ attribute: false }) partBudget?: { level: DetailLevel | null; used: number; limit: number | null }
418
418
  /** How many recipe templates the catalog holds; none, and the section is not drawn. */
419
419
  @property({ type: Number }) recipeCount = 0
420
420
 
@@ -494,7 +494,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
494
494
  }}
495
495
  >
496
496
  <option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
497
- ${(['S', 'M', 'L'] as const).map(
497
+ ${DETAIL_LEVELS.map(
498
498
  one => html`<option value=${one} ?selected=${one === b.level}>
499
499
  ${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
500
500
  </option>`
@@ -6,8 +6,9 @@ import { i18next, localize } from '@operato/i18n'
6
6
  import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
7
7
  import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
- import { apexOver, drawnOf, insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
10
- import { END_FACE } from './section-controls.js'
9
+ import { apexOver, drawnOf, insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, workingShapeOf, type SectionWorking } from './section-drawing.js'
10
+ import { cavityDepthOf, cavityRequestOf, cavityResultOf, END_FACE, sameOf, sidesOf } from './section-controls.js'
11
+ import './figure-cavity-drawing.js'
11
12
  import { openPopup } from '@operato/layout'
12
13
  import './figure-section-close-choice.js'
13
14
  import type { SectionCloseChoice } from './figure-section-close-choice.js'
@@ -17,6 +18,10 @@ type Axis = 'x' | 'y' | 'z'
17
18
  /** How the section is laid on the screen, by the axis it is pushed along: across to the right, then down. */
18
19
  const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['x', 'y'] }
19
20
 
21
+ /** The inside said so two can be compared: its walls by side, its ends. */
22
+ const cavityKey = (w: SectionWorking) =>
23
+ w.cavity ? [Object.entries(w.cavity.wall).sort(), w.cavity.start, w.cavity.end] : null
24
+
20
25
  /** The editor's width and room for the solid beside it: below this the two take turns. */
21
26
  export const NARROW_BELOW = 900
22
27
 
@@ -35,6 +40,10 @@ export const NARROW_BELOW = 900
35
40
  * apex is dragged or typed like a point, and is not offered while something is fastened to the end it takes away (the
36
41
  * kernel's own refusal, `pointBlocked`).
37
42
  *
43
+ * A rectangle's or ellipse's inside is made, changed or taken away here too, by the same drawing the section panel has
44
+ * (figure-cavity-drawing), drawn and judged on the widths being typed: walls that fit only a wider section are taken
45
+ * with it, in the same command (figure-model `set-section`'s `cavity`).
46
+ *
38
47
  * What the kernel would refuse is marked on the outline and said in the list; apply is held back, and the solid shows
39
48
  * the last valid state, which the caption names. Apply sends one `set-section` command (one step of the history); cancel
40
49
  * or Esc sends nothing and lets the preview go -- after asking, when something was changed: apply it, let it go, or keep
@@ -192,6 +201,26 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
192
201
  min-width: 0;
193
202
  font: inherit;
194
203
  }
204
+ div[cavity] {
205
+ display: grid;
206
+ gap: 6px;
207
+ margin-top: 10px;
208
+ padding-top: 8px;
209
+ border-top: 1px solid var(--md-sys-color-outline-variant);
210
+ }
211
+ /* The panel's width: wider, the drawing pushed the outline out of view. */
212
+ div[cavity] figure-cavity-drawing {
213
+ max-width: 340px;
214
+ }
215
+ label[toggle] {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 6px;
219
+ flex-wrap: wrap;
220
+ }
221
+ label[toggle] small[result] {
222
+ margin-left: auto;
223
+ }
195
224
  ul[issues] {
196
225
  margin: 8px 0 0;
197
226
  padding: 0 0 0 16px;
@@ -256,7 +285,9 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
256
285
  if (!this.narrow) this.show = 'outline'
257
286
  })
258
287
 
259
- @state() private working: SectionWorking = { points: [], across: {}, length: 0, apex: null }
288
+ @state() private working: SectionWorking = { points: [], across: {}, length: 0, apex: null, cavity: null }
289
+ /** Walls one per side in the inside's drawing; on when they open differing. */
290
+ @state() private perSide = false
260
291
  @state() private selected: string | null = null
261
292
  @state() private note = ''
262
293
  private opened!: SectionWorking
@@ -274,6 +305,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
274
305
  private get kind() {
275
306
  return this.body && drawnOf(this.body.shape)?.section.kind
276
307
  }
308
+ /** A pushed rectangle or ellipse can have an inside; a drawn section's is a later stage. */
309
+ private get hollowable() {
310
+ return !!this.body?.shape.extrusion && (this.kind === 'rectangle' || this.kind === 'ellipse')
311
+ }
277
312
 
278
313
  connectedCallback() {
279
314
  super.connectedCallback()
@@ -282,6 +317,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
282
317
  this.lastValid = this.working
283
318
  this.openedAt = this.revision
284
319
  this.extent = this.extentOf(this.working)
320
+ this.perSide = !!this.working.cavity && sameOf(Object.values(this.working.cavity.wall)) === undefined
285
321
  window.addEventListener('keydown', this.keys, true)
286
322
  if (this.parentElement) this.room.observe(this.parentElement)
287
323
  }
@@ -310,6 +346,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
310
346
  */
311
347
  private keys = (e: KeyboardEvent) => {
312
348
  const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
349
+ /* A number being typed in the inside's drawing: Esc there puts the number back, not the editor away. */
350
+ if (inField && e.composedPath().some(el => (el as HTMLElement).tagName === 'FIGURE-CAVITY-DRAWING')) return
313
351
  /* While the close is being asked, the question has the keys: Esc there is 계속 편집. */
314
352
  if (this.asking) return
315
353
  const take = () => (e.preventDefault(), e.stopImmediatePropagation())
@@ -351,10 +389,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
351
389
  }
352
390
 
353
391
  /** The solid behind shows the working state, or the last valid one while the working one is refused. */
354
- private preview() {
355
- this.dispatchEvent(
356
- new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
357
- )
392
+ private preview(w = this.lastValid) {
393
+ this.dispatchEvent(new CustomEvent('command-live', { detail: { action: this.action(w) }, bubbles: true, composed: true }))
358
394
  }
359
395
 
360
396
  /** One command for everything changed since the editor opened; a measure not changed is not sent (it may follow another). */
@@ -363,13 +399,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
363
399
  const changed = (a: number | undefined, b: number | undefined) => Math.abs((a ?? 0) - (b ?? 0)) > 1e-9
364
400
  const across = Object.fromEntries(Object.entries(w.across).filter(([k, v]) => changed(v, o.across[k as Axis])))
365
401
  const apexMoved = !w.apex !== !o.apex || (!!w.apex && Object.entries(w.apex).some(([k, v]) => changed(v, o.apex![k as Axis])))
402
+ const cavityMoved = JSON.stringify(cavityKey(w)) !== JSON.stringify(cavityKey(o))
366
403
  return {
367
404
  kind: 'set-section',
368
405
  part: this.body!.id,
369
406
  ...(this.kind === 'polygon' ? { points: w.points.map(p => ({ ...p })) } : {}),
370
407
  ...(Object.keys(across).length ? { across } : {}),
371
408
  ...(changed(w.length, o.length) ? { length: w.length } : {}),
372
- ...(apexMoved ? { apex: w.apex ? { ...w.apex } : null } : {})
409
+ ...(apexMoved ? { apex: w.apex ? { ...w.apex } : null } : {}),
410
+ ...(cavityMoved ? { cavity: w.cavity ? { wall: { ...w.cavity.wall }, start: w.cavity.start, end: w.cavity.end } : null } : {})
373
411
  }
374
412
  }
375
413
 
@@ -403,7 +441,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
403
441
  w.points.map(p => [p.id, p.x ?? 0, p.y ?? 0, p.z ?? 0]),
404
442
  Object.entries(w.across).sort(),
405
443
  w.length,
406
- w.apex ? Object.entries(w.apex).sort() : null
444
+ w.apex ? Object.entries(w.apex).sort() : null,
445
+ cavityKey(w)
407
446
  ])
408
447
  return !!this.opened && key(this.working) !== key(this.opened)
409
448
  }
@@ -529,8 +568,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
529
568
  const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
530
569
  const lines: number[] = []
531
570
  for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
532
- const cavity = this.body!.shape.cavity
533
- const inside = cavity && !invalid ? figureCavitySectionOf(section, this.axis, cavity.wall) : undefined
571
+ const inside = w.cavity && !invalid ? figureCavitySectionOf(section, this.axis, w.cavity.wall) : undefined
534
572
  const sized = (axis: Axis) => this.measures?.measures[axis] === 'editable'
535
573
  return html`<div caption ?last-valid=${invalid}>
536
574
  ${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
@@ -710,6 +748,62 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
710
748
  : nothing}`
711
749
  }
712
750
 
751
+ /** The inside, as the section panel shows it, on the working section: on or off, what it makes, its drawing. */
752
+ private cavityBlock(invalid: boolean) {
753
+ const w = this.working
754
+ const shape = workingShapeOf(this.body!.shape, w)
755
+ return html`<div cavity>
756
+ <label toggle>
757
+ <input
758
+ type="checkbox"
759
+ .checked=${live(!!w.cavity)}
760
+ @change=${(ev: Event) => {
761
+ const on = (ev.target as HTMLInputElement).checked
762
+ const ask = cavityRequestOf(shape, {})
763
+ const wall = typeof ask.wall === 'number' ? Object.fromEntries(sidesOf(this.axis).map(side => [side, ask.wall as number])) : (ask.wall as Record<string, number>)
764
+ this.change({ ...w, cavity: on ? { wall, start: ask.start, end: ask.end } : null })
765
+ }}
766
+ />
767
+ ${i18next.t('figure.label.section-cavity')}
768
+ ${w.cavity && !invalid
769
+ ? html`<small result>
770
+ ${i18next.t('figure.text.section-depth', {
771
+ result: i18next.t(`figure.label.section-result-${cavityResultOf(shape.cavity!)}`),
772
+ depth: String(cavityDepthOf(shape))
773
+ })}
774
+ </small>`
775
+ : nothing}
776
+ </label>
777
+ ${w.cavity
778
+ ? html`<figure-cavity-drawing
779
+ .body=${{ id: this.body!.id, shape }}
780
+ .perSide=${this.perSide}
781
+ @command-live=${(ev: CustomEvent) => {
782
+ ev.stopPropagation()
783
+ const c = this.cavityOf(ev.detail.action)
784
+ this.preview(c && this.valid({ ...this.working, cavity: c }) ? { ...this.working, cavity: c } : this.lastValid)
785
+ }}
786
+ @command=${(ev: CustomEvent) => {
787
+ ev.stopPropagation()
788
+ const c = this.cavityOf(ev.detail.action)
789
+ if (c) this.change({ ...this.working, cavity: c })
790
+ }}
791
+ ></figure-cavity-drawing>
792
+ <label toggle>
793
+ <input type="checkbox" .checked=${live(this.perSide)} @change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)} />
794
+ ${i18next.t('figure.label.section-wall-per-side')}
795
+ </label>`
796
+ : nothing}
797
+ </div>`
798
+ }
799
+
800
+ /** The inside a drawing's set-cavity asks for, every wall written out; the drawing's commands stay in the editor. */
801
+ private cavityOf(action: FigureAuthoringAction | null): SectionWorking['cavity'] | undefined {
802
+ if (action?.kind !== 'set-cavity' || !action.cavity) return undefined
803
+ const { wall, start, end } = action.cavity
804
+ return { wall: typeof wall === 'number' ? Object.fromEntries(sidesOf(this.axis).map(side => [side, wall])) : ({ ...wall } as Record<string, number>), start, end }
805
+ }
806
+
713
807
  private list(issues: ReturnType<typeof workingIssuesOf>['issues'], words: ReturnType<typeof workingIssuesOf>['words']) {
714
808
  const [h, v] = SCREEN[this.axis]
715
809
  const marked = markedBy(issues)
@@ -773,6 +867,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
773
867
  )}
774
868
  </ol>`
775
869
  : nothing}
870
+ ${this.hollowable ? this.cavityBlock(words.length > 0) : nothing}
776
871
  ${this.note ? html`<small role="status">${this.note}</small>` : nothing}
777
872
  ${words.length
778
873
  ? html`<ul issues role="alert">
@@ -1,5 +1,5 @@
1
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'
2
+ import type { FigureCavityRequest, 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).
@@ -106,6 +106,8 @@ export interface SectionWorking {
106
106
  length: number
107
107
  /** A drawn section's end drawn to one point (a pyramid), across the plane axes; null for a push. */
108
108
  apex: Partial<Record<Axis, number>> | null
109
+ /** A pushed rectangle's or ellipse's inside, every side's wall written out; null for none or a drawn section. */
110
+ cavity: { wall: Record<string, number>; start: FigureCavityRequest['start']; end: FigureCavityRequest['end'] } | null
109
111
  }
110
112
 
111
113
  /** A pushed part's section, axis and ends -- or a pyramid's, whose end is its apex (figure-model `loft@1`). */
@@ -135,7 +137,28 @@ export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
135
137
  : s.kind === 'ellipse'
136
138
  ? { [u]: 2 * s.radius[u]!, [v]: 2 * s.radius[v]! }
137
139
  : {}
138
- return { points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [], across, length: e.end - e.start, apex: e.apex ? { ...e.apex } : null }
140
+ const c = shape.extrusion && shape.cavity
141
+ return {
142
+ points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [],
143
+ across,
144
+ length: e.end - e.start,
145
+ apex: e.apex ? { ...e.apex } : null,
146
+ cavity: c ? { wall: { ...c.wall }, start: c.start, end: c.end } : null
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The part as the working state would make it: its section, its length from the same start, its inside -- what the
152
+ * inside's drawing reads, so its walls are judged against the widths being typed and not the ones the editor opened with.
153
+ */
154
+ export function workingShapeOf(shape: FigureEvaluatedShape, w: SectionWorking): FigureEvaluatedShape {
155
+ const e = shape.extrusion!
156
+ const { cavity: _, ...rest } = shape
157
+ return {
158
+ ...rest,
159
+ extrusion: { ...e, section: sectionOf(shape, w), end: e.start + w.length },
160
+ ...(w.cavity ? { cavity: { id: shape.cavity?.id ?? 'inside', wall: { ...w.cavity.wall }, start: w.cavity.start, end: w.cavity.end } } : {})
161
+ } as FigureEvaluatedShape
139
162
  }
140
163
 
141
164
  /** The section the working state makes, of the part's own kind: its corners kept, its points or widths the working ones. */
@@ -159,8 +182,8 @@ export function workingIssuesOf(
159
182
  const words = issues.map(i => issueWords(i, w.points, e.axis))
160
183
  if (!(Number.isFinite(w.length) && w.length > 0)) words.push({ key: 'figure.text.section-issue-length', params: {} })
161
184
  if (w.apex && !Object.values(w.apex).every(n => Number.isFinite(n))) words.push({ key: 'figure.text.section-issue-number', params: {} })
162
- if (!issues.length && words.length === 0 && shape.cavity) {
163
- const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [shape.cavity])
185
+ if (!issues.length && words.length === 0 && w.cavity) {
186
+ const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [{ id: 'inside', ...w.cavity }])
164
187
  if ('refused' in made) {
165
188
  const r = made.refused
166
189
  words.push({ key: r.code === 'CAVITY_NO_LENGTH' ? 'figure.text.section-issue-cavity-length' : 'figure.text.section-issue-cavity-walls', params: {} })
@@ -21,8 +21,6 @@ export declare class FigureCanvas extends LitElement {
21
21
  * would reach the editor as an edit.
22
22
  */
23
23
  viewOnly: boolean;
24
- /** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
25
- startShown: boolean;
26
24
  /** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
27
25
  fan: FigureRenderPart | null;
28
26
  /**