@things-factory/figure-ui 10.1.94 → 10.1.96

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 (38) hide show
  1. package/client/modeller/editor.ts +30 -11
  2. package/client/modeller/figure-fasten-drawing.ts +0 -2
  3. package/client/modeller/figure-links.ts +22 -25
  4. package/client/modeller/figure-material-panel.ts +18 -11
  5. package/client/modeller/figure-properties.ts +2 -2
  6. package/client/modeller/figure-section-panel.ts +12 -11
  7. package/client/modeller/links.ts +49 -23
  8. package/client/modeller/side-panel-styles.ts +67 -32
  9. package/dist-client/modeller/editor.d.ts +7 -2
  10. package/dist-client/modeller/editor.js +31 -10
  11. package/dist-client/modeller/editor.js.map +1 -1
  12. package/dist-client/modeller/figure-fasten-drawing.js +0 -2
  13. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  14. package/dist-client/modeller/figure-links.js +24 -25
  15. package/dist-client/modeller/figure-links.js.map +1 -1
  16. package/dist-client/modeller/figure-material-panel.js +18 -11
  17. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  18. package/dist-client/modeller/figure-properties.js +2 -2
  19. package/dist-client/modeller/figure-properties.js.map +1 -1
  20. package/dist-client/modeller/figure-section-panel.d.ts +1 -0
  21. package/dist-client/modeller/figure-section-panel.js +12 -11
  22. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  23. package/dist-client/modeller/links.d.ts +14 -4
  24. package/dist-client/modeller/links.js +51 -23
  25. package/dist-client/modeller/links.js.map +1 -1
  26. package/dist-client/modeller/side-panel-styles.d.ts +5 -1
  27. package/dist-client/modeller/side-panel-styles.js +67 -32
  28. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/test/command-preview.test.ts +3 -3
  32. package/test/first-minute.test.ts +16 -7
  33. package/test/section-panel.test.ts +2 -2
  34. package/translations/en.json +18 -10
  35. package/translations/ja.json +18 -10
  36. package/translations/ko.json +18 -10
  37. package/translations/ms.json +18 -10
  38. package/translations/zh.json +18 -10
@@ -312,13 +312,18 @@ export class FigureEditor extends LitElement {
312
312
  z-index: 1;
313
313
  display: grid;
314
314
  gap: 4px;
315
- padding: 8px 10px;
315
+ padding: 10px 12px;
316
316
  background: var(--md-sys-color-surface-container-lowest);
317
317
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
318
318
  }
319
+ /* The panel's three sizes (side-panel-styles): the part's kind at the title's, its two lines at the hint's. */
319
320
  div[subject-summary] {
320
321
  display: grid;
321
- gap: 1px;
322
+ gap: 2px;
323
+ }
324
+ div[subject-summary] b {
325
+ font-size: var(--ui-t-body, 13px);
326
+ font-weight: 600;
322
327
  }
323
328
  div[subject-summary] b span {
324
329
  font-weight: 400;
@@ -1626,14 +1631,13 @@ export class FigureEditor extends LitElement {
1626
1631
  const key = `${view.context}|${view.revision}|${this.selected}`
1627
1632
  if (this.detachSaid?.key === key) return this.detachSaid.words
1628
1633
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1629
- const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part)
1630
1634
  let words: string[]
1631
1635
  try {
1632
1636
  const seen = this.session.previewCommand({ kind: 'detach', part: this.selected })
1633
1637
  const own = seen.moves.some(m => m.body === this.selected)
1634
1638
  words = own
1635
1639
  ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
1636
- : [say('figure.text.detach-stays', { from })]
1640
+ : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []
1637
1641
  const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part))
1638
1642
  if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1639
1643
  if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
