@things-factory/figure-ui 10.1.169 → 10.1.170

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 (105) hide show
  1. package/client/modeller/affected-parts.ts +11 -0
  2. package/client/modeller/canvas.ts +15 -1
  3. package/client/modeller/editor-styles.ts +98 -6
  4. package/client/modeller/editor.ts +212 -53
  5. package/client/modeller/fasten-geometry.ts +19 -0
  6. package/client/modeller/figure-fasten-drawing.ts +4 -3
  7. package/client/modeller/figure-links.ts +80 -37
  8. package/client/modeller/figure-material-panel.ts +20 -1
  9. package/client/modeller/figure-preview.ts +117 -8
  10. package/client/modeller/figure-properties.ts +1 -2
  11. package/client/modeller/figure-section-panel.ts +5 -0
  12. package/client/modeller/figure-verdict.ts +9 -2
  13. package/client/modeller/gate-findings.ts +12 -4
  14. package/client/modeller/group-menu.ts +57 -0
  15. package/client/modeller/links.ts +22 -1
  16. package/client/modeller/modeller-keys.ts +6 -0
  17. package/client/modeller/preview-size.ts +32 -0
  18. package/client/modeller/stage-model.ts +11 -0
  19. package/client/pages/figure-modeller-page.ts +8 -2
  20. package/dist-client/modeller/affected-parts.d.ts +6 -0
  21. package/dist-client/modeller/affected-parts.js +12 -0
  22. package/dist-client/modeller/affected-parts.js.map +1 -0
  23. package/dist-client/modeller/canvas.d.ts +3 -0
  24. package/dist-client/modeller/canvas.js +12 -1
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +1 -1
  27. package/dist-client/modeller/editor-styles.js +98 -6
  28. package/dist-client/modeller/editor-styles.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +42 -1
  30. package/dist-client/modeller/editor.js +220 -51
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/fasten-geometry.d.ts +27 -0
  33. package/dist-client/modeller/fasten-geometry.js +14 -0
  34. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  35. package/dist-client/modeller/figure-fasten-drawing.js +4 -3
  36. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +3 -1
  38. package/dist-client/modeller/figure-links.js +84 -38
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-material-panel.d.ts +8 -1
  41. package/dist-client/modeller/figure-material-panel.js +24 -1
  42. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.d.ts +12 -0
  44. package/dist-client/modeller/figure-preview.js +116 -5
  45. package/dist-client/modeller/figure-preview.js.map +1 -1
  46. package/dist-client/modeller/figure-properties.js +1 -2
  47. package/dist-client/modeller/figure-properties.js.map +1 -1
  48. package/dist-client/modeller/figure-section-panel.d.ts +1 -1
  49. package/dist-client/modeller/figure-section-panel.js +5 -0
  50. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  51. package/dist-client/modeller/figure-verdict.d.ts +1 -0
  52. package/dist-client/modeller/figure-verdict.js +8 -2
  53. package/dist-client/modeller/figure-verdict.js.map +1 -1
  54. package/dist-client/modeller/gate-findings.d.ts +3 -1
  55. package/dist-client/modeller/gate-findings.js +8 -4
  56. package/dist-client/modeller/gate-findings.js.map +1 -1
  57. package/dist-client/modeller/group-menu.d.ts +18 -0
  58. package/dist-client/modeller/group-menu.js +52 -0
  59. package/dist-client/modeller/group-menu.js.map +1 -0
  60. package/dist-client/modeller/links.d.ts +5 -0
  61. package/dist-client/modeller/links.js +21 -1
  62. package/dist-client/modeller/links.js.map +1 -1
  63. package/dist-client/modeller/modeller-keys.js +7 -0
  64. package/dist-client/modeller/modeller-keys.js.map +1 -1
  65. package/dist-client/modeller/preview-size.d.ts +23 -0
  66. package/dist-client/modeller/preview-size.js +32 -0
  67. package/dist-client/modeller/preview-size.js.map +1 -0
  68. package/dist-client/modeller/stage-model.d.ts +11 -0
  69. package/dist-client/modeller/stage-model.js +11 -0
  70. package/dist-client/modeller/stage-model.js.map +1 -1
  71. package/dist-client/pages/figure-modeller-page.d.ts +3 -0
  72. package/dist-client/pages/figure-modeller-page.js +11 -2
  73. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +8 -8
  76. package/test/browser/editing-page.js +3 -1
  77. package/test/browser/editing.check.mjs +2 -1
  78. package/test/browser/links-page.js +1 -0
  79. package/test/browser/links.check.mjs +20 -5
  80. package/test/browser/modeller.check.mjs +6 -0
  81. package/test/browser/more-open.js +11 -0
  82. package/test/browser/parts.check.mjs +11 -1
  83. package/test/browser/places-mesh.check.mjs +9 -3
  84. package/test/browser/product-page.check.mjs +9 -4
  85. package/test/browser/props.check.mjs +224 -0
  86. package/test/browser/screen-page.js +17 -0
  87. package/test/browser/screen.check.mjs +171 -29
  88. package/test/browser/section-page.js +1 -0
  89. package/test/browser/section.check.mjs +6 -0
  90. package/test/command-preview.test.ts +2 -2
  91. package/test/fasten-geometry.test.ts +15 -0
  92. package/test/fields.test.ts +4 -2
  93. package/test/first-minute.test.ts +15 -2
  94. package/test/layout.test.ts +1 -1
  95. package/test/preview-size.test.ts +33 -0
  96. package/test/refusals.test.ts +1 -1
  97. package/test/resize-with-placement.test.ts +1 -1
  98. package/test/stage-model.test.ts +11 -1
  99. package/test/verdict.test.ts +7 -0
  100. package/translations/en.json +37 -27
  101. package/translations/es.json +18 -8
  102. package/translations/ja.json +19 -9
  103. package/translations/ko.json +50 -40
  104. package/translations/ms.json +19 -9
  105. package/translations/zh.json +19 -9
