@things-factory/figure-ui 10.1.222 → 10.1.224

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 (59) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor.ts +14 -7
  5. package/client/modeller/figure-capability-panel.ts +145 -1
  6. package/client/modeller/figure-data-panel.ts +495 -188
  7. package/client/modeller/figure-links.ts +130 -12
  8. package/client/modeller/figure-preview.ts +19 -2
  9. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  10. package/client/modeller/links.ts +58 -0
  11. package/client/modeller/refusals.ts +12 -4
  12. package/client/modeller/slot-layout-host.ts +76 -5
  13. package/dist-client/modeller/canvas.js +2 -2
  14. package/dist-client/modeller/canvas.js.map +1 -1
  15. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  16. package/dist-client/modeller/consumer-capabilities.js +60 -0
  17. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  18. package/dist-client/modeller/data-interface.d.ts +83 -17
  19. package/dist-client/modeller/data-interface.js +194 -82
  20. package/dist-client/modeller/data-interface.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +5 -2
  22. package/dist-client/modeller/editor.js +16 -5
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  25. package/dist-client/modeller/figure-capability-panel.js +123 -1
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  28. package/dist-client/modeller/figure-data-panel.js +461 -180
  29. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  30. package/dist-client/modeller/figure-links.d.ts +11 -0
  31. package/dist-client/modeller/figure-links.js +137 -16
  32. package/dist-client/modeller/figure-links.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +4 -0
  34. package/dist-client/modeller/figure-preview.js +19 -1
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/links.d.ts +22 -0
  39. package/dist-client/modeller/links.js +48 -0
  40. package/dist-client/modeller/links.js.map +1 -1
  41. package/dist-client/modeller/refusals.js +14 -6
  42. package/dist-client/modeller/refusals.js.map +1 -1
  43. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  44. package/dist-client/modeller/slot-layout-host.js +68 -6
  45. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +9 -9
  48. package/test/browser/data-table-page.js +9 -0
  49. package/test/browser/links.check.mjs +25 -3
  50. package/test/consumer-capabilities.test.ts +116 -0
  51. package/test/data-motion.test.ts +94 -27
  52. package/test/links.test.ts +15 -0
  53. package/test/slot-layout-host.test.ts +63 -5
  54. package/translations/en.json +81 -23
  55. package/translations/es.json +81 -23
  56. package/translations/ja.json +81 -23
  57. package/translations/ko.json +81 -23
  58. package/translations/ms.json +81 -23
  59. package/translations/zh.json +81 -23
@@ -8,7 +8,7 @@ import { customElement, property, state } from 'lit/decorators.js'
8
8
 
9
9
  import { i18next, localize } from '@operato/i18n'
10
10
  import { figureFaceOverhangOf, figureMotionsOf, motionIdOf } from '@hatiolab/figure-model'
11
- import type { FigureAsset, FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
11
+ import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureFace } from '@hatiolab/figure-model'
12
12
 