@@ -1859,14 +1863,15 @@ export class FigureEditor extends LitElement {
1859
1863
  </div>`
1860
1864
  }
1861
1865
  /**
1862
- * The part the panel speaks of, held at its top: its kind, how its size answers, what holds it (designer 2026-09-27,
1863
- * B). No path above it (user 2026-10-01): the parts list and the canvas already show which part is chosen, and the
1866
+ * The part the panel speaks of, held at its top: its kind, and what its size follows when it follows something. No path above it (user 2026-10-01): the parts list and the canvas already show which part is chosen, and the
1864
1867
  * list's root is the way back to the model's settings.
1865
1868
  */
1866
1869
  private renderSubject() {
1867
- let summary: { kind: string; size: string; relation: string } | undefined
1870
+ let summary: { kind: string; size: string } | undefined
1868
1871
  try {
1869
- summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
1872
+ summary = this.selected
1873
+ ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
1874
+ : undefined
1870
1875
  } catch {
1871
1876
  summary = undefined
1872
1877
  }
@@ -1874,7 +1879,7 @@ export class FigureEditor extends LitElement {
1874
1879
  return html`<div subject>
1875
1880
  <div subject-summary>
1876
1881
  ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1877
- <small>${summary.size}</small><small>${summary.relation}</small>
1882
+ ${summary.size ? html`<small>${summary.size}</small>` : nothing}
1878
1883
  </div>
1879
1884
  </div>`
1880
1885
  }
@@ -2122,6 +2127,7 @@ export class FigureEditor extends LitElement {
2122
2127
  ? html`<section>
2123
2128
  <h3>${i18next.t(vectorTitle)}</h3>
2124
2129
  ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
2130
+ ${this.renderHeldAxes(groups.vectors)}
2125
2131
  ${tools ?? nothing}
2126
2132
  </section>`
2127
2133
  : nothing}
@@ -2176,7 +2182,20 @@ export class FigureEditor extends LitElement {
2176
2182
  )}
2177
2183
  </div>
2178
2184
  </div>
2179
- ${ruled.length ? html`<p hint held-axes>${i18next.t('figure.text.axes-held-by-rule', { group: i18next.t('figure.label.group-' + group.name), axes: ruled.map(a => a.toUpperCase()).join(' · ') })}</p>` : nothing}`
2185
+ `
2186
+ }
2187
+ /**
2188
+ * Why a row's numbers cannot be typed, said once under the rows by what decides them (user 2026-10-01: "set by a
2189
+ * rule" named nothing): a fastened part's place and turn are its fastening's; other numbers are worked out from
2190
+ * something else. A size that follows something is said at the panel's head, so not again here.
2191
+ */
2192
+ private renderHeldAxes(vectors: FieldGroup[]) {
2193
+ const fastened = !!(this.asset() as FigureAsset | undefined)?.joints?.some(j => j.body1.part === ownerOf(this.selected))
2194
+ const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && (['x', 'y', 'z'] as const).some(axis => !g.cells[axis]))
2195
+ if (!held.length) return nothing
2196
+ const placed = held.every(g => g.name === 'position' || g.name === 'rotation')
2197
+ if (fastened && placed) return html`<p hint held-axes>${i18next.t('figure.text.held-by-fastening')}</p>`
2198
+ return html`<p hint held-axes>${i18next.t('figure.text.held-worked-out', { group: held.map(g => i18next.t('figure.label.group-' + g.name)).join(' · ') })}</p>`
2180
2199
  }
2181
2200
  /**
2182
2201
  * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
@@ -2191,7 +2210,7 @@ export class FigureEditor extends LitElement {
2191
2210
  f => html`<span>${fieldWordsOf(f.id, key => i18next.t(key))} (${f.unit})</span><input aria-label=${f.id} type="number" .value=${String(f.value)} disabled />`
2192
2211
  )}
2193
2212
  </div>
2194
- <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
2213
+ <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}><md-icon>fit_screen</md-icon>${i18next.t('figure.button.room-refit')}</button>
2195
2214
  </section>`
2196
2215
  }
2197
2216
  /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
@@ -76,7 +76,6 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
76
76
  gap: 2px;
77
77
  padding: 2px 4px 4px;
78
78
  font: inherit;
79
- font-size: 0.72rem;
80
79
  color: var(--md-sys-color-on-surface);
81
80
  background: none;
82
81
  border: 1px solid var(--md-sys-color-outline-variant);
@@ -95,7 +94,6 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
95
94
  position: absolute;