@@ -4,7 +4,7 @@ import { customElement, property, state } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import type { FigureFace } from '@hatiolab/figure-model'
6
6
 
7
- import { alignmentAt, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
7
+ import { alignmentAt, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
8
8
  import type { Alignment } from './links.js'
9
9
 
10
10
  type Axis = 'x' | 'y' | 'z'
@@ -299,6 +299,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
299
299
  }
300
300
  const faceName = this.target ? `${this.target} ${i18next.t('figure.label.face-' + this.face)}` : this.planeWord
301
301
  const sizeWords = faceSized ? ` ${Math.round(view.face[h]!)} × ${Math.round(view.face[v]!)}` : ''
302
+ const labels = fastenLabelsOf(faceSized ? { x: fx, y: fy, w: fw, h: fh } : { x: 6, y: 4, w: W - 12, h: H - 8 }, { x: px, y: py, w: pw, h: ph }, this.partName, H)
302
303
  return html`<div where tabindex="0" @keydown=${(ev: KeyboardEvent) => this.keys(ev, view)}>
303
304
  ${svg`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label=${faceName}
304
305
  @pointermove=${(ev: PointerEvent) => this.drag(ev, view, cx, cy)}
@@ -306,7 +307,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
306
307
  ${faceSized
307
308
  ? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx="4" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`
308
309
  : svg`<rect x="1" y="1" width=${W - 2} height=${H - 2} rx="8" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`}
309
- <text x=${faceSized ? fx + 4 : 10} y=${faceSized ? fy + fh - 6 : 18} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
310
+ <text face-name x=${labels.face.x} y=${labels.face.y} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
310
311
  ${guide(h)}${guide(v)}
311
312
  <g footprint style=${`cursor:${this.locked ? 'default' : 'grab'}`} @pointerdown=${(ev: PointerEvent) => this.grab(ev, at, caught)}>
312
313
  <rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2} style="fill:var(--md-sys-color-tertiary-container)" />
@@ -317,7 +318,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
317
318
  : nothing}
318
319
  <rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2}
319
320
  style=${`fill:none;stroke:var(--md-sys-color-on-surface);stroke-width:1.5;stroke-dasharray:${touching ? '' : '4 3'}`} />
320
- <text x=${px + pw / 2} y=${py + ph / 2 + 4} text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.partName}</text>
321
+ <text part-name x=${labels.part.x} y=${labels.part.y} text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.partName}</text>
321
322
  </g>
322
323
  ${dim(h)}${dim(v)}
323
324
  </svg>`}
@@ -20,6 +20,7 @@ import {
20
20
  followableAxes,
21
21
  positionIsShare,
22
22
  choiceOf,
23
+ followWordsOf,
23
24
  fastenedTo,
24
25
  heldBy,
25
26
  followOf,
@@ -60,6 +61,7 @@ import type { FastenChange } from './figure-fasten-drawing.js'
60
61
  import { faceViewOf, insideAlignmentOf, modeOf, myFaceFor, turnsInPlace, type FaceView, type FastenMode } from './fasten-geometry.js'
61
62
  import type { FigureRevision } from './revision.js'
62
63
  import { HelpToggles } from './help.js'
64
+ import { groupMenu, GroupMenuStyles } from './group-menu.js'
63
65
  import { jointAxisWord } from './joint-axis.js'
64
66
  import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js'
65
67
 
@@ -100,10 +102,28 @@ const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as Figu
100
102
  @customElement('figure-links')
101
103
  export class FigureLinks extends localize(i18next)(LitElement) {
102
104
  private readonly help = new HelpToggles(this)
103
- static styles = [SidePanelStyles, css`
105
+ static styles = [SidePanelStyles, GroupMenuStyles, css`
104
106
  :host {
105
107
  display: block;
106
108
  }
109
+ /* A group's sub-question, under its title: quieter than the title, as a label is. */
110
+ p[group-question] {
111
+ margin: 4px 0 0;
112
+ color: var(--md-sys-color-on-surface-variant);
113
+ }
114
+ /* The fastening a part has, in one line, and 「바꾸기」 at its end. */
115
+ div[fastened-summary] {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 8px;
119
+ }
120
+ div[fastened-summary] > p {
121
+ flex: 1;
122
+ margin: 0;
123
+ }
124
+ div[fastened-summary] > button[quiet] {
125
+ margin-left: 0;
126
+ }
107
127
  /* The measure's name, then its rule: X, or a cylinder's 반지름 · 길이, on one line. */
108
128
  div[row] > b {
109
129
  white-space: nowrap;
@@ -121,9 +141,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
121
141
  div[resize-now] { display: grid; gap: 4px; margin: 12px 0; }
122
142
  div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
123
143
  div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
124
- button[link] { background: none; border: none; padding: 0; color: var(--md-sys-color-primary, #477b9e); text-decoration: underline; cursor: pointer; font: inherit; }
125
144
  /* Size behaviour is a choice about dimensions only. It never implies a placement or a fastening. */
126
- details[resize-choice] { margin: 12px 0; padding: 0; border: 0; }
145
+ /* One of the size group's rows: the group's gap spaces it, as it does the rest (a margin on top of it left a hole). */
146
+ details[resize-choice] { margin: 0; padding: 0; border: 0; }
127
147
  details[resize-choice] > div { display: grid; gap: 8px; padding: 8px 0; }
128
148
  p[fasten-mode] { margin: 0; font-weight: 600; }
129
149
  section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
@@ -186,6 +206,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
186
206
 
187
207
  /** A new fastening starts with a target. Its face and alignment are asked only after that target is chosen. */
188
208
  @state() private choosingFastening = false
209
+ /** The fastening the part has, opened to be changed: until then the place group says it in one line. */
210
+ @state() private changingFastening = false
189
211
  /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
190
212
  @state() private spotlit = false
191
213
  @state() private fasten = freshFasten()
@@ -219,6 +241,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
219
241
  this.row = freshRow()
220
242
  this.editingMotion = false
221
243
  this.choosingFastening = false
244
+ this.changingFastening = false
222
245
  }
223
246
  }
224
247
 
@@ -270,17 +293,29 @@ export class FigureLinks extends localize(i18next)(LitElement) {
270
293
  ? this.renderPlaces(asset)
271
294
  : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`
272
295
  }
296
+ /*
297
+ Three groups in one order whatever the part's state (chief architect 2026-10-16): its size, its place, its row. The
298
+ editor's own numbers go in through the slots, so a group keeps its place when a rule or a fastening takes them over.
299
+ */
300
+ /* Fastened, by a face or by an origin: either can be taken off, from the place group's 「⋯」. */
301
+ const held = !!heldBy(asset, this.partId) || !!(this.revision && fasteningOf(this.revision, this.partId)?.atOrigin)
273
302
  const shown = html`
274
- <section>
275
- <h3>${i18next.t('figure.label.resize-intent')}</h3>
303
+ <section size>
304
+ <h3>${i18next.t('figure.label.group-size')}</h3>
305
+ <slot name="size"></slot>
306
+ <p group-question>${i18next.t('figure.label.resize-intent')}</p>
276
307
  ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
277
308
  </section>
278
309
  <section fastening ?spotlit=${this.spotlit} @focusin=${() => (this.working = 'fasten')}>
279
- <h3>${i18next.t('figure.label.placement')}${this.help.button('figure.text.fastening-explained')}</h3>${this.help.hint('figure.text.fastening-explained')}
310
+ <h3>
311
+ ${i18next.t('figure.label.group-place')}${this.help.button('figure.text.fastening-explained')}
312
+ ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
313
+ </h3>${this.help.hint('figure.text.fastening-explained')}
314
+ <slot name="place"></slot>
280
315
  ${this.renderPositions(asset)}
281
316
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
282
317
  </section>
283
- ${this.renderRow(asset)}
318
+ ${rowOf(asset, this.partId) ? html`<slot name="row"></slot>` : this.renderRow(asset)}
284
319
  `
285
320
  this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : this.fastenCandidate
286
321
  return shown
@@ -301,7 +336,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
301
336
  <b bound>${one.label}</b>
302
337
  <small>${one.face ? i18next.t('figure.label.face-' + one.face) + ' · ' : ''}${placeTerms(one, (key, params) => i18next.t(key, params))}</small>
303
338
  </span>
304
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
339
+ <button quiet ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
305
340
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
306
341
  </button>
307
342
  </div>`
@@ -373,9 +408,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
373
408
  return html`<div row>
374
409
  <b>${word(axis)}</b>
375
410
  <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
376
- <span>${i18next.t(custom ? 'figure.label.resize-custom' : following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
411
+ <span>${custom ? followWordsOf(now, (key, params) => i18next.t(key, params)) : i18next.t(following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
377
412
  ${following
378
- ? html`<button link resize-unfollow=${axis} ?disabled=${this.locked}
413
+ ? html`<button quiet resize-unfollow=${axis} ?disabled=${this.locked}
379
414
  @click=${() => {
380
415
  this.follow(axis, 'own', now)
381
416
  }}>${i18next.t('figure.button.resize-unfollow')}</button>`
@@ -383,7 +418,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
383
418
  </div>`
384
419
  })}</div>`}
385
420
  ${followsAll
386
- ? html`<p hint>${i18next.t('figure.text.resize-all-following')}</p>`
421
+ ? nothing
387
422
  : html`<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
388
423
  <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
389
424
  </button>`}
@@ -398,15 +433,15 @@ export class FigureLinks extends localize(i18next)(LitElement) {
398
433
  <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
399
434
  ${AXES.filter(one => hasInstanceSize(asset, one)).map(
400
435
  one => html`<option value=${`share:${one}`} ?selected=${value === `share:${one}`}>
401
- ${i18next.t('figure.label.follows-instance-share')} · ${one.toUpperCase()}
436
+ ${i18next.t('figure.label.follows-instance-share', { axis: one.toUpperCase() })}
402
437
  </option>
403
438
  <option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
404
- ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
439
+ ${i18next.t('figure.label.follows-instance', { axis: one.toUpperCase() })}
405
440
  </option>`
406
441
  )}
407
442
  ${others.flatMap(other =>
408
443
  AXES.map(
409
- one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${other} · ${one.toUpperCase()}</option>`
444
+ one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${i18next.t('figure.label.follows-part-axis', { part: other, axis: one.toUpperCase() })}</option>`
410
445
  )
411
446
  )}
412
447
  ${now.of === 'expression' || now.of === 'input'
@@ -468,6 +503,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
468
503
  private async openFastening(asset: FigureAsset) {
469
504
  const own = fasteningOf(this.revision!, this.partId)
470
505
  this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
506
+ /* Asked for from elsewhere: the form itself, not the one-line summary of the fastening it has. */
507
+ this.changingFastening = true
471
508
  this.spotlit = true
472
509
  await this.updateComplete
473
510
  const section = this.renderRoot.querySelector<HTMLElement>('section[fastening]')
@@ -548,9 +585,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
548
585
  this.fastenCandidate = undefined
549
586
  return html`<div fastened fasten-at-origin>
550
587
  <p>${this.heldWords(asset)}</p>
551
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
552
- <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
553
- </button>
554
588
  ${this.renderDetachWords()}
555
589
  </div>`
556
590
  }
@@ -610,17 +644,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
610
644
  */
611
645
  const current = asset.joints?.find(j => j.body1.part === this.partId)
612
646
  const editing = i18next.t('figure.text.fasten-new')
613
- /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
614
- if (!current && !this.choosingFastening && !f.target)
615
- return html`
616
- <p hint>${i18next.t('figure.text.fasten-start')}</p>
617
- <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
618
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
619
- </button>
620
- `
621
- return html`
622
- ${current ? nothing : html`<p fasten-mode>${editing}</p>`}
623
- ${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
647
+ /*
648
+ A fastening the part has, said in one line -- what, on which face, how -- and 「바꾸기」 to open the drawing and the
649
+ details (chief architect 2026-10-16). They were open on every fastened part, near a screen of the panel, for a
650
+ choice made once. Taking it off is in the group's 「⋯」. What would hang over still shows: it is about now.
651
+ */
652
+ /* What hangs over the face, and the way to fix it: about the fastening as it is now, so said open or folded. */
653
+ const overhang = html`${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
624
654
  ${this.overhangWords.length
625
655
  ? html`<div overhang role="status">
626
656
  ${this.overhangWords.map(line => html`<p>${line}</p>`)}
@@ -631,15 +661,28 @@ export class FigureLinks extends localize(i18next)(LitElement) {
631
661
  ${this.fixNote ? html`<p hint fix-note>${this.fixNote}</p>` : nothing}`
632
662
  : nothing}
633
663
  </div>`
634
- : nothing}
635
- ${held
636
- ? html`<div fastened>
637
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
638
- <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
639
- </button>
640
- ${this.renderDetachWords()}
641
- </div>`
642
- : nothing}
664
+ : nothing}`
665
+ if (current && held && !this.changingFastening && (f.toPlane || f.target))
666
+ return html`
667
+ ${overhang}
668
+ <div fastened-summary>
669
+ <p fasten-sentence>${this.sentenceOf(myFace, f.toPlane ? planeWord : `${f.target}의 ${i18next.t('figure.label.face-' + targetFace)}`, mode)}</p>
670
+ <button quiet change-fastening ?disabled=${this.locked} @click=${() => (this.changingFastening = true)}>${i18next.t('figure.button.change-fastening')}</button>
671
+ </div>
672
+ ${this.renderDetachWords()}
673
+ `
674
+ /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
675
+ if (!current && !this.choosingFastening && !f.target)
676
+ return html`
677
+ <p hint>${i18next.t('figure.text.fasten-start')}</p>
678
+ <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
679
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
680
+ </button>
681
+ `
682
+ return html`
683
+ ${current ? nothing : html`<p fasten-mode>${editing}</p>`}
684
+ ${overhang}
685
+ ${held ? this.renderDetachWords() : nothing}
643
686
  <div form>
644
687
  <span>${i18next.t('figure.label.target-part')}</span>
645
688
  <select @change=${(e: Event) => {
@@ -46,6 +46,11 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
46
46
  :host {
47
47
  display: block;
48
48
  }
49
+ /* The rarer half, drawn inside the 「더 보기」 fold: the fold gives the padding and the line. */
50
+ div[more] {
51
+ display: grid;
52
+ gap: 8px;
53
+ }
49
54
  div[field] {
50
55
  display: grid;
51
56
  gap: 4px;
@@ -164,6 +169,11 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
164
169
  /** The asset's palette, token → colour. */
165
170
  @property({ attribute: false }) palette: Record<string, string> = {}
166
171
  @property({ type: Boolean }) locked = false
172
+ /**
173
+ * Which half to draw. `look` is what most parts need -- the colour role and the preset; `more` is what few parts
174
+ * need -- transparency, glow and the segments of a round part -- drawn in the properties' 「더 보기」 fold.
175
+ */
176
+ @property({ type: String }) show: 'look' | 'more' = 'look'
167
177
  /** The glow while its slider is dragged: shown beside it and drawn on the canvas, recorded on release. */
168
178
  @state() private glowing?: number
169
179
 
@@ -179,6 +189,7 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
179
189
  const painted = entries.filter(([token]) => !token.startsWith('palette.status-'))
180
190
  const lit = entries.filter(([token]) => token.startsWith('palette.status-'))
181
191
  const glow = material.emissive
192
+ if (this.show === 'more') return this.renderMore(part, painted, lit)
182
193
  return html`<section>
183
194
  <h3>${i18next.t('figure.label.part-material')}</h3>
184
195
  <div field>
@@ -201,6 +212,14 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
201
212
  )}
202
213
  </select>
203
214
  </div>
215
+ </section>`
216
+ }
217
+
218
+ /** Transparency, glow and segments: few parts need them, so they wait in the 「더 보기」 fold. */
219
+ private renderMore(part: FigureAssetGeometry & { id: string }, painted: [string, string][], lit: [string, string][]) {
220
+ const material = part.material
221
+ const glow = material.emissive
222
+ return html`<div more>
204
223
  <label toggle>
205
224
  <input
206
225
  type="checkbox"
@@ -233,7 +252,7 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
233
252
  </div>`
234
253
  : nothing}
235
254
  ${this.renderSegments(part)}
236
- </section>`
255
+ </div>`
237
256
  }
238
257
 
239
258
  private renderSegments(part: FigureAssetGeometry & { id: string }) {
@@ -10,15 +10,18 @@ import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSetting
10
10
  import { dialShape } from './angle-dial.js'
11
11
  import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
12
12
  import { derivedOf, type FigureRevision } from './revision.js'
13
- import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewTypeOf, setupPartOf } from '@things-factory/figure-viewer/dist-client/preview-model.js'
13
+ import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeRangeOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
14
+ import { previewSizeWordsOf } from './preview-size.js'
14
15
 
15
16
  /**
16
17
  * The figure placed on a board.
17
18
  *
18
19
  * The editing canvas shows the figure at the size it was made. Half of what a figure is lies in how it answers a
19
- * different size, so here the figure being authored is registered as a board registers it and placed as
20
- * `FigureInstance`s: the size it was made, and each of the seven ways of growing one, two or three axes twice as
21
- * large, side by side at one scale, four to a row. The board draws them as it will on a real board --
20
+ * different size, so here the figure being authored is registered as a board registers it and placed as a
21
+ * `FigureInstance` the author stretches with a slider per axis the figure lets change, its size and its rows' counts
22
+ * said beside it (first 15 minutes, 2026-10-16: eight variants side by side at one scale made a 60 mm roller on a 3 m
23
+ * conveyor a speck). The eight -- the size it was made, and each of the seven ways of growing one, two or three axes
24
+ * twice as large, four to a row -- are a choice in the legend. The board draws them as it will on a real board --
22
25
  * placement, lights, environment, the same renderer.
23
26
  *
24
27
  * The controls: each value that poses the figure with a slider (and a dial for an angle), each clip's
@@ -92,6 +95,55 @@ export class FigurePreview extends localize(i18next)(LitElement) {
92
95
  flex-wrap: wrap;
93
96
  max-width: calc(100% - 16px);
94
97
  }
98
+ /* One figure or eight: two words in the legend, the one shown pressed. */
99
+ div[legend] button[way] {
100
+ padding: 0 6px;
101
+ font: inherit;
102
+ color: inherit;
103
+ background: none;
104
+ border: none;
105
+ border-radius: 4px;
106
+ cursor: pointer;
107
+ opacity: 0.7;
108
+ }
109
+ div[legend] button[way][aria-pressed='true'] {
110
+ opacity: 1;
111
+ background-color: rgba(255, 255, 255, 0.18);
112
+ }
113
+ /* The one figure's size: under the legend, in the controls' look. */
114
+ div[sizes] {
115
+ position: absolute;
116
+ top: 40px;
117
+ left: 8px;
118
+ display: grid;
119
+ gap: 6px;
120
+ padding: 8px 10px;
121
+ max-width: calc(100% - 16px);
122
+ box-sizing: border-box;
123
+ color: rgba(255, 255, 255, 0.92);
124
+ background-color: rgba(28, 28, 30, 0.72);
125
+ border-radius: 8px;
126
+ font-size: 0.72rem;
127
+ }
128
+ div[sizes] label {
129
+ display: grid;
130
+ grid-template-columns: 32px 160px 52px;
131
+ align-items: center;
132
+ gap: 8px;
133
+ }
134
+ div[sizes] input {
135
+ width: 100%;
136
+ accent-color: var(--md-sys-color-primary);
137
+ }
138
+ div[sizes] output {
139
+ font-variant-numeric: tabular-nums;
140
+ text-align: right;
141
+ }
142
+ div[sizes] p[said] {
143
+ margin: 0;
144
+ font-variant-numeric: tabular-nums;
145
+ font-weight: 600;
146
+ }
95
147
  /* A variant drawn at another size than it asks: the figure does not declare that size. */
96
148
  div[legend] small[clamp] {
97
149
  margin-left: 4px;
@@ -216,6 +268,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
216
268
  /** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
217
269
  @property() selected = ''
218
270
  @state() private setupPart = ''
271
+ /** One figure stretched by sliders, or the eight ways of growing side by side. */
272
+ @state() private way: 'one' | 'eight' = 'one'
273
+ /** The size the one figure is tried at; the authored size when the figure stands up. */
274
+ @state() private trying?: { x: number; y: number; z: number }
275
+ private board = { width: 0, height: 0 }
219
276
 
220
277
  /** What is being tried here now -- clip speeds and control values -- for the AI dock to hear with a request; none when nothing is. */
221
278
  tried(): { clips?: Record<string, number>; controls?: Record<string, number> } | undefined {
@@ -247,7 +304,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
247
304
  ${this.cannot
248
305
  ? html`<div cannot>${this.cannot}</div>`
249
306
  : html`<div legend>
250
- ${PREVIEW_WAYS.map(way => {
307
+ ${(['one', 'eight'] as const).map(
308
+ way => html`<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`
309
+ )}
310
+ ${this.way === 'one' ? nothing : PREVIEW_WAYS.map(way => {
251
311
  const clamps = clampsByWay[way.name] ?? []
252
312
  return html`<span>
253
313
  ${way.axes.length ? way.name : i18next.t('figure.label.preview-base')}
@@ -257,6 +317,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
257
317
  </span>`
258
318
  })}
259
319
  </div>
320
+ ${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
260
321
  <div foot>${asset?.appearance.length ? html`<div setup>
261
322
  <p>${i18next.t('figure.text.preview-setup')}</p>
262
323
  <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
@@ -267,6 +328,51 @@ export class FigurePreview extends localize(i18next)(LitElement) {
267
328
  ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
268
329
  }
269
330
 
331
+ /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
332
+ private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
333
+ const say = (key: string, params?: Record<string, string>) => i18next.t(key, params)
334
+ /* A board sizes a figure by the space it takes: with none declared, its box folds and no size can be tried. */
335
+ if (!asset.occupancy) return html`<div sizes><p said>${i18next.t('figure.text.preview-needs-space')}</p></div>`
336
+ return html`<div sizes>
337
+ ${(['x', 'y', 'z'] as const).map(axis => {
338
+ const range = sizeRangeOf(asset, axis)
339
+ if (!range) return nothing
340
+ return html`<label>
341
+ <span>${i18next.t(`figure.label.measure-${axis}`)}</span>
342
+ <input type="range" aria-label=${i18next.t(`figure.label.measure-${axis}`)} min=${range.min} max=${range.max} step="any" .value=${String(size[axis])}
343
+ @input=${(e: Event) => this.tryAxis(axis, (e.target as HTMLInputElement).valueAsNumber)}
344
+ @change=${() => this.fitView()} />
345
+ <output>${Math.round(size[axis]).toLocaleString()}</output>
346
+ </label>`
347
+ })}
348
+ <p said>${previewSizeWordsOf(asset, size, say)}</p>
349
+ </div>`
350
+ }
351
+
352
+ private chooseWay(way: 'one' | 'eight') {
353
+ if (way === this.way) return
354
+ this.way = way
355
+ this.standUp()
356
+ }
357
+
358
+ /** The one figure at another size on an axis, held to what the figure declares, its centre kept on the board's. */
359
+ private tryAxis(axis: 'x' | 'y' | 'z', value: number) {
360
+ const asset = this.asset
361
+ const used = asset ? sizeWithinRange(asset, axis, value) : undefined
362
+ if (used === undefined || !this.trying) return
363
+ this.trying = { ...this.trying, [axis]: used }
364
+ const instance = this.scene?.root?.components?.[0] as { model?: { width?: number; height?: number }; set(props: Record<string, unknown>): void } | undefined
365
+ instance?.set(axis === 'x' ? { width: used } : axis === 'y' ? { depth: used } : { height: used })
366
+ const { width = 0, height = 0 } = instance?.model ?? {}
367
+ instance?.set({ left: this.board.width / 2 - width / 2, top: this.board.height / 2 - height / 2 })
368
+ }
369
+
370
+ /** The figure as stretched, whole in view: done once the slider is let go, so the ground does not slide under a drag. */
371
+ private fitView() {
372
+ this.scene?.fit?.('ratio')
373
+ this.scene?.root?.fitCameraToModel?.()
374
+ }
375
+
270
376
  private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
271
377
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
272
378
  return html`<div clips>
@@ -464,7 +570,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
464
570
  this.teardown()
465
571
  return
466
572
  }
467
- const signature = JSON.stringify([asset, view.placement, view.ceilingHeight])
573
+ const signature = JSON.stringify([asset, view.placement, view.ceilingHeight, this.way])
468
574
  if (this.scene && this.standing === signature) return
469
575
  try {
470
576
  compileFigureAsset(asset)
@@ -490,12 +596,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
490
596
  this.valuing = {}
491
597
  registerFigure(type, asset)
492
598
  this.registered = type
493
- const { components, board } = previewInstances(type, size)
599
+ const { components, board } = this.way === 'one' ? previewOneInstance(type, size) : previewInstances(type, size)
600
+ this.board = board
601
+ this.trying = this.way === 'one' ? { ...size } : undefined
494
602
  const model = {
495
603
  type: 'model-layer',
496
604
  width: board.width,
497
605
  height: board.height,
498
- depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
606
+ /* As tall as the figure may grow: twice for the eight, the declared height range's top for the one. */
607
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeRangeOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
499
608
  threed: true,
500
609
  sky: 'color',
501
610
  ...sceneLook(view),
@@ -174,7 +174,6 @@ export class FigureProperties extends localize(i18next)(LitElement) {
174
174
  one => html`<option value=${one} ?selected=${one === current}>${i18next.t('figure.label.placement-' + one)}</option>`
175
175
  )}
176
176
  </select>
177
- <small>${i18next.t('figure.text.placement-is-intent')}</small>
178
177
  </div>
179
178
  `
180
179
  }
@@ -212,7 +211,7 @@ export class FigureProperties extends localize(i18next)(LitElement) {
212
211
  <button act ?disabled=${!facts.declared || !part} @click=${() => this.command({ kind: 'set-mounting-face', part: part!, face })}>
213
212
  <md-icon>check</md-icon>${i18next.t('figure.button.set-mounting-face')}
214
213
  </button>
215
- <button act ?disabled=${!mounting} @click=${() => this.command({ kind: 'clear-mounting-face' })}>
214
+ <button quiet ?disabled=${!mounting} @click=${() => this.command({ kind: 'clear-mounting-face' })}>
216
215
  <md-icon>close</md-icon>${i18next.t('figure.button.clear-mounting-face')}
217
216
  </button>
218
217
  </div>
@@ -30,6 +30,11 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
30
30
  :host {
31
31
  display: block;
32
32
  }
33
+ /* Inside the properties' 「더 보기」 fold: one group among the fold's, not a section of the side of its own. */
34
+ :host([embedded]) section {
35
+ padding: 0;
36
+ border-bottom: 0;
37
+ }
33
38
  div[row] {
34
39
  display: flex;
35
40
  gap: 6px;
@@ -421,6 +421,9 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
421
421
  else this.measureTimer = setTimeout(() => this.measure(), MEASURE_SETTLE_MS)
422
422
  }
423
423
  if (changed || changes.has('figureId') || changes.has('figureType')) this.schedule()
424
+ /* What blocks release, told to the page so the bar can say it beside 「발행」 (first 15 minutes, 2026-10-16). */
425
+ if (changes.has('inspection'))
426
+ this.dispatchEvent(new CustomEvent('release-verdict', { detail: { blocking: (this.inspection?.findings ?? []).filter(finding => finding.blocking) } }))
424
427
  }
425
428
 
426
429
  private measure() {
@@ -762,19 +765,23 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
762
765
  ${blocking.length
763
766
  ? html`<ul gate>
764
767
  <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
765
- ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code)))}
768
+ ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code), this.partNames()))}
766
769
  </ul>`
767
770
  : nothing}
768
771
  ${advisory.length
769
772
  ? html`<ul gate>
770
773
  <li head quiet>${i18next.t('figure.text.does-not-block')}</li>
771
- ${advisory.map(finding => renderGateFinding(finding, false))}
774
+ ${advisory.map(finding => renderGateFinding(finding, false, nothing, this.partNames()))}
772
775
  </ul>`
773
776
  : nothing}
774
777
  </section>
775
778
  `
776
779
  }
777
780
 
781
+ private partNames(): string[] {
782
+ return (this.revision?.asset.appearance ?? []).map(look => look.target)
783
+ }
784
+
778
785
  /** The same way out the release panel offers for the same two findings — declare the space from the parts. */
779
786
  private actionsFor(code: string) {
780
787
  if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
@@ -2,6 +2,7 @@ import { css, html, nothing, type TemplateResult } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
3
 
4
4
  import type { FigureGateFinding } from '../types.js'
5
+ import { affectedPartsOf } from './affected-parts.js'
5
6
 
6
7
  /**
7
8
  * One release-gate finding as a line on screen — the measured fact, where, why it matters, and what to change.
@@ -11,15 +12,22 @@ import type { FigureGateFinding } from '../types.js'
11
12
  * in each would drift the first time one of them is touched. The caller passes its own action buttons, because
12
13
  * what can be done about a finding depends on the screen it is shown on.
13
14
  */
14
- export function renderGateFinding(finding: FigureGateFinding, blocking: boolean, actions: unknown = nothing): TemplateResult {
15
+ export function renderGateFinding(
16
+ finding: FigureGateFinding,
17
+ blocking: boolean,
18
+ actions: unknown = nothing,
19
+ /** The figure's part names, when the screen has the figure: only these are shown as affected. */
20
+ parts?: readonly string[]
21
+ ): TemplateResult {
22
+ const affected = finding.at ? (parts ? affectedPartsOf(finding.at, parts) : [finding.at]) : []
15
23
  return html`
16
24
  <li finding ?blocking=${blocking}>
17
25
  <span msg>${finding.message}</span>
18
- ${finding.at
26
+ ${affected.length
19
27
  ? html`
20
28
  <details affected>
21
29
  <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
22
- <code where>${finding.at}</code>
30
+ <span where>${affected.join(' · ')}</span>
23
31
  </details>
24
32
  `
25
33
  : nothing}
@@ -68,7 +76,7 @@ export const gateFindingStyles = css`
68
76
  background-color: var(--md-sys-color-error-container);
69
77
  }
70
78
 
71
- li[finding] code[where] {
79
+ li[finding] [where] {
72
80
  font-family: var(--mono-font, monospace);
73
81
  font-size: 0.68rem;
74
82
  opacity: 0.85;