@things-factory/figure-ui 10.1.245 → 10.1.246

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 (127) hide show
  1. package/client/modeller/align.ts +51 -13
  2. package/client/modeller/clips.ts +51 -1
  3. package/client/modeller/data-interface.ts +16 -0
  4. package/client/modeller/editor-styles.ts +6 -0
  5. package/client/modeller/editor.ts +112 -25
  6. package/client/modeller/fasten-form.ts +8 -1
  7. package/client/modeller/fasten-geometry.ts +24 -2
  8. package/client/modeller/figure-clips.ts +137 -4
  9. package/client/modeller/figure-fasten-dialog.ts +1 -0
  10. package/client/modeller/figure-fasten-drawing.ts +11 -5
  11. package/client/modeller/figure-links.ts +25 -0
  12. package/client/modeller/figure-material-panel.ts +54 -4
  13. package/client/modeller/figure-parts.ts +7 -2
  14. package/client/modeller/figure-pipe-panel.ts +215 -0
  15. package/client/modeller/figure-preview.ts +125 -4
  16. package/client/modeller/figure-section-editor.ts +36 -3
  17. package/client/modeller/links.ts +24 -2
  18. package/client/modeller/material-edit.ts +9 -0
  19. package/client/modeller/maturity.ts +15 -4
  20. package/client/modeller/palette.ts +9 -0
  21. package/client/modeller/pipe-path.ts +21 -0
  22. package/client/modeller/refusals.ts +66 -1
  23. package/client/modeller/section-controls.ts +2 -1
  24. package/client/modeller/section-drawing.ts +20 -1
  25. package/client/modeller/starting-shape.ts +8 -5
  26. package/dist-client/modeller/align.d.ts +28 -7
  27. package/dist-client/modeller/align.js +37 -12
  28. package/dist-client/modeller/align.js.map +1 -1
  29. package/dist-client/modeller/clips.d.ts +24 -0
  30. package/dist-client/modeller/clips.js +53 -1
  31. package/dist-client/modeller/clips.js.map +1 -1
  32. package/dist-client/modeller/data-interface.d.ts +6 -0
  33. package/dist-client/modeller/data-interface.js +17 -0
  34. package/dist-client/modeller/data-interface.js.map +1 -1
  35. package/dist-client/modeller/editor-styles.js +6 -0
  36. package/dist-client/modeller/editor-styles.js.map +1 -1
  37. package/dist-client/modeller/editor.d.ts +10 -1
  38. package/dist-client/modeller/editor.js +117 -26
  39. package/dist-client/modeller/editor.js.map +1 -1
  40. package/dist-client/modeller/fasten-form.d.ts +2 -0
  41. package/dist-client/modeller/fasten-form.js +5 -1
  42. package/dist-client/modeller/fasten-form.js.map +1 -1
  43. package/dist-client/modeller/fasten-geometry.d.ts +8 -1
  44. package/dist-client/modeller/fasten-geometry.js +22 -1
  45. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  46. package/dist-client/modeller/figure-clips.d.ts +9 -0
  47. package/dist-client/modeller/figure-clips.js +137 -4
  48. package/dist-client/modeller/figure-clips.js.map +1 -1
  49. package/dist-client/modeller/figure-fasten-dialog.js +1 -0
  50. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  51. package/dist-client/modeller/figure-fasten-drawing.d.ts +3 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +13 -5
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-links.d.ts +5 -0
  55. package/dist-client/modeller/figure-links.js +22 -0
  56. package/dist-client/modeller/figure-links.js.map +1 -1
  57. package/dist-client/modeller/figure-material-panel.d.ts +2 -0
  58. package/dist-client/modeller/figure-material-panel.js +53 -4
  59. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  60. package/dist-client/modeller/figure-parts.d.ts +1 -1
  61. package/dist-client/modeller/figure-parts.js +5 -1
  62. package/dist-client/modeller/figure-parts.js.map +1 -1
  63. package/dist-client/modeller/figure-pipe-panel.d.ts +29 -0
  64. package/dist-client/modeller/figure-pipe-panel.js +221 -0
  65. package/dist-client/modeller/figure-pipe-panel.js.map +1 -0
  66. package/dist-client/modeller/figure-preview.d.ts +8 -0
  67. package/dist-client/modeller/figure-preview.js +128 -4
  68. package/dist-client/modeller/figure-preview.js.map +1 -1
  69. package/dist-client/modeller/figure-section-editor.d.ts +5 -0
  70. package/dist-client/modeller/figure-section-editor.js +33 -3
  71. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  72. package/dist-client/modeller/links.d.ts +6 -0
  73. package/dist-client/modeller/links.js +17 -2
  74. package/dist-client/modeller/links.js.map +1 -1
  75. package/dist-client/modeller/material-edit.d.ts +8 -0
  76. package/dist-client/modeller/material-edit.js +8 -0
  77. package/dist-client/modeller/material-edit.js.map +1 -1
  78. package/dist-client/modeller/maturity.js +16 -5
  79. package/dist-client/modeller/maturity.js.map +1 -1
  80. package/dist-client/modeller/palette.js +9 -0
  81. package/dist-client/modeller/palette.js.map +1 -1
  82. package/dist-client/modeller/pipe-path.d.ts +19 -0
  83. package/dist-client/modeller/pipe-path.js +14 -0
  84. package/dist-client/modeller/pipe-path.js.map +1 -0
  85. package/dist-client/modeller/refusals.d.ts +12 -1
  86. package/dist-client/modeller/refusals.js +67 -0
  87. package/dist-client/modeller/refusals.js.map +1 -1
  88. package/dist-client/modeller/section-controls.js +2 -1
  89. package/dist-client/modeller/section-controls.js.map +1 -1
  90. package/dist-client/modeller/section-drawing.d.ts +5 -0
  91. package/dist-client/modeller/section-drawing.js +19 -1
  92. package/dist-client/modeller/section-drawing.js.map +1 -1
  93. package/dist-client/modeller/starting-shape.d.ts +9 -2
  94. package/dist-client/modeller/starting-shape.js +8 -3
  95. package/dist-client/modeller/starting-shape.js.map +1 -1
  96. package/dist-client/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +8 -8
  98. package/test/align.test.ts +28 -6
  99. package/test/browser/editing-page.js +21 -1
  100. package/test/browser/editing.check.mjs +124 -0
  101. package/test/browser/links.check.mjs +49 -8
  102. package/test/browser/modeller.check.mjs +27 -1
  103. package/test/browser/part-ids.check.mjs +60 -3
  104. package/test/browser/parts.check.mjs +2 -2
  105. package/test/browser/places-mesh.check.mjs +14 -8
  106. package/test/browser/screen.check.mjs +54 -1
  107. package/test/browser/section-page.js +7 -1
  108. package/test/browser/section.check.mjs +81 -0
  109. package/test/browser/side-panel-sweep.check.mjs +13 -0
  110. package/test/clips.test.ts +30 -1
  111. package/test/data-motion.test.ts +14 -1
  112. package/test/fasten-geometry.test.ts +13 -2
  113. package/test/joint-frames.test.ts +12 -2
  114. package/test/links.test.ts +24 -0
  115. package/test/material.test.ts +15 -1
  116. package/test/maturity.test.ts +19 -1
  117. package/test/palette-icons.test.ts +2 -1
  118. package/test/pipe-panel.test.ts +33 -0
  119. package/test/refusals.test.ts +29 -0
  120. package/test/revision.test.ts +1 -1
  121. package/test/section-editor.test.ts +17 -1
  122. package/translations/en.json +86 -3
  123. package/translations/es.json +86 -3
  124. package/translations/ja.json +86 -3
  125. package/translations/ko.json +86 -3
  126. package/translations/ms.json +86 -3
  127. package/translations/zh.json +86 -3