96
95
  width: 72px;
97
96
  font: inherit;
98
- font-size: 0.75rem;
99
97
  }
100
98
  small[warn] {
101
99
  color: var(--md-sys-color-tertiary);
@@ -119,17 +119,16 @@ export class FigureLinks extends localize(i18next)(LitElement) {
119
119
  div[resize-held] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
120
120
  div[resize-held] p { margin: 0; display: flex; align-items: center; gap: 6px; }
121
121
  p[fasten-mode] { margin: 0; font-weight: 600; }
122
- p[fasten-mode][editing] { color: var(--md-sys-color-primary, #477b9e); }
123
122
  section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
124
- details { margin-top: 14px; }
125
- summary { cursor: pointer; padding: 8px 0; }
123
+ details { margin-top: 8px; }
126
124
  /* What the command would do, said beside the result drawn on the canvas. */
127
- ul[preview-words] { margin: 10px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
125
+ 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
+ p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
128
127
  div[row-basis] { display: grid; gap: 4px; margin: 8px 0; }
129
128
  div[row-basis] label { display: flex; align-items: center; gap: 6px; }
130
129
  div[overhang] { display: grid; gap: 6px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--md-sys-color-outline-variant, #8884); }
131
130
  div[overhang] p { margin: 0; }
132
- ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; font-size: 0.8rem; opacity: 0.85; }
131
+ ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; }
133
132
  span[fasten-summary] { opacity: 0.8; }
134
133
  /* The difference a following measure keeps: under its choice, on the same line as its words. */
135
134
  label[inline-plus] {
@@ -326,9 +325,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
326
325
  const word = (axis: Axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
327
326
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
328
327
  const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance')
329
- return html`<p hint>${i18next.t('figure.text.resize-intent')}</p>
330
- ${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
331
- <div resize-now>${editable.map(axis => {
328
+ /* A size of its own on every axis is the usual: nothing to read, only the way to make it follow (user 2026-10-01). */
329
+ const allOwn = editable.every(axis => followOf(asset, this.partId, axis).of === 'own')
330
+ return html`${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
331
+ ${allOwn ? nothing : html`<div resize-now>${editable.map(axis => {
332
332
  const now = followOf(asset, this.partId, axis)
333
333
  const following = now.of === 'instance' && now.axis === axis
334
334
  const custom = now.of !== 'own' && !following
@@ -344,7 +344,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
344
344
  }}>${i18next.t('figure.button.resize-unfollow')}</button>`
345
345
  : nothing}
346
346
  </div>`
347
- })}</div>
347
+ })}</div>`}
348
348
  ${followsAll
349
349
  ? html`<p hint>${i18next.t('figure.text.resize-all-following')}</p>`
350
350
  : html`<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
@@ -443,11 +443,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
443
443
  return html`<div resize-held>
444
444
  <p><md-icon>link</md-icon> ${where}</p>
445
445
  <button act open-fastening @click=${() => this.openFastening(asset)}>
446
- ${i18next.t('figure.button.open-fastening')}
446
+ <md-icon>link</md-icon>${i18next.t('figure.button.open-fastening')}
447
447
  </button>
448
448
  ${joint
449
449
  ? html`<button act detach-held ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
450
- ${i18next.t('figure.button.detach-held')}
450
+ <md-icon>link_off</md-icon>${i18next.t('figure.button.detach-held')}
451
451
  </button>
452
452
  ${this.renderDetachWords()}`
453
453
  : nothing}
@@ -569,17 +569,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
569
569
  ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
570
570
  ]
571
571
  /*
572
- Editing the fastening it has, or fastening it anew: said, with the relation named, so the form is not taken for a
573
- blank new one while it holds the current (designer 2026-09-27). Applied unchanged, the same fastening is written
574
- again, fields and all (resize-with-placement.test).
572
+ 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
+ said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
574
+ same (「현재 관계 수정」, 「붙은 곳」) went (user 2026-10-01, the panel checked for balance).
575
575
  */
576
576
  const current = asset.joints?.find(j => j.body1.part === this.partId)
577
- const editing = current
578
- ? i18next.t('figure.text.fasten-editing', {
579
- face: i18next.t(`figure.label.face-${current.body1.face}`),
580
- target: 'plane' in current.body0 ? i18next.t('figure.label.mounting-plane') : `${current.body0.part} · ${i18next.t(`figure.label.face-${current.body0.face}`)}`
581
- })
582
- : i18next.t('figure.text.fasten-new')
577
+ const editing = i18next.t('figure.text.fasten-new')
583
578
  /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
584
579
  if (!current && !this.choosingFastening && !f.target)
585
580
  return html`
@@ -589,7 +584,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
589
584
  </button>
590
585
  `
591
586
  return html`
592
- <p fasten-mode ?editing=${!!current}>${editing}</p>
587
+ ${current ? nothing : html`<p fasten-mode>${editing}</p>`}
593
588
  ${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
594
589
  ${this.overhangWords.length
595
590
  ? html`<div overhang role="status">
@@ -600,13 +595,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
600
595
  </div>`
601
596
  : nothing}
602
597
  ${held
603
- ? html`<div pair>
604
- <span>${i18next.t('figure.label.fastened-to')}: <b bound>${'plane' in held ? i18next.t('figure.label.mounting-plane') : held.part}</b></span>
598
+ ? html`<div fastened>
605
599
  <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
606
600
  <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
607
601
  </button>
608
- </div>
609
- ${this.renderDetachWords()}`
602
+ ${this.renderDetachWords()}
603
+ </div>`
610
604
  : nothing}
611
605
  <div form>
612
606
  <span>${i18next.t('figure.label.target-part')}</span>
@@ -687,6 +681,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
687
681
  }
688
682
 
689
683
  private renderDetachWords() {
684
+ /* One thing to say is a line, not a list of one. */
685
+ if (this.detachWords.length === 1) return html`<p hint detach-words>${this.detachWords[0]}</p>`
690
686
  return this.detachWords.length ? html`<ul detach-words>${this.detachWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
691
687
  }
692
688
 
@@ -729,6 +725,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
729
725
  * (the editor's) shows it and takes it away.
730
726
  */
731
727
  private renderPreviewWords() {
728
+ if (this.previewWords.length === 1) return html`<p preview-words role="status">${this.previewWords[0]}</p>`
732
729
  return this.previewWords.length ? html`<ul preview-words role="status">${this.previewWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
733
730
  }
734
731
 
@@ -49,8 +49,16 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
49
49
  display: grid;
50
50
  gap: 4px;
51
51
  }
52
- div[field] > label {
53
- font-weight: 600;
52
+ /* A slider and its value on one line, the value at the end where the eye finishes. */
53
+ div[slider] {
54
+ display: grid;
55
+ grid-template-columns: minmax(0, 1fr) 3em;
56
+ align-items: center;
57
+ gap: 8px;
58
+ }
59
+ div[slider] output {
60
+ text-align: right;
61
+ font-variant-numeric: tabular-nums;
54
62
  }
55
63
  div[swatches] {
56
64
  display: flex;
@@ -118,16 +126,14 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
118
126
  fill: var(--md-sys-color-surface-container-lowest);
119
127
  }
120
128
  button[seg] b {
121
- font-size: 0.8rem;
129
+ font-weight: 600;
122
130
  }
123
131
  button[seg] small {
124
132
  margin: 0;
125
- font-size: 0.6rem;
126
133
  }
127
134
  details[free] summary {
128
135
  margin-top: 4px;
129
136
  cursor: pointer;
130
- font-size: 0.68rem;
131
137
  color: var(--md-sys-color-on-surface-variant);
132
138
  }
133
139
  div[presets] {
@@ -139,7 +145,6 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
139
145
  flex: 1;
140
146
  padding: 3px 0;
141
147
  font: inherit;
142
- font-size: 0.72rem;
143
148
  color: var(--md-sys-color-on-surface);
144
149
  background: none;
145
150
  border: 1px solid var(--md-sys-color-outline-variant);
@@ -184,8 +189,8 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
184
189
  : nothing}
185
190
  <small><b>${purposeOf(material.token)}</b> — ${i18next.t('figure.text.palette-purpose-explained')}</small>
186
191
  </div>
187
- <div field>
188
- <label>${i18next.t('figure.label.preset')}</label>
192
+ <div form>
193
+ <span>${i18next.t('figure.label.preset')}</span>
189
194
  <select
190
195
  ?disabled=${this.locked}
191
196
  @change=${(e: Event) => this.put({ preset: ((e.target as HTMLSelectElement).value || undefined) as FigureMaterial['preset'] })}
@@ -205,8 +210,9 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
205
210
  />
206
211
  ${i18next.t('figure.label.transparent')}
207
212
  </label>
208
- <div field>
209
- <label>${i18next.t('figure.label.emissive')}</label>
213
+ <div form>
214
+ <span>${i18next.t('figure.label.emissive')}</span>
215
+ <div slider>
210
216
  <input
211
217
  type="range"
212
218
  min="0"
@@ -217,7 +223,8 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
217
223
  @input=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value), true)}
218
224
  @change=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value))}
219
225
  />
220
- <small>${(this.glowing ?? glow?.intensity ?? 0).toFixed(2)}</small>
226
+ <output>${(this.glowing ?? glow?.intensity ?? 0).toFixed(2)}</output>
227
+ </div>
221
228
  </div>
222
229
  ${glow
223
230
  ? html`<div field>
@@ -177,10 +177,10 @@ export class FigureProperties extends localize(i18next)(LitElement) {
177
177
  </div>
178
178
  <div row>
179
179
  <button act ?disabled=${!facts.declared || !part} @click=${() => this.command({ kind: 'set-mounting-face', part: part!, face })}>
180
- ${i18next.t('figure.button.set-mounting-face')}
180
+ <md-icon>check</md-icon>${i18next.t('figure.button.set-mounting-face')}
181
181
  </button>
182
182
  <button act ?disabled=${!mounting} @click=${() => this.command({ kind: 'clear-mounting-face' })}>
183
- ${i18next.t('figure.button.clear-mounting-face')}
183
+ <md-icon>close</md-icon>${i18next.t('figure.button.clear-mounting-face')}
184
184
  </button>
185
185
  </div>
186
186
  <small>
@@ -1,3 +1,4 @@
1
+ import '@material/web/icon/icon.js'
1
2
  import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
2
3
  import { customElement, property, state } from 'lit/decorators.js'
3
4
  import { live } from 'lit/directives/live.js'
@@ -105,7 +106,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
105
106
  ${body.shape.dimensions.jamb === undefined
106
107
  ? html`<div field>
107
108
  <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
+ <md-icon>edit</md-icon>${i18next.t('figure.button.section-editor-open')}
109
110
  </button>
110
111
  ${e.section.kind === 'polygon'
111
112
  ? html`<small>${i18next.t(e.apex ? 'figure.text.section-points-apex' : 'figure.text.section-points', { points: String(e.section.points.length) })}</small>`
@@ -113,11 +114,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
113
114
  </div>`
114
115
  : nothing}
115
116
  ${kind === 'rectangle' ? this.renderCorner(body) : nothing}
116
- ${kind === 'rectangle' || kind === 'ellipse'
117
- ? this.renderCavity(body, e.axis)
118
- : html`<div field>
119
- <small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
120
- </div>`}
117
+ ${/* No inside for a drawn section: the control is not there, rather than a line saying so. */ kind === 'rectangle' || kind === 'ellipse' ? this.renderCavity(body, e.axis) : nothing}
121
118
  </section>`
122
119
  }
123
120
 
@@ -128,8 +125,9 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
128
125
  .map(k => d[k])
129
126
  const now = reaches.length ? sameOf(reaches) : 0
130
127
  const max = cornerLimitOf(body.shape)
131
- return html`<div field>
128
+ return html`<div form>
132
129
  <label for="corner">${i18next.t('figure.label.section-corner')}</label>
130
+ <div>
133
131
  ${this.number('corner', now, {
134
132
  min: 0,
135
133
  ask: value => {
@@ -140,6 +138,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
140
138
  placeholder: now === undefined ? i18next.t('figure.text.section-corner-differs') : ''
141
139
  })}
142
140
  ${this.refusals.corner ? nothing : max !== undefined ? html`<small>${i18next.t('figure.text.section-at-most', { max: String(mm(max)) })}</small>` : nothing}
141
+ </div>
143
142
  </div>`
144
143
  }
145
144
 
@@ -167,7 +166,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
167
166
  action: value => ({ kind: 'set-cavity', part: body.id, cavity: request(patch(value)) })
168
167
  })
169
168
  return html`<div field>
170
- <label>
169
+ <label toggle>
171
170
  <input
172
171
  type="checkbox"
173
172
  .checked=${live(!!cavity)}
@@ -191,8 +190,9 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
191
190
  </div>
192
191
  ${cavity ? html`<figure-cavity-drawing .body=${body} .perSide=${this.perSide} ?locked=${this.locked}></figure-cavity-drawing>` : nothing}
193
192
  ${cavity
194
- ? html`<div field>
193
+ ? html`<div form>
195
194
  <label>${i18next.t('figure.label.section-wall')}</label>
195
+ <div>
196
196
  ${this.perSide
197
197
  ? html`<div sides>
198
198
  ${[...sidesOf(axis)].sort((a, b) => a[0]!.localeCompare(b[0]!) || (a[1] === '-' ? -1 : 1)).map(
@@ -206,7 +206,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
206
206
  ${this.perSide || this.refusals.wall
207
207
  ? nothing
208
208
  : html`<small>${i18next.t('figure.text.section-wall-at-most', { max: String(mm(wallLimitOf(body.shape))) })}</small>`}
209
- <label>
209
+ <label toggle>
210
210
  <input
211
211
  type="checkbox"
212
212
  .checked=${live(this.perSide)}
@@ -215,6 +215,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
215
215
  />
216
216
  ${i18next.t('figure.label.section-wall-per-side')}
217
217
  </label>
218
+ </div>
218
219
  </div>
219
220
  ${(['start', 'end'] as const).map(end => this.renderEnd(body, axis, end, cavity[end], request, cavityField))}`
220
221
  : nothing}`
@@ -229,7 +230,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
229
230
  cavityField: (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) => unknown
230
231
  ) {
231
232
  const closed = now !== 'open'
232
- return html`<div field>
233
+ return html`<div form>
233
234
  <label>${i18next.t('figure.label.face-' + END_FACE(axis, end))}</label>
234
235
  <div row>
235
236
  <select
@@ -89,36 +89,62 @@ export function resizeAxesOf(asset: FigureAsset, part: string, pending?: readonl
89
89
  }
90
90
 
91
91
  /**
92
- * The selected part in three short lines, for the head of its panel (designer 2026-09-27, B): what it is, how its
93
- * size answers the figure's, and what holds it -- 「box · 상자 / 너비는 도형을 따라감 / 아랫면을 바닥에 유지 중」.
92
+ * Korean particles for a word the sentence is built around: the topic (은/는) and "and/with" (과/와), by whether the
93
+ * word's last syllable ends in a consonant. Other languages need none.
94
94
  */
95
- export function partSummaryOf(asset: FigureAsset, part: string, say: (key: string, params?: Record<string, string>) => string): { kind: string; size: string; relation: string } {
95
+ export function particlesOf(word: string, lang: string): { topic: string; with: string } {
96
+ if (!lang.startsWith('ko')) return { topic: word, with: word }
97
+ const last = word.trim().slice(-1).charCodeAt(0)
98
+ const closed = last >= 0xac00 && last <= 0xd7a3 && (last - 0xac00) % 28 !== 0
99
+ return { topic: word + (closed ? '은' : '는'), with: word + (closed ? '과' : '와') }
100
+ }
101
+
102
+ /**
103
+ * The selected part at the head of its panel: what it is, and -- only when a measure changes by itself -- what it
104
+ * follows, as a sentence a person reads without knowing the model's words: the model's size, a share of it, another
105
+ * part's measure, a field, a calculation (`figureFollowOf`). A size of its own and a part fastened or not say nothing
106
+ * here; the fastening is drawn in its own section (user 2026-10-01: 「규칙을 따름: 높이 (Y)」 -- "who would know what that
107
+ * means?", and "why say anything when all is as usual?").
108
+ */
109
+ export function partSummaryOf(
110
+ asset: FigureAsset,
111
+ part: string,
112
+ say: (key: string, params?: Record<string, string>) => string,
113
+ lang = 'ko'
114
+ ): { kind: string; size: string } {
96
115
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
97
116
  const named = startingShapeKindOf(shape as never)
98
117
  const kind = named ? say(`figure.label.primitive-${named}`) : ''
99
118
  const editable = followableAxes(asset, part)
100
119
  const round = editable.length === 2
101
- const word = (axis: Axis) => say(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
102
- const following = editable.filter(axis => {
103
- const now = followOf(asset, part, axis)
104
- return now.of === 'instance' && now.axis === axis
105
- })
106
- const ruled = editable.filter(axis => followOf(asset, part, axis).of !== 'own' && !following.includes(axis))
107
- const size = [
108
- following.length ? say('figure.text.summary-follows', { axes: following.map(word).join(' · ') }) : '',
109
- ruled.length ? say('figure.text.summary-ruled', { axes: ruled.map(word).join(' · ') }) : ''
110
- ].filter(Boolean).join(' / ') || say('figure.text.summary-size-fixed')
111
- const joint = asset.joints?.find(j => j.body1.part === part)
112
- let relation = say('figure.text.summary-free')
113
- if (joint) {
114
- const face = say(`figure.label.face-${joint.body1.face}`)
115
- const gap = typeof joint.origin?.gap === 'number' ? joint.origin.gap : 0
116
- relation =
117
- 'plane' in joint.body0
118
- ? say('figure.text.held-on-plane', { face, place: say(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
119
- : say('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: say(`figure.label.face-${joint.body0.face}`) })
120
+ const word = (axis: Axis) => say(round && axis === 'x' ? 'figure.label.measure-diameter' : `figure.label.measure-${axis}`)
121
+ const pct = (times: number) => String(Math.round(times * 1000) / 10)
122
+ /* One sentence per way of following; the measures that follow the same way are said together. */
123
+ const lines = new Map<string, { key: string; params: Record<string, string>; axes: Axis[] }>()
124
+ for (const axis of editable) {
125
+ const f = followOf(asset, part, axis)
126
+ let key: string
127
+ let params: Record<string, string> = {}
128
+ if (f.of === 'own') continue
129
+ if (f.of === 'instance') {
130
+ key = f.times === 1 ? 'figure.text.summary-size-follows' : 'figure.text.summary-size-share'
131
+ params = { pct: pct(f.times), whole: say(`figure.label.measure-${f.axis}`) }
132
+ if (f.plus) (key = key + '-plus'), (params.plus = String(f.plus))
133
+ } else if (f.of === 'part') {
134
+ const other = say(`figure.label.measure-${f.axis}`)
135
+ key = (f.times ?? 1) === 1 ? 'figure.text.summary-size-part' : 'figure.text.summary-size-part-share'
136
+ params = { other: f.part, measure: particlesOf(other, lang).with, measureWord: other, pct: pct(f.times ?? 1) }
137
+ if (f.plus) (key = key + '-plus'), (params.plus = String(f.plus))
138
+ } else if (f.of === 'input') {
139
+ key = 'figure.text.summary-size-input'
140
+ params = { field: fieldWordsOf(f.input, k => say(k)) }
141
+ } else key = 'figure.text.summary-size-worked-out'
142
+ const id = key + JSON.stringify(params)
143
+ lines.set(id, { key, params, axes: [...(lines.get(id)?.axes ?? []), axis] })
120
144
  }
121
- return { kind, size, relation }
145
+ /* Nothing to say when the size is its own: only a measure that changes by itself is news (user 2026-10-01). */
146
+ const size = [...lines.values()].map(l => say(l.key, { ...l.params, axes: particlesOf(l.axes.map(word).join(' · '), lang).topic })).join(' ')
147
+ return { kind, size }
122
148
  }
123
149
 
124
150
  /**