13
13
  import {
14
14
  acceptsOf,
@@ -39,8 +39,17 @@ import {
39
39
  rowOf,
40
40
  positionsWorkedOut,
41
41
  type Axis,
42
- type MotionForm
42
+ type MotionForm,
43
+ followOf,
44
+ followableAxes,
45
+ measureNow,
46
+ freshStateSize,
47
+ stateSizeIdFor,
48
+ stateSizeCommandOf,
49
+ stateSizeFixedOf,
50
+ type StateSizeForm
43
51
  } from './links.js'
52
+ import { measureFactor } from './size-response.js'
44
53
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
45
54
  import { insideAlignmentOf } from './fasten-geometry.js'
46
55
  import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
@@ -179,7 +188,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
179
188
  /** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
180
189
  @state() private fixNote = ''
181
190
  @state() private motion: MotionForm = freshMotion()
191
+ /* The form for a measure a control sets (「상태에 따라 크기」). */
192
+ @state() private stateSize: StateSizeForm = freshStateSize()
182
193
  @state() private place = freshPlace()
194
+ /** The id of the place the form changes, kept so the place keeps its id (a release refuses a place id that goes); null adds one. */
195
+ @state() private placeEditing: string | null = null
183
196
  /** How places are made here: one at a time, or by the figure's rule. Unset: by the rule when the figure has one. */
184
197
  @state() private placesBy?: 'one' | 'rule'
185
198
  @state() private row = freshRow()
@@ -204,6 +217,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
204
217
  this.loadedFor = this.partId
205
218
  this.motion = freshMotion()
206
219
  this.place = freshPlace()
220
+ this.placeEditing = null
207
221
  this.row = freshRow()
208
222
  this.rowOpen = false
209
223
  this.rowCandidate = undefined
@@ -238,7 +252,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
238
252
  if (!asset || !this.partId) return nothing
239
253
  if (this.section === 'motion')
240
254
  return html`<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
241
- ${this.renderMotion(asset)}${this.renderShown(asset)}</section>`
255
+ ${this.renderMotion(asset)}${this.renderShown(asset)}${this.renderStateSize(asset)}</section>`
242
256
  if (this.section === 'places') {
243
257
  /*
244
258
  One idea, places, two ways to make them (ADR-0118): one at a time on this part, or many by a rule -- the figure's
@@ -306,6 +320,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
306
320
  return shown
307
321
  }
308
322
 
323
+ /** A place on this part into the form, to change it under its own id. */
324
+ private editPlace(one: FigureBindingPlace) {
325
+ this.placeEditing = one.id
326
+ this.place = { role: one.role, face: one.face ?? 'top', label: one.label, accepts: (one.accepts ?? []).join(', '), capacity: one.capacity }
327
+ }
328
+
309
329
  /** Where things are held on this part: a seat things rest on, or a grip things are held in. */
310
330
  private renderPlaces(asset: FigureAsset) {
311
331
  const faces = placeFacesOf(asset, this.partId)
@@ -321,6 +341,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
321
341
  <b bound>${one.label}</b>
322
342
  <small>${one.face ? i18next.t('figure.label.face-' + one.face) + ' · ' : ''}${placeTerms(one, (key, params) => i18next.t(key, params))}</small>
323
343
  </span>
344
+ <button quiet edit-place ?disabled=${this.locked} @click=${() => this.editPlace(one)}>
345
+ <md-icon>edit</md-icon>${i18next.t('figure.button.edit-place')}
346
+ </button>
324
347
  <button quiet ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
325
348
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
326
349
  </button>
@@ -345,28 +368,35 @@ export class FigureLinks extends localize(i18next)(LitElement) {
345
368
  <span>${i18next.t('figure.label.place-name')}</span>
346
369
  <input .value=${p.label} placeholder=${roleName(p.role)} @change=${(e: Event) => (this.place = { ...p, label: (e.target as HTMLInputElement).value })} />
347
370
  <span>${i18next.t('figure.label.place-accepts')}</span>
348
- <input .value=${p.accepts} placeholder="BOX, TOTE" @input=${(e: Event) => (this.place = { ...p, accepts: (e.target as HTMLInputElement).value })} />
371
+ <input .value=${p.accepts} placeholder=${i18next.t('figure.text.place-accepts-any')} @input=${(e: Event) => (this.place = { ...p, accepts: (e.target as HTMLInputElement).value })} />
349
372
  <span>${i18next.t('figure.label.place-capacity')}</span>
350
373
  <input type="number" min="1" step="1" .value=${String(p.capacity)} @change=${(e: Event) => (this.place = { ...p, capacity: (e.target as HTMLInputElement).valueAsNumber })} />
351
374
  </div>
352
- ${accepts.length ? nothing : html`<small>${i18next.t('figure.text.place-accepts-needed')}</small>`}
353
375
  <button
354
376
  act
355
- ?disabled=${this.locked || !accepts.length || !capacityOk}
356
- @click=${() =>
377
+ ?disabled=${this.locked || !capacityOk}
378
+ @click=${() => {
357
379
  this.command({
358
380
  kind: 'add-binding',
359
- id: placeIdFor(asset, this.partId, p.role),
381
+ id: this.placeEditing ?? placeIdFor(asset, this.partId, p.role),
360
382
  label: p.label.trim() || roleName(p.role),
361
383
  role: p.role,
362
384
  part: this.partId,
363
385
  face,
364
- accepts,
365
- capacity: p.capacity
366
- })}
386
+ /* None named: it takes any item (ADR-0051 §4.1 decision 5). */
387
+ ...(accepts.length ? { accepts } : {}),
388
+ capacity: p.capacity,
389
+ ...(this.placeEditing ? { replace: true } : {})
390
+ })
391
+ this.placeEditing = null
392
+ this.place = freshPlace()
393
+ }}
367
394
  >
368
- <md-icon>back_hand</md-icon>${i18next.t('figure.button.add-place')}
395
+ <md-icon>back_hand</md-icon>${i18next.t(this.placeEditing ? 'figure.button.save-place' : 'figure.button.add-place')}
369
396
  </button>
397
+ ${this.placeEditing
398
+ ? html`<button quiet @click=${() => ((this.placeEditing = null), (this.place = freshPlace()))}>${i18next.t('figure.button.cancel-place-edit')}</button>`
399
+ : nothing}
370
400
  `
371
401
  }
372
402
 