@@ -9,8 +9,10 @@ import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput
9
9
  import { create, figureLegendSourcesChanged, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
10
10
 
11
11
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
12
+ import { parametersForClip } from '@things-factory/figure-viewer/dist-client/detail-motion.js'
13
+ import { controlsOf } from '@things-factory/figure-viewer/dist-client/controls.js'
12
14
  import { dialShape } from './angle-dial.js'
13
- import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
15
+ import { dataValueAt, domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
14
16
  import { derivedOf, type FigureRevision } from './revision.js'
15
17
  import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
16
18
  import { previewSizeWordsOf } from './preview-size.js'
@@ -43,10 +45,16 @@ interface SceneHandle {
43
45
  root?: {
44
46
  set?: (patch: Record<string, unknown>) => void
45
47
  fitCameraToModel?: () => void
48
+ /** The scene's camera presets: the perspective, or a square-on view from one side. */
49
+ setCameraView?: (view: PreviewView) => void
46
50
  components?: { set?: (property: string, value: unknown) => void }[]
47
51
  }
48
52
  }
49
53
 
54
+ /* Where the preview looks from: the edit canvas's presets, which the preview's still scene does not draw (robot dog 2026-11-03). */
55
+ type PreviewView = 'perspective' | 'front' | 'right' | 'top'
56
+ const PREVIEW_VIEWS: readonly PreviewView[] = ['perspective', 'front', 'right', 'top']
57
+
50
58
  @customElement('figure-preview')
51
59
  export class FigurePreview extends localize(i18next)(LitElement) {
52
60
  static styles = css`
@@ -148,6 +156,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
148
156
  cursor: pointer;
149
157
  opacity: 0.7;
150
158
  }
159
+ div[legend] button[view] {
160
+ padding: 0 6px;
161
+ font: inherit;
162
+ color: inherit;
163
+ background: none;
164
+ border: none;
165
+ border-radius: 4px;
166
+ cursor: pointer;
167
+ opacity: 0.7;
168
+ }
169
+ div[legend] span[views] {
170
+ display: flex;
171
+ gap: 2px;
172
+ padding-left: 6px;
173
+ border-left: 1px solid rgba(255, 255, 255, 0.22);
174
+ }
175
+ div[legend] button[view][aria-pressed='true'],
151
176
  div[legend] button[way][aria-pressed='true'] {
152
177
  opacity: 1;
153
178
  background-color: rgba(255, 255, 255, 0.18);
@@ -198,7 +223,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
198
223
  div[clips] {
199
224
  margin-left: auto;
200
225
  display: grid;
201
- grid-template-columns: auto 20px 96px 34px;
226
+ grid-template-columns: auto 20px 96px 44px;
202
227
  gap: 6px 8px;
203
228
  align-items: center;
204
229
  padding: 8px 10px;
@@ -241,6 +266,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
241
266
  fill: none;
242
267
  stroke: rgba(255, 255, 255, 0.35);
243
268
  }
269
+ /* Folded, the controls are one button: what they covered -- a leg, say -- can be seen (robot dog 2026-11-03). */
270
+ div[clips] button[fold-controls] {
271
+ justify-self: end;
272
+ padding: 0 4px;
273
+ font: inherit;
274
+ color: inherit;
275
+ background: none;
276
+ border: none;
277
+ cursor: pointer;
278
+ opacity: 0.75;
279
+ }
280
+ div[clips] button[fold-controls]:hover {
281
+ opacity: 1;
282
+ }
244
283
  div[clips] button[pose] {
245
284
  align-self: stretch;
246
285
  padding: 4px 8px;
@@ -277,6 +316,17 @@ export class FigurePreview extends localize(i18next)(LitElement) {
277
316
  grid-column: 3 / -1;
278
317
  width: auto;
279
318
  }
319
+ /* A control's value, typed where the slider cannot land on it (a step of the range's hundredth, robot dog 2026-11-03). */
320
+ div[clips] input[typed] {
321
+ width: 100%;
322
+ padding: 0 3px;
323
+ text-align: right;
324
+ font-variant-numeric: tabular-nums;
325
+ }
326
+ div[clips] label[sampled] output {
327
+ grid-column: 3 / -1;
328
+ text-align: left;
329
+ }
280
330
  div[clips] output {
281
331
  font-variant-numeric: tabular-nums;
282
332
  text-align: right;
@@ -335,6 +385,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
335
385
  @state() private setupPart = ''
336
386
  /** One figure stretched by sliders, or the eight ways of growing side by side. */
337
387
  @state() private way: 'one' | 'eight' = 'one'
388
+ /** Where the camera looks from; a new scene starts in perspective. */
389
+ @state() private looking: PreviewView = 'perspective'
390
+ /** The posing controls folded to their one button. */
391
+ @state() private controlsFolded = false
338
392
  /** The size the one figure is tried at; the authored size when the figure stands up. */
339
393
  @state() private trying?: { x: number; y: number; z: number }
340
394
  private board = { width: 0, height: 0 }
@@ -376,6 +430,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
376
430
  ${(['one', 'eight'] as const).map(
377
431
  way => html`<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`
378
432
  )}
433
+ <span views>
434
+ ${PREVIEW_VIEWS.map(
435
+ view => html`<button view=${view} aria-pressed=${String(this.looking === view)} @click=${() => this.lookFrom(view)}>${i18next.t(`figure.label.preview-view-${view}`)}</button>`
436
+ )}
437
+ </span>
379
438
  ${this.way === 'one' ? nothing : PREVIEW_WAYS.map(way => {
380
439
  const clamps = clampsByWay[way.name] ?? []
381
440
  return html`<span>
@@ -397,6 +456,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
397
456
  ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
398
457
  }
399
458
 
459
+ private lookFrom(view: PreviewView) {
460
+ this.scene?.root?.setCameraView?.(view)
461
+ this.looking = view
462
+ }
463
+
400
464
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
401
465
  private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
402
466
  const say = (key: string, params?: Record<string, string>) => i18next.t(key, params)
@@ -485,16 +549,28 @@ export class FigurePreview extends localize(i18next)(LitElement) {
485
549
  }
486
550
 
487
551
  private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
552
+ const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
488
553
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
554
+ const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
555
+ ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
556
+ </button>`
557
+ if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
489
558
  return html`<div clips>
559
+ ${fold}
490
560
  ${changed.length
491
561
  ? html`<button pose title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
492
562
  ${i18next.t('figure.button.pose-as-default')}
493
563
  </button>`
494
564
  : nothing}
565
+ ${Object.keys(this.giving).length
566
+ ? html`<button clear-tried title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
567
+ ${i18next.t('figure.button.clear-tried')}
568
+ </button>`
569
+ : nothing}
495
570
  ${controls.map(control => {
496
571
  const value = this.giving[control.id] ?? asset.stateDefaults[control.id] ?? control.min
497
- const step = Math.max((control.max - control.min) / 100, 0.01)
572
+ /* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
573
+ const step = steps.get(control.id) ?? (control.max - control.min) / 100
498
574
  const label = asset.stateInputs?.[control.id]?.label ?? control.id
499
575
  return html`<label>
500
576
  <span>${label} <span drive>${unitWord(control.unit)}</span></span>
@@ -507,7 +583,19 @@ export class FigurePreview extends localize(i18next)(LitElement) {
507
583
  .value=${String(value)}
508
584
  @input=${(e: Event) => this.give(control.id, Number((e.target as HTMLInputElement).value))}
509
585
  />
510
- <output>${Math.round(value)}</output>
586
+ <input
587
+ typed
588
+ type="number"
589
+ step="any"
590
+ min=${control.min}
591
+ max=${control.max}
592
+ aria-label=${label}
593
+ .value=${String(Math.round(value * 100) / 100)}
594
+ @change=${(e: Event) => {
595
+ const typed = (e.target as HTMLInputElement).valueAsNumber
596
+ if (Number.isFinite(typed)) this.give(control.id, Math.min(Math.max(typed, control.min), control.max))
597
+ }}
598
+ />
511
599
  </label>`
512
600
  })}
513
601
  ${clips.map(clip => {
@@ -552,6 +640,16 @@ export class FigurePreview extends localize(i18next)(LitElement) {
552
640
  private renderDataValue(row: FigureDataRow) {
553
641
  const node = row.node
554
642
  const label = row.label ?? row.path
643
+ /*
644
+ The data tab's sample, when it gives the row, is what the figure shows: a value given here is not read. Said, and
645
+ the field not offered, rather than one that changes nothing (robot dog 2026-11-03).
646
+ */
647
+ const sampled = this.sample === null || this.sample === undefined ? undefined : dataValueAt(this.sample, row.path)
648
+ if (sampled !== undefined)
649
+ return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
650
+ <span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
651
+ <output>${typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
652
+ </label>`
555
653
  const fallback = 'default' in node ? node.default : undefined
556
654
  const value = this.valuing[row.path] ?? fallback
557
655
  if (node.kind === 'number') {
@@ -591,6 +689,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
591
689
 
592
690
  private move(group: string, playing: { clip: string; speed: number }) {
593
691
  this.moving = { ...this.moving, [group]: playing }
692
+ this.release(playing.clip)
594
693
  this.tellInstances('motion', Object.fromEntries(Object.entries(this.moving).map(([id, m]) => [id, { ...m, paused: false }])))
595
694
  }
596
695
 
@@ -607,9 +706,30 @@ export class FigurePreview extends localize(i18next)(LitElement) {
607
706
 
608
707
  private drive(clip: string, value: number) {
609
708
  this.driving = { ...this.driving, [clip]: value }
709
+ if (value) this.release(clip)
610
710
  this.tellInstances('clips', this.driving)
611
711
  }
612
712
 
713
+ /*
714
+ A value given here wins over the clips for its control (things-scene's \`parameters\`), so a value left from trying held
715
+ the control still while a clip played (robot dog, 2026-11-03). A clip played lets go of the controls it drives, as
716
+ the detail view does (\`parametersForClip\`).
717
+ */
718
+ private release(clip: string) {
719
+ const asset = this.asset
720
+ if (!asset) return
721
+ const next = parametersForClip(asset, this.giving, clip)
722
+ if (Object.keys(next).length === Object.keys(this.giving).length) return
723
+ this.giving = next
724
+ this.tellInstances('parameters', this.giving)
725
+ }
726
+
727
+ /** Every value tried here let go: the figure stands in its default pose, and its clips move it again. Nothing is saved. */
728
+ private clearTried() {
729
+ this.giving = {}
730
+ this.tellInstances('parameters', this.giving)
731
+ }
732
+
613
733
  private give(id: string, value: number) {
614
734
  this.giving = { ...this.giving, [id]: value }
615
735
  this.tellInstances('parameters', this.giving)
@@ -757,6 +877,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
757
877
  mode: 0
758
878
  }) as SceneHandle
759
879
  this.standing = signature
880
+ this.looking = 'perspective'
760
881
  this.scene.resize?.()
761
882
  this.scene.fit?.('ratio')
762
883
  this.scene.root?.fitCameraToModel?.()
@@ -164,13 +164,16 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
164
164
  min-width: 0;
165
165
  font: inherit;
166
166
  }
167
- label[apex] {
167
+ /* A drawn section's end to one point, a rectangle's top another size: a choice over the whole row. */
168
+ label[apex],
169
+ label[top] {
168
170
  display: flex;
169
171
  align-items: center;
170
172
  gap: 6px;
171
173
  grid-column: 1 / -1;
172
174
  }
173
- label[apex] small {
175
+ label[apex] small,
176
+ label[top] small {
174
177
  flex-basis: 100%;
175
178
  }
176
179
  ol[points] {
@@ -286,7 +289,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
286
289
  if (!this.narrow) this.show = 'outline'
287
290
  })
288
291
 
289
- @state() private working: SectionWorking = { points: [], across: {}, length: 0, apex: null, cavity: null }
292
+ @state() private working: SectionWorking = { points: [], across: {}, length: 0, apex: null, top: null, cavity: null }
290
293
  /** Walls one per side in the inside's drawing; on when they open differing. */
291
294
  @state() private perSide = false
292
295
  @state() private selected: string | null = null
@@ -739,6 +742,35 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
739
742
  : nothing}`
740
743
  }
741
744
 
745
+ /**
746
+ * A rectangle's top another size (ADR-0100 §16.1: a tapered box -- a thigh's cover, a hopper's mouth): on, its width
747
+ * and depth there and one corner radius; off, its top the section again. A box with an inside is not tapered.
748
+ */
749
+ private topFields() {
750
+ const [h, v] = SCREEN[this.axis]
751
+ const top = this.working.top
752
+ const set = (patch: Partial<NonNullable<SectionWorking['top']>>) => this.change({ ...this.working, top: { ...top!, ...patch } })
753
+ return html`<label top>
754
+ <input
755
+ type="checkbox"
756
+ .checked=${live(!!top)}
757
+ ?disabled=${!!this.working.cavity}
758
+ @change=${(ev: Event) => {
759
+ const on = (ev.target as HTMLInputElement).checked
760
+ this.change({ ...this.working, top: on ? (this.opened.top ?? { across: { ...this.working.across }, corner: 0 }) : null })
761
+ }}
762
+ />
763
+ <span>${i18next.t('figure.label.section-editor-top')}</span>
764
+ ${this.working.cavity ? html`<small>${i18next.t('figure.text.section-editor-top-no-inside')}</small>` : nothing}
765
+ </label>
766
+ ${top
767
+ ? html`${([h, v] as const).map(axis =>
768
+ this.field(i18next.t('figure.label.section-editor-top-across', { axis: axis.toUpperCase() }), top.across[axis] ?? 0, value => set({ across: { ...top.across, [axis]: value } }), false, "")
769
+ )}
770
+ ${this.field(i18next.t('figure.label.section-editor-top-corner'), top.corner, value => set({ corner: value }), false, "")}`
771
+ : nothing}`
772
+ }
773
+
742
774
  /** The inside, as the section panel shows it, on the working section: on or off, what it makes, its drawing. */
743
775
  private cavityBlock(invalid: boolean) {
744
776
  const w = this.working
@@ -821,6 +853,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
821
853
  this.noteWords(axis)
822
854
  )
823
855
  )}
856
+ ${this.kind === 'rectangle' ? this.topFields() : nothing}
824
857
  </div>
825
858
  ${this.kind === 'polygon'
826
859
  ? html`<ol points aria-label=${i18next.t('figure.label.section-editor-points')}>
@@ -607,6 +607,12 @@ export interface MotionForm {
607
607
  */
608
608
  followSum?: Array<{ joint: string; multiplier: number }>
609
609
  travelSum?: Extract<NonNullable<FigureJointMotion['travel']>, { sum: unknown }>
610
+ /**
611
+ * The point a turn passes (ADR-0159 \`through\`), in millimetres from the joint frame's origin along its axes: the
612
+ * axis moves there, the part does not. A distance that follows a length is said, not edited, and written back.
613
+ */
614
+ through?: Partial<Record<Axis, number>>
615
+ throughFollows?: NonNullable<FigureJointMotion['through']>
610
616
  }
611
617
 
612
618
  /**
@@ -644,10 +650,26 @@ export function motionFormOf(joint: FigureJoint, k = 0): MotionForm {
644
650
  travel,
645
651
  travelPlus: one?.plus ?? 0,
646
652
  ...(terms && !single ? { followSum: terms.sum } : {}),
647
- ...(travelSum ? { travelSum } : {})
653
+ ...(travelSum ? { travelSum } : {}),
654
+ ...throughFormOf(a?.through)
648
655
  }
649
656
  }
650
657
 
658
+ /** The form's two halves of an axis's point: the millimetres typed, and the distances that follow a length, kept. */
659
+ function throughFormOf(through: FigureJointMotion['through']): Pick<MotionForm, 'through' | 'throughFollows'> {
660
+ if (!through) return {}
661
+ const typed = Object.fromEntries(Object.entries(through).filter(([, d]) => typeof d === 'number')) as Partial<Record<Axis, number>>
662
+ const follows = Object.fromEntries(Object.entries(through).filter(([, d]) => typeof d !== 'number'))
663
+ return { ...(Object.keys(typed).length ? { through: typed } : {}), ...(Object.keys(follows).length ? { throughFollows: follows } : {}) }
664
+ }
665
+
666
+ /** The axis's point as the record keeps it: the kept distances, the typed ones over them, 0 left out. */
667
+ function throughOf(m: MotionForm): FigureJointMotion['through'] {
668
+ const typed = Object.fromEntries(Object.entries(m.through ?? {}).filter(([, d]) => d))
669
+ const through = { ...(m.throughFollows ?? {}), ...typed }
670
+ return Object.keys(through).length ? through : undefined
671
+ }
672
+
651
673
  /**
652
674
  * The axes a part's joint can move with (URDF's mimic): one of another part's that moves the same way — a slide follows
653
675
  * a slide, a turn a turn — and has a control of its own, since a follower follows the axis that is driven (ADR-0159).
@@ -675,7 +697,7 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string, k =
675
697
  ...(was?.id !== undefined ? { id: was.id } : {}),
676
698
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
677
699
  axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
678
- ...(was?.through ? { through: was.through } : {})
700
+ ...(throughOf(m) ? { through: throughOf(m) } : {})
679
701
  }
680
702
  const axes = joint.motion ?? []
681
703
  const given = (motion: Omit<FigureJointMotion, 'type' | 'axis'>) => figureJointWithMotion(joint, [...axes.slice(0, k), { ...moving, ...motion }, ...axes.slice(k + 1)])
@@ -10,6 +10,15 @@ export function offeredPaletteOf(app: Record<string, string>, asset: Record<stri
10
10
  return { ...app, ...asset }
11
11
  }
12
12
 
13
+ /**
14
+ * A colour of the part's own, picked by its value (#ffd400) rather than a palette role: named by that value, so the same
15
+ * colour picked twice is one entry of the asset's palette. A role follows the board's theme; this colour does not.
16
+ */
17
+ export function ownColourOf(hex: string): { token: string; colour: string } {
18
+ const colour = hex.toLowerCase()
19
+ return { token: `colour.${colour.slice(1)}`, colour }
20
+ }
21
+
13
22
  /** The glow at an intensity: 0 is no glow at all; a new glow takes the part's own colour until another is picked. */
14
23
  export function glowOf(material: FigureMaterial, intensity: number): Partial<FigureMaterial> {
15
24
  return { emissive: intensity > 0 ? { token: material.emissive?.token ?? material.token, intensity, on: true } : undefined }
@@ -9,6 +9,7 @@ import {
9
9
  figureGradeOf,
10
10
  figureMaterialKeyOf,
11
11
  figureScoreOf,
12
+ figurePartBudgetOf,
12
13
  figureWorldBoxesOf,
13
14
  figureWritersOf
14
15
  } from '@hatiolab/figure-model'
@@ -38,9 +39,11 @@ import type { FigureCapacity } from './scene-budget.js'
38
39
  *
39
40
  * ## Budgets does not have
40
41
  *
41
- * Two budgets are not measured here, and are not shown as zero:
42
- * - off-palette surfaces: the material has no surface field, so there is nothing to count;
43
- * - part count by detail level: does not carry the detail level (chief architect, 2026-09-24, ADR-0087).
42
+ * One budget is not measured here, and is not shown as zero:
43
+ * - off-palette surfaces: the material has no surface field, so there is nothing to count.
44
+ *
45
+ * The part count against the detail level's limit is measured once the figure declares a level (the level returned,
46
+ * user 2026-09-24, ADR-0087); a figure without one says so.
44
47
  */
45
48
 
46
49
  /*
@@ -135,7 +138,7 @@ export function figureReportOf(asset: FigureAsset): FigureReport | undefined {
135
138
  reading: String(reuse.saved),
136
139
  because: 'figure.text.maturity-reuse-because'
137
140
  },
138
- { key: 'detail', blocked: 'figure.text.maturity-blocked-no-detail-level' },
141
+ detail(asset),
139
142
  { key: 'reactive', blocked: 'figure.text.maturity-blocked-no-binding' },
140
143
  readable(drawn, writers)
141
144
  ]
@@ -158,6 +161,14 @@ export function figureReportOf(asset: FigureAsset): FigureReport | undefined {
158
161
  }
159
162
  }
160
163
 
164
+ /** The parts used of what the declared detail level allows (`figurePartBudgetOf`); unmeasured when no level is declared. */
165
+ function detail(asset: FigureAsset): FigureMaturityAxis {
166
+ const budget = figurePartBudgetOf(asset)
167
+ if (asset.detailLevel === 'UNLIMITED') return { key: 'detail', blocked: 'figure.text.maturity-blocked-unlimited' }
168
+ if (!budget) return { key: 'detail', blocked: 'figure.text.maturity-blocked-no-detail-level' }
169
+ return { key: 'detail', ratio: Math.min(1, budget.used / budget.limit), reading: `${budget.used}/${budget.limit}`, because: 'figure.text.maturity-detail-because' }
170
+ }
171
+
161
172
  /**
162
173
  * The key things-scene shares a body's geometry by (`figure-geometry.ts` `keyOf`): the operation, what is not in its
163
174
  * numbers (the axis, the kind of section, which ends of an inside are open), its numbers to a micrometre, and segments
@@ -43,6 +43,15 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
43
43
  ],
44
44
  apex: { x: 0, z: 0 }
45
45
  }
46
+ case 'pipe':
47
+ /* An elbow: up, a quarter bend toward +X, along. Its section and pieces are changed in its own panel. */
48
+ return {
49
+ id,
50
+ position: { x: 0, y: 0, z: 0 },
51
+ kind: 'pipe',
52
+ dimensions: { radius: 25, wall: 3 },
53
+ path: [{ line: 200 }, { bend: { radius: 100, angle: 90, toward: 90 } }, { line: 200 }]
54
+ }
46
55
  case 'plate':
47
56
  return { ...standing(10), kind: 'box', dimensions: { width: 600, height: 10, depth: 400 } }
48
57
  case 'wall':
@@ -0,0 +1,21 @@
1
+ import { figureSweepTurnOf, type FigureEvaluatedShape } from '@hatiolab/figure-model'
2
+
3
+ /*
4
+ * A pipe's path as its panel says it (ADR-0100 §16.2): its pieces as set-pipe takes them, and each bend's direction as
5
+ * the figure's axis it turns to there. Kept apart from the element so the reading is tested without a browser.
6
+ */
7
+ /** The four quarter directions a bend is offered toward: across the section, from its first axis to its second. */
8
+ export const QUARTERS = [0, 90, 180, 270]
9
+
10
+ /**
11
+ * Which way bend `piece` turns were it `toward` degrees, as the screen says it -- `−X` -- or nothing between the axes:
12
+ * figure-model's reading (`figureSweepTurnOf`), the one a model revising the pipe is told too.
13
+ */
14
+ export const bendWordOf = (sweep: NonNullable<FigureEvaluatedShape['sweep']>, piece: number, toward: number): string =>
15
+ (figureSweepTurnOf(sweep, piece, toward) ?? '').replace('-', '−')
16
+
17
+ /** A path as `set-pipe` takes it, from the evaluated one. */
18
+ export type PanelPiece = { line: number } | { bend: { radius: number; angle: number; toward: number } }
19
+ export const piecesOf = (sweep: NonNullable<FigureEvaluatedShape['sweep']>): PanelPiece[] =>
20
+ sweep.path.map(p => (p.kind === 'line' ? { line: p.length } : { bend: { radius: p.radius, angle: p.angle, toward: p.toward } }))
21
+
@@ -7,7 +7,7 @@
7
7
  * English is for logs and readers of the kernel, and a screen that read it went quiet whenever a sentence changed or a
8
8
  * new one came under the same code (ADR-0054). A refusal with no details the screen knows is said with its code.
9
9
  */
10
- import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureClipLoss, type FigureRefusalDetails, type FigureSizeRangeFit } from '@hatiolab/figure-model'
10
+ import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureAsset, type FigureClipLoss, type FigureRefusalDetails, type FigureSizeRangeFit } from '@hatiolab/figure-model'
11
11
 
12
12
  type Say = (key: string, params: Record<string, string>) => string
13
13
  type Reason = FigureRefusalDetails['reason']
@@ -48,6 +48,25 @@ export function sizeRangeFitsOf(fits: readonly FigureSizeRangeFit[], say: Say):
48
48
  .join(' ')
49
49
  }
50
50
 
51
+ /**
52
+ * Default poses an edit moved because their range no longer held them (a joint's range narrowed or turned the other
53
+ * way, 2026-11-03): each control, from what to what, in one line. A default the author set inside the range is not
54
+ * said; nothing moves in silence (a default that changed unseen, proposal 17).
55
+ */
56
+ export function defaultsMovedOf(before: FigureAsset | undefined, after: FigureAsset | undefined, say: Say): string {
57
+ if (!before || !after) return ''
58
+ return after.document.model.inputs
59
+ .filter(i => i.role === 'state')
60
+ .flatMap(i => {
61
+ const from = before.stateDefaults[i.id],
62
+ to = after.stateDefaults[i.id]
63
+ if (from === undefined || to === undefined || from === to) return []
64
+ if ((i.min === undefined || from >= i.min) && (i.max === undefined || from <= i.max)) return []
65
+ return [say('figure.text.default-moved-to-end', { control: after.stateInputs?.[i.id]?.label ?? i.id, from: numberOf(from), to: numberOf(to) })]
66
+ })
67
+ .join(' ')
68
+ }
69
+
51
70
  /**
52
71
  * A placement refused because a part is fastened to the mounting plane in a way the new plane does not take (standing
53
72
  * on the floor, when the figure is to hang on a wall), said in the author's words with the parts it names.
@@ -99,6 +118,36 @@ export function placedRefusalOf(error: unknown, part: string, say: Say): string
99
118
  return null
100
119
  }
101
120
 
121
+ /** A part the edit cannot move because a joint or a fastening places it, named by the part the kernel gave. */
122
+ function placedByRefusalOf(error: unknown, say: Say): string | null {
123
+ const part = (detailsOf(error, 'placed-by-joint') ?? detailsOf(error, 'placed-by-fastening'))?.part
124
+ return part === undefined ? null : placedRefusalOf(error, part, say)
125
+ }
126
+
127
+ /** A mirrored copy not made (figure-model's mirror-part): why, for the part it was asked of. */
128
+ const MIRROR_REFUSALS = {
129
+ 'mirror-on-plane': 'figure.text.mirror-would-overlap',
130
+ 'mirror-not-symmetric': 'figure.text.mirror-not-symmetric',
131
+ 'mirror-turned-parent': 'figure.text.mirror-turned-parent',
132
+ 'mirror-surface': 'figure.text.mirror-surface',
133
+ 'mirror-unplaced': 'figure.text.mirror-unplaced',
134
+ 'mirror-unmeasured': 'figure.text.mirror-unplaced',
135
+ 'mirror-keep-needs-zero': 'figure.text.mirror-keep-needs-zero'
136
+ } as const
137
+ function mirrorRefusalOf(error: unknown, say: Say): string | null {
138
+ for (const [reason, key] of Object.entries(MIRROR_REFUSALS)) {
139
+ const found = detailsOf(error, reason as keyof typeof MIRROR_REFUSALS)
140
+ if (found) return say(key, { part: found.part })
141
+ }
142
+ return null
143
+ }
144
+
145
+ /** A value the edit names that the figure does not declare: the value, by its id (the refusal's path). */
146
+ function unknownInputRefusalOf(error: unknown, say: Say): string | null {
147
+ if (codeOf(error) !== 'UNKNOWN_INPUT') return null
148
+ return say('figure.text.unknown-input', { input: (error as { path?: string })?.path ?? '' })
149
+ }
150
+
102
151
  /** A part name refused: not a name, already used, or another part is named under the part. The rule is the kernel's. */
103
152
  export function partNameRefusalOf(error: unknown, say: Say): string | null {
104
153
  /* A new part named as one already is: the same sentence as a part renamed so. */
@@ -222,6 +271,18 @@ export function sessionRefusalOf(error: unknown, say: Say): string | null {
222
271
  return key ? say(key, {}) : null
223
272
  }
224
273
 
274
+ /**
275
+ * A pipe refused (ADR-0100 §16.2): a bend tighter than its section -- with the least radius it takes --, a wall that
276
+ * leaves no inside, a path through itself. Nothing for any other refusal.
277
+ */
278
+ export function pipeRefusalOf(error: unknown, say: Say): string | null {
279
+ const tight = detailsOf(error, 'sweep-bend-tight')
280
+ if (tight) return say('figure.text.pipe-bend-tight', { piece: String(tight.piece + 1), least: String(Math.ceil(tight.least)) })
281
+ if (detailsOf(error, 'sweep-wall')) return say('figure.text.pipe-wall-too-thick', {})
282
+ if (detailsOf(error, 'sweep-self-crossing')) return say('figure.text.pipe-self-crossing', {})
283
+ return null
284
+ }
285
+
225
286
  export function editRefusalOf(error: unknown, say: Say): string {
226
287
  return (
227
288
  sessionRefusalOf(error, say) ??
@@ -230,9 +291,13 @@ export function editRefusalOf(error: unknown, say: Say): string {
230
291
  repeatPitchRefusalOf(error, say) ??
231
292
  rowLimitRefusalOf(error, say) ??
232
293
  placementRefusalOf(error, say) ??
294
+ placedByRefusalOf(error, say) ??
295
+ mirrorRefusalOf(error, say) ??
296
+ unknownInputRefusalOf(error, say) ??
233
297
  partNameRefusalOf(error, say) ??
234
298
  documentLimitRefusalOf(error, say) ??
235
299
  sectionRefusalOf(error, say) ??
300
+ pipeRefusalOf(error, say) ??
236
301
  slotLayoutRefusalOf(error, say) ??
237
302
  dataMotionRefusalOf(error, say) ??
238
303
  say('figure.text.edit-refused', { code: codeOf(error) ?? '' })
@@ -53,7 +53,8 @@ export function cavityRequestOf(shape: FigureEvaluatedShape, patch: Partial<Figu
53
53
 
54
54
  /** The most a rectangle's corners can reach: half its narrower side (the kernel's CORNER_TOO_LARGE says where). */
55
55
  export function cornerLimitOf(shape: FigureEvaluatedShape): number | undefined {
56
- const e = shape.extrusion
56
+ /* A tapered box's bottom: its corners are its first section's (ADR-0100 §16.1). */
57
+ const e = shape.extrusion ?? (shape.loft?.to ? shape.loft : undefined)
57
58
  if (e?.section.kind !== 'rectangle') return undefined
58
59
  const [u, v] = FIGURE_SECTION_PLANE[e.axis]
59
60
  const far = 2 * Math.max(...acrossOf(e))
@@ -106,6 +106,8 @@ export interface SectionWorking {
106
106
  length: number
107
107
  /** A drawn section's end drawn to one point (a pyramid), across the plane axes; null for a push. */
108
108
  apex: Partial<Record<Axis, number>> | null
109
+ /** A rectangle's top another size across and one corner radius: a tapered box (ADR-0100 §16.1); null for a push. */
110
+ top: { across: Partial<Record<Axis, number>>; corner: number } | null
109
111
  /** A pushed rectangle's or ellipse's inside, every side's wall written out; null for none or a drawn section. */
110
112
  cavity: { wall: Record<string, number>; start: FigureCavityRequest['start']; end: FigureCavityRequest['end'] } | null
111
113
  }
@@ -115,7 +117,7 @@ export function drawnOf(shape: FigureEvaluatedShape):
115
117
  | { axis: Axis; section: FigureSection; start: number; end: number; apex: Partial<Record<Axis, number>> | null }
116
118
  | undefined {
117
119
  if (shape.extrusion) return { ...shape.extrusion, apex: null }
118
- if (shape.loft) return { ...shape.loft, apex: shape.loft.apex }
120
+ if (shape.loft) return { ...shape.loft, apex: shape.loft.apex ?? null }
119
121
  return undefined
120
122
  }
121
123
 
@@ -143,10 +145,19 @@ export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
143
145
  across,
144
146
  length: e.end - e.start,
145
147
  apex: e.apex ? { ...e.apex } : null,
148
+ top: topOf(shape),
146
149
  cavity: c ? { wall: { ...c.wall }, start: c.start, end: c.end } : null
147
150
  }
148
151
  }
149
152
 
153
+ /** A tapered box's top as the editor holds it: its size across and its one corner radius. */
154
+ function topOf(shape: FigureEvaluatedShape): SectionWorking['top'] {
155
+ const t = shape.loft?.to
156
+ if (t?.kind !== 'rectangle') return null
157
+ const [u, v] = FIGURE_SECTION_PLANE[shape.loft!.axis]
158
+ return { across: { [u]: t.size[u]!, [v]: t.size[v]! }, corner: t.corner?.[u] ?? 0 }
159
+ }
160
+
150
161
  /**
151
162
  * The part as the working state would make it: its section, its length from the same start, its inside -- what the
152
163
  * inside's drawing reads, so its walls are judged against the widths being typed and not the ones the editor opened with.
@@ -182,6 +193,12 @@ export function workingIssuesOf(
182
193
  const words = issues.map(i => issueWords(i, w.points, e.axis))
183
194
  if (!(Number.isFinite(w.length) && w.length > 0)) words.push({ key: 'figure.text.section-issue-length', params: {} })
184
195
  if (w.apex && !Object.values(w.apex).every(n => Number.isFinite(n))) words.push({ key: 'figure.text.section-issue-number', params: {} })
196
+ if (w.top) {
197
+ /* The top is a section of its own, checked by the same rule (ADR-0100 §16.1). */
198
+ const [u, v] = FIGURE_SECTION_PLANE[e.axis]
199
+ const top: FigureSection = { kind: 'rectangle', size: { [u]: w.top.across[u]!, [v]: w.top.across[v]! }, ...(w.top.corner > 0 ? { corner: { [u]: w.top.corner, [v]: w.top.corner } } : {}) }
200
+ if (checkFigureSection(top, e.axis).length || !(w.top.corner >= 0)) words.push({ key: 'figure.text.section-issue-top', params: {} })
201
+ }
185
202
  if (!issues.length && words.length === 0 && w.cavity) {
186
203
  const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [{ id: 'inside', ...w.cavity }])
187
204
  if ('refused' in made) {
@@ -205,6 +222,7 @@ export function sectionActionOf(part: string, kind: FigureSection['kind'] | unde
205
222
  const across = Object.fromEntries(Object.entries(w.across).filter(([k, v]) => changed(v, opened.across[k as Axis])))
206
223
  const apexMoved = !w.apex !== !opened.apex || (!!w.apex && Object.entries(w.apex).some(([k, v]) => changed(v, opened.apex![k as Axis])))
207
224
  const cavityMoved = JSON.stringify(cavityKeyOf(w)) !== JSON.stringify(cavityKeyOf(opened))
225
+ const topMoved = JSON.stringify(w.top) !== JSON.stringify(opened.top)
208
226
  return {
209
227
  kind: 'set-section',
210
228
  part,
@@ -212,6 +230,7 @@ export function sectionActionOf(part: string, kind: FigureSection['kind'] | unde
212
230
  ...(Object.keys(across).length ? { across } : {}),
213
231
  ...(changed(w.length, opened.length) ? { length: w.length } : {}),
214
232
  ...(apexMoved ? { apex: w.apex ? { ...w.apex } : null } : {}),
233
+ ...(topMoved ? { top: w.top ? { across: { ...w.top.across }, corner: w.top.corner } : null } : {}),
215
234
  ...(cavityMoved ? { cavity: w.cavity ? { wall: { ...w.cavity.wall }, start: w.cavity.start, end: w.cavity.end } : null } : {})
216
235
  }
217
236
  }