@@ -582,6 +612,94 @@ export class FigureLinks extends localize(i18next)(LitElement) {
582
612
  * When the part is drawn: always, or while a control is at least a value -- a lamp lit while the gate is open. One
583
613
  * part edit (`set-visibility`), one step. Offered once the figure has a control; a motion is what makes one.
584
614
  */
615
+ /**
616
+ * A measure a control sets (modeller snag C1, 2026-10-26): a wound roll shrinking to its core. Each measure that does is
617
+ * one line with the way back to a fixed number; the form makes another. Measures read as the size row shows them --
618
+ * a cylinder's X as its diameter.
619
+ */
620
+ private renderStateSize(asset: FigureAsset) {
621
+ const revision = this.revision
622
+ if (!revision) return nothing
623
+ const part = this.partId
624
+ const axes = followableAxes(asset, part)
625
+ if (!axes.length) return nothing
626
+ const word = (axis: Axis) => `${axis.toUpperCase()}${measureFactor(asset, part, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
627
+ const unitWord = (unit: string) => i18next.t(`figure.label.state-unit-${unit}`)
628
+ const sized = axes.flatMap(axis => {
629
+ const f = followOf(asset, part, axis)
630
+ return f.of === 'state' ? [{ axis, f }] : []
631
+ })
632
+ const free = axes.filter(axis => !sized.some(one => one.axis === axis))
633
+ const form = this.stateSize
634
+ const axis = form.axis && free.includes(form.axis) ? form.axis : free[0] ?? ''
635
+ const factor = axis ? measureFactor(asset, part, axis) : 1
636
+ const atMax = Number.isFinite(form.atMax) ? form.atMax : axis ? (measureNow(revision, part, axis) ?? NaN) : NaN
637
+ const id = form.id || stateSizeIdFor(asset, part)
638
+ const controls = visibilityControlsOf(asset)
639
+ const command = stateSizeCommandOf(asset, part, { ...form, axis, atMax }, factor, id)
640
+ /* A control already there is taken with its unit and range; another unit or range would be refused. */
641
+ const pickControl = (value: string) => {
642
+ const there = controls.find(c => c.id === value)
643
+ this.stateSize = there && ['percent', 'ratio', 'mm'].includes(there.unit)
644
+ ? { ...form, id: value, unit: there.unit as StateSizeForm['unit'], min: there.min, max: there.max }
645
+ : { ...form, id: value }
646
+ }
647
+ const number = (key: 'min' | 'max' | 'atMin' | 'atMax') => (e: Event) => (this.stateSize = { ...form, axis, [key]: (e.target as HTMLInputElement).valueAsNumber })
648
+ return html`<h4 state-size>${i18next.t('figure.label.state-size')}</h4>
649
+ ${sized.map(({ axis: one, f }) => {
650
+ const k = measureFactor(asset, part, one)
651
+ const input = controls.find(c => c.id === f.input)
652
+ return html`<div pair state-size-row=${one}>
653
+ <span>${word(one)}
654
+ <small>${i18next.t('figure.text.state-size-row', {
655
+ control: input?.label ?? f.input,
656
+ min: String(input?.min ?? ''),
657
+ max: String(input?.max ?? ''),
658
+ unit: input ? unitWord(input.unit) : '',
659
+ from: String(Math.round(f.at.min * k * 1000) / 1000),
660
+ to: String(Math.round(f.at.max * k * 1000) / 1000)
661
+ })}</small>
662
+ </span>
663
+ <button act ?disabled=${this.locked} @click=${() => {
664
+ const back = stateSizeFixedOf(asset, part, one)
665
+ if (back) this.command(back)
666
+ }}><md-icon>close</md-icon>${i18next.t('figure.button.state-size-fix')}</button>
667
+ </div>`
668
+ })}
669
+ ${free.length
670
+ ? html`<div form state-size-form>
671
+ <span>${i18next.t('figure.label.state-size-measure')}</span>
672
+ <select ?disabled=${this.locked} @change=${(e: Event) => (this.stateSize = { ...form, axis: (e.target as HTMLSelectElement).value as Axis, atMax: NaN })}>
673
+ ${free.map(one => html`<option value=${one} ?selected=${one === axis}>${word(one)}</option>`)}
674
+ </select>
675
+ <span>${i18next.t('figure.label.control-id')}</span>
676
+ <input list="state-size-controls" .value=${id} ?disabled=${this.locked} @change=${(e: Event) => pickControl((e.target as HTMLInputElement).value.trim())} />
677
+ <datalist id="state-size-controls">${controls.map(c => html`<option value=${c.id}>${c.label}</option>`)}</datalist>
678
+ <span>${i18next.t('figure.label.state-size-unit')}</span>
679
+ <select ?disabled=${this.locked} @change=${(e: Event) => {
680
+ const unit = (e.target as HTMLSelectElement).value as StateSizeForm['unit']
681
+ this.stateSize = { ...form, axis, unit, ...(unit === 'percent' ? { min: 0, max: 100 } : unit === 'ratio' ? { min: 0, max: 1 } : {}) }
682
+ }}>
683
+ ${(['percent', 'ratio', 'mm'] as const).map(u => html`<option value=${u} ?selected=${u === form.unit}>${unitWord(u)}</option>`)}
684
+ </select>
685
+ <span>${i18next.t('figure.label.control-range')} (${unitWord(form.unit)})</span>
686
+ <div pair>
687
+ <input type="number" step="any" .value=${String(form.min)} ?disabled=${this.locked} @change=${number('min')} />
688
+ <input type="number" step="any" .value=${String(form.max)} ?disabled=${this.locked} @change=${number('max')} />
689
+ </div>
690
+ <span>${i18next.t('figure.label.state-size-at-min')} (mm)</span>
691
+ <input type="number" step="any" min="0" state-size-at-min .value=${Number.isFinite(form.atMin) ? String(form.atMin) : ''} ?disabled=${this.locked} @change=${number('atMin')} />
692
+ <span>${i18next.t('figure.label.state-size-at-max')} (mm)</span>
693
+ <input type="number" step="any" min="0" state-size-at-max .value=${Number.isFinite(atMax) ? String(atMax) : ''} ?disabled=${this.locked} @change=${number('atMax')} />
694
+ </div>
695
+ <button act add-state-size ?disabled=${this.locked || !command} @click=${() => {
696
+ if (!command) return
697
+ this.command(command)
698
+ this.stateSize = freshStateSize()
699
+ }}><md-icon>open_in_full</md-icon>${i18next.t('figure.button.add-state-size')}</button>`
700
+ : nothing}`
701
+ }
702
+
585
703
  private renderShown(asset: FigureAsset) {
586
704
  const controls = visibilityControlsOf(asset)
587
705
  if (!controls.length) return nothing
@@ -4,7 +4,7 @@ import { customElement, property, query, state } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { compileFigureAsset } from '@hatiolab/figure-model'
6
6
  import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
7
- import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
7
+ import { create, figureLegendSourcesChanged, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
@@ -33,7 +33,11 @@ interface SceneHandle {
33
33
  resize?: () => void
34
34
  fit?: (type?: string) => void
35
35
  /** The scene's container: it can make the content again while keeping the 3D renderer and the camera (undo and redo use it). */
36
- rootContainer?: { rebuildContentModel?: (model: unknown, options?: { preserveThreeD?: boolean }) => void }
36
+ rootContainer?: {
37
+ rebuildContentModel?: (model: unknown, options?: { preserveThreeD?: boolean }) => void
38
+ /** The scene's legends, by name (ADR-0166 7): what its figures' tables choose from. */
39
+ style?: Record<string, unknown>
40
+ }
37
41
  root?: {
38
42
  set?: (patch: Record<string, unknown>) => void
39
43
  fitCameraToModel?: () => void
@@ -265,6 +269,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
265
269
  @state() private valuing: Record<string, FigureDataValue> = {}
266
270
  /** Data tried in the data tab: the preview's figures take it as their \`data\`, as a board's data source gives it; null when none is. */
267
271
  @property({ attribute: false }) sample: unknown = null
272
+ /** Sample legends by role (ADR-0166 10): the scene's legends named as the roles, as a board would give them. */
273
+ @property({ attribute: false }) legends: Record<string, unknown> = {}
268
274
  /** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
269
275
  @property() selected = ''
270
276
  @state() private setupPart = ''
@@ -526,8 +532,17 @@ export class FigurePreview extends localize(i18next)(LitElement) {
526
532
  if (this.sample !== null && this.sample !== undefined) this.tellData(this.sample)
527
533
  }
528
534
 
535
+ /** The sample legends as the scene's (its root container's, where a board's are): its tables choose again. */
536
+ private tellLegends() {
537
+ const container = this.scene?.rootContainer
538
+ if (!container) return
539
+ container.style = { ...this.legends }
540
+ figureLegendSourcesChanged()
541
+ }
542
+
529
543
  willUpdate(changed: Map<string, unknown>) {
530
544
  if (changed.has('sample') && this.scene) this.tellData(this.sample)
545
+ if (changed.has('legends') && this.scene) this.tellLegends()
531
546
  if (!this.hasUpdated) return
532
547
  if (!this.active) {
533
548
  this.stale = true
@@ -622,6 +637,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
622
637
  this.scene!.rootContainer!.rebuildContentModel!(model, { preserveThreeD: true })
623
638
  this.standing = signature
624
639
  this.cannot = ''
640
+ this.tellLegends()
625
641
  this.tellSample()
626
642
  return
627
643
  } catch {
@@ -633,6 +649,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
633
649
  this.scene = create({
634
650
  target: this.stage,
635
651
  model,
652
+ style: { ...this.legends },
636
653
  mode: 0
637
654
  }) as SceneHandle
638
655
  this.standing = signature
@@ -7,11 +7,13 @@ import { compileFigureAsset, type FigureAsset, type SlotLayout } from '@hatiolab
7
7
 
8
8
  import {
9
9
  anchorFacesOf,
10
+ anchorRoomOf,
10
11
  anchorIdFor,
11
12
  figureSlotIssues,
12
13
  slotLayoutCommandOf,
13
14
  slotLayoutFormOf,
14
15
  slotPresetsOf,
16
+ startSizeOf,
15
17
  type SlotAnchor,
16
18
  type SlotAnchorFace,
17
19
  type SlotLayoutForm
@@ -170,13 +172,15 @@ export class FigureSlotLayoutPanel extends localize(i18next)(LitElement) {
170
172
  const value = {
171
173
  canUseDefault: false,
172
174
  presets: slotPresetsOf(form.anchors),
173
- /* The first anchor's face is the size a preset is laid out over; the slot height is the author's. */
174
- presetSize: anchorFaces[form.anchors[0]?.id ?? ''] ?? {},
175
+ /* The first anchor's face is the size a preset is laid out over, the room over it the slot height: no number to type. */
176
+ presetSize: startSizeOf(form.anchors, anchorFaces, anchorRoomOf(form.anchors, geometry)),
177
+ /* No layout yet: the whole face is one slot, shown at once and put in on Apply. */
178
+ startFrom: 'single',
175
179
  anchors: form.anchors.map(a => ({ id: a.id, label: `${a.id} · ${a.part} ${i18next.t('figure.label.face-' + a.face)}` })),
176
180
  anchorFaces,
177
181
  validate: (layout: SlotLayout) => figureSlotIssues(layout, form.anchors.map(a => a.id)),
178
182
  issueText: (issue: { code: string; path: string }) =>
179
- issue.code === 'ACCEPTS_REQUIRED' ? `${issue.path}: ${i18next.t('figure.text.slot-accepts-required')}` : undefined
183
+ issue.code === 'ACCEPTS_EMPTY' ? `${issue.path}: ${i18next.t('figure.text.slot-accepts-empty-list')}` : undefined
180
184
  }
181
185
  this.options = { key, asset, value }
182
186
  return value
@@ -163,6 +163,9 @@ export function partSummaryOf(
163
163
  } else if (f.of === 'input') {
164
164
  key = 'figure.text.summary-size-input'
165
165
  params = { field: fieldWordsOf(f.input, k => say(k)) }
166
+ } else if (f.of === 'state') {
167
+ key = 'figure.text.summary-size-state'
168
+ params = { control: f.input }
166
169
  } else key = 'figure.text.summary-size-worked-out'
167
170
  const id = key + JSON.stringify(params)
168
171
  lines.set(id, { key, params, axes: [...(lines.get(id)?.axes ?? []), axis] })
@@ -729,6 +732,8 @@ export function followWordsOf(follow: Follow, say: (key: string, params?: Record
729
732
  return say('figure.label.follows-part-axis', { part: follow.part, axis: follow.axis.toUpperCase() })
730
733
  case 'input':
731
734
  return follow.input
735
+ case 'state':
736
+ return say('figure.label.follows-state', { control: follow.input })
732
737
  case 'expression':
733
738
  return say('figure.label.follows-expression')
734
739
  }
@@ -744,6 +749,8 @@ export function choiceOf(follow: Follow): string {
744
749
  return `part:${follow.part}:${follow.axis}`
745
750
  case 'input':
746
751
  return `input:${follow.input}`
752
+ case 'state':
753
+ return `state:${follow.input}`
747
754
  case 'expression':
748
755
  return 'expression'
749
756
  }
@@ -863,3 +870,54 @@ export function turnsAboutAJoint(asset: FigureAsset): boolean {
863
870
 
864
871
  /** The joint made still: its fastening, its place and its row kept, its motion gone (the kernel's `figureJointAsFixed`, ADR-0140 D6). */
865
872
  export const fixedJointOf = figureJointAsFixed
873
+
874
+ /** The form that sets a measure by a control (「상태에 따라 크기」): which measure, the control, and the measure at its two ends. */
875
+ export interface StateSizeForm {
876
+ axis: Axis | ''
877
+ id: string
878
+ unit: 'percent' | 'ratio' | 'mm'
879
+ min: number
880
+ max: number
881
+ /** The measure at the control's least and most, as the size row shows it (a cylinder's diameter). */
882
+ atMin: number
883
+ atMax: number
884
+ }
885
+
886
+ export const freshStateSize = (): StateSizeForm => ({ axis: '', id: '', unit: 'percent', min: 0, max: 100, atMin: NaN, atMax: NaN })
887
+
888
+ /** The control a new one would be named: `fill`, then `<part>-fill` when that is taken by another kind of value. */
889
+ export function stateSizeIdFor(asset: FigureAsset, part: string): string {
890
+ const taken = modelOf(asset).inputs.find(i => i.id === 'fill')
891
+ return !taken || taken.role === 'state' ? 'fill' : `${part}-fill`
892
+ }
893
+
894
+ /**
895
+ * The command the form sends, or why it cannot: a control with a range, and a positive measure at each end. The
896
+ * measure is typed as the row shows it and sent as the part's own (a diameter halved). A measure that follows something
897
+ * else is replaced: the author chose this one.
898
+ */
899
+ export function stateSizeCommandOf(asset: FigureAsset, part: string, form: StateSizeForm, factor: number, id: string): FigureAuthoringAction | undefined {
900
+ if (!form.axis || !id) return undefined
901
+ if (!(Number.isFinite(form.min) && Number.isFinite(form.max) && form.max > form.min)) return undefined
902
+ if (!(form.atMin > 0 && form.atMax > 0)) return undefined
903
+ const following = followOf(asset, part, form.axis).of !== 'own'
904
+ return {
905
+ kind: 'size-by-state',
906
+ part,
907
+ axis: form.axis,
908
+ state: { id, unit: form.unit, min: form.min, max: form.max },
909
+ at: { min: form.atMin / factor, max: form.atMax / factor },
910
+ ...(following ? { replace: true } : {})
911
+ }
912
+ }
913
+
914
+ /** A measure a control sets, back to a fixed number: the one it has where its control rests. */
915
+ export function stateSizeFixedOf(asset: FigureAsset, part: string, axis: Axis): FigureAuthoringAction | undefined {
916
+ const f = followOf(asset, part, axis)
917
+ if (f.of !== 'state') return undefined
918
+ const input = modelOf(asset).inputs.find(i => i.id === f.input)
919
+ if (!input || input.min === undefined || input.max === undefined) return undefined
920
+ const rest = asset.stateDefaults[f.input] ?? input.max
921
+ const mm = f.at.min + ((f.at.max - f.at.min) * (rest - input.min)) / (input.max - input.min)
922
+ return { kind: 'unlink-dimension', part, axis, mm: Math.round(mm * 1e6) / 1e6 }
923
+ }
@@ -269,10 +269,18 @@ export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
269
269
  high: domain.within ? numberOf(domain.within[1]) : '',
270
270
  value: domain.value === undefined ? '' : String(domain.value)
271
271
  })
272
- const port = detailsOf(error, 'data-port-invalid')
273
- if (port) return say('figure.text.data-port-invalid-' + port.what, { port: port.port, id: port.id ?? '' })
274
- const bound = detailsOf(error, 'data-port-bound')
275
- if (bound) return say('figure.text.data-port-bound', { port: bound.port, bindings: bound.bindings.join(', ') })
272
+ const target = detailsOf(error, 'data-table-target')
273
+ if (target) return say('figure.text.data-table-target-' + target.what, { binding: target.binding, id: target.id ?? '' })
274
+ const entry = detailsOf(error, 'data-table-entry')
275
+ if (entry)
276
+ return say('figure.text.data-table-entry-' + entry.what, {
277
+ binding: entry.binding,
278
+ key: entry.key === 'default' ? say('figure.label.data-otherwise', {}) : entry.key,
279
+ id: entry.id ?? '',
280
+ value: entry.value === undefined ? '' : String(typeof entry.value === 'object' ? JSON.stringify(entry.value) : entry.value)
281
+ })
282
+ const legend = detailsOf(error, 'data-legend-invalid')
283
+ if (legend) return say('figure.text.data-legend-invalid', { role: legend.role })
276
284
  const clipAbsent = detailsOf(error, 'motion-clip-absent')
277
285
  if (clipAbsent) return say('figure.text.motion-clip-absent', { clip: clipAbsent.clip, group: clipAbsent.group })
278
286
  const shared = detailsOf(error, 'motion-clip-shared')
@@ -9,12 +9,15 @@
9
9
  * Kept free of Lit, i18n and the editor's element so node tests read it directly.
10
10
  */
11
11
  import {
12
+ figureBindingsOf,
13
+ figureShapeBoundsOf,
12
14
  figureSlotLayoutOf,
13
15
  figureSlotLayoutIssues,
14
16
  slotLayoutPreset,
15
17
  SLOT_LAYOUT_PRESET_KEYS,
16
18
  SLOT_LAYOUT_PRESET_PARAMS,
17
19
  type FigureAsset,
20
+ type FigureFace,
18
21
  type SlotLayout,
19
22
  type SlotPresetSize
20
23
  } from '@hatiolab/figure-model'
@@ -46,8 +49,28 @@ export function slotLayoutFormOf(asset: FigureAsset, part: string): { form: Slot
46
49
  return { form: { label: '', role: 'seat', anchors: part ? [{ id: part, part, face: 'top' }] : [] } }
47
50
  }
48
51
 
49
- /** How big each anchor's face is: things-scene's, the one a board's placed figure is measured with too. */
50
- export { figureAnchorFacesOf as anchorFacesOf } from '@hatiolab/things-scene'
52
+ /** How big each anchor's face is, and the room the figure leaves over it: things-scene's, the ones a board's placed figure is measured with too. */
53
+ export { figureAnchorFacesOf as anchorFacesOf, figureAnchorRoomOf as anchorRoomOf } from '@hatiolab/things-scene'
54
+ import { figureAnchorRoomOf } from '@hatiolab/things-scene'
55
+
56
+ /* How high a place over a face is when nobody said: the room the figure leaves over it, else the face's shorter side. */
57
+ const heightOver = (room: number | undefined, width: number, depth: number) => ((room ?? 0) > 0 ? room! : Math.min(width, depth))
58
+
59
+ /**
60
+ * Where an author starts with no numbers to type (slot layout drawn on the face, step 1): the first anchor's face, and a
61
+ * slot as high as the room the figure leaves over it (a forklift's forks: up to its mast) -- the face's shorter side when
62
+ * the face is the figure's top. Nothing when the face is not known: the editor then asks for the size.
63
+ */
64
+ export function startSizeOf(
65
+ anchors: readonly SlotAnchor[],
66
+ faces: Record<string, { width: number; depth: number }>,
67
+ room: Record<string, number>
68
+ ): Partial<SlotPresetSize> {
69
+ const first = anchors[0]
70
+ const face = first && faces[first.id]
71
+ if (!face) return {}
72
+ return { width: face.width, depth: face.depth, slotHeight: heightOver(room[first.id], face.width, face.depth) }
73
+ }
51
74
 
52
75
  /**
53
76
  * Where an author starts: the equipment presets (figure-model, ADR-0118 decision 8), laid out on this figure's anchors --
@@ -67,13 +90,13 @@ export function slotPresetsOf(anchors: readonly SlotAnchor[]) {
67
90
  }
68
91
 
69
92
  /*
70
- * What figure-model asks beyond the editor's own checks: every slot names the types it takes (ADR-0118 decision 5) -- the
71
- * editor's "any type" is a board's, not a figure's. The kernel's own check (`figureSlotLayoutIssues`, the one a
93
+ * What figure-model asks beyond the editor's own checks: a slot names the types it takes or takes any item, never an
94
+ * empty list (ADR-0051 §4.1 decision 5, 2026-10-27). The kernel's own check (`figureSlotLayoutIssues`, the one a
72
95
  * placement's override is held to as well), said before Apply rather than refused after it; the editor already shows the
73
96
  * common findings, so only the figure's own are added.
74
97
  */
75
98
  export function figureSlotIssues(layout: SlotLayout, anchors: readonly string[] = layout.tiers.map(t => t.anchorId)): SlotEditorIssue[] {
76
- return figureSlotLayoutIssues(layout, anchors).filter(issue => issue.code === 'ACCEPTS_REQUIRED')
99
+ return figureSlotLayoutIssues(layout, anchors).filter(issue => issue.code === 'ACCEPTS_EMPTY')
77
100
  }
78
101
 
79
102
  /** The command the panel sends: the whole layout, its anchors and its name, as one step of the history. */
@@ -97,6 +120,8 @@ export function anchorIdFor(part: string, taken: readonly string[]): string {
97
120
 
98
121
  /* The token the canvas draws slots in: translucent, never picked, never saved. */
99
122
  export const SLOT_MARK = '__slot'
123
+ /* The token the selected part's places are drawn in: the same box, in the primary colour, so the list and the canvas meet. */
124
+ export const SLOT_MARK_CHOSEN = '__slot_chosen'
100
125
 
101
126
  type Evaluated = { id: string; pose: { r: number[][]; t: number[] }; region: { dimensions: number[] } | null }
102
127
 
@@ -126,3 +151,49 @@ export function slotMarksOf(places: readonly Evaluated[], layout: SlotLayout | u
126
151
  }
127
152
  return marks
128
153
  }
154
+
155
+ /* Which way each face looks out of its part: the axis and the side. */
156
+ const OUTWARD: Record<FigureFace, ['x' | 'y' | 'z', 1 | -1]> = { top: ['y', 1], bottom: ['y', -1], right: ['x', 1], left: ['x', -1], front: ['z', 1], back: ['z', -1] }
157
+ /* The two axes a face lies along. */
158
+ const ACROSS = { x: ['y', 'z'], y: ['x', 'z'], z: ['x', 'y'] } as const
159
+
160
+ type Body = { id: string; shape?: Parameters<typeof figureShapeBoundsOf>[0]; pose?: { r: number[][]; t: number[] } }
161
+
162
+ /**
163
+ * The places held one by one -- a forklift's fork, a lift's plate -- drawn where they are, as the slot layout's are: a
164
+ * place given a size as a box of that size out of its face, one without as its part's face raised as high as a slot
165
+ * would start there (the room over it, else the face's shorter side), so an author sees which face things sit on or
166
+ * hang from and about how much room they have. The selected part's places in the primary colour.
167
+ */
168
+ export function placeMarksOf(places: readonly Evaluated[], asset: FigureAsset, geometry: readonly Body[], selected?: string) {
169
+ const at = new Map(places.map(p => [p.id, p] as const))
170
+ const marks: FigureRenderPart[] = []
171
+ for (const one of figureBindingsOf(asset)) {
172
+ const p = at.get(one.id)
173
+ if (one.slot || !one.face || !p) continue
174
+ const [axis, side] = OUTWARD[one.face]
175
+ const [u, v] = ACROSS[axis]
176
+ let size: Record<'x' | 'y' | 'z', number>
177
+ if (one.region) size = one.region
178
+ else {
179
+ const shape = geometry.find(g => g.id === one.part)?.shape
180
+ if (!shape) continue
181
+ const b = figureShapeBoundsOf(shape)
182
+ const face = { x: b.x.max - b.x.min, y: b.y.max - b.y.min, z: b.z.max - b.z.min }
183
+ const room = axis === 'y' ? figureAnchorRoomOf([{ id: one.id, part: one.part!, face: one.face }], geometry as never)[one.id] : 0
184
+ size = { ...face, [axis]: heightOver(room, face[u], face[v]) }
185
+ }
186
+ const out = size[axis]
187
+ marks.push({
188
+ id: `${SLOT_MARK}:${one.id}`,
189
+ shape: {
190
+ provider: 'extrude',
191
+ dimensions: { width: size.x, height: size.y, depth: size.z },
192
+ extrusion: { axis, section: { kind: 'rectangle', size: { [u]: size[u], [v]: size[v] } }, start: side > 0 ? 0 : -out, end: side > 0 ? out : 0 }
193
+ },
194
+ pose: p.pose,
195
+ material: { token: one.part === selected ? SLOT_MARK_CHOSEN : SLOT_MARK, transparent: true }
196
+ })
197
+ }
198
+ return marks
199
+ }
@@ -5,7 +5,7 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene';
5
5
  import { DEFAULT_VIEW, sceneLook, WORKBENCH } from '@things-factory/figure-viewer/dist-client/figure-view.js';
6
6
  import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit } from './stage-model.js';
7
7
  import { JOINT_FAN } from './joint-fan.js';
8
- import { SLOT_MARK } from './slot-layout-host.js';
8
+ import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js';
9
9
  import { PREVIEW, TARGET_FACE } from './command-preview.js';
10
10
  import { ownerOf } from './fields.js';
11
11
  /* How long the figure stands still before the camera judges whether to follow it. */
@@ -225,7 +225,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
225
225
  /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
226
226
  const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board) : null;
227
227
  const accent = this.palette['palette.accent'] ?? FAN_COLOUR;
228
- const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent }, this.board));
228
+ const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent, [SLOT_MARK_CHOSEN]: this.palette['palette.primary'] ?? accent }, this.board));
229
229
  const shown = [...(fan ? [fan] : []), ...slots, ...this.ghost.map(part => componentOf(part, { ...this.palette, [PREVIEW]: accent, [TARGET_FACE]: accent }, this.board)), ...(this.highlight ? [componentOf(this.highlight, { ...this.palette, [TARGET_FACE]: accent }, this.board)] : [])];
230
230
  return [...parts, ...shown.filter((c) => !!c).map(c => ({ ...c, capturable: false, locked: true }))];
231
231
  }