@things-factory/figure-ui 10.1.205 → 10.1.207

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/editor-styles.ts +16 -0
  2. package/client/modeller/editor.ts +53 -137
  3. package/client/modeller/fasten-form.ts +50 -11
  4. package/client/modeller/figure-facts.ts +1 -1
  5. package/client/modeller/figure-fasten-dialog.ts +3 -2
  6. package/client/modeller/figure-history-panel.ts +1 -29
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +10 -12
  9. package/client/modeller/figure-verdict.ts +2 -17
  10. package/client/modeller/gizmo.ts +10 -0
  11. package/client/modeller/keys.ts +10 -1
  12. package/client/modeller/links.ts +32 -41
  13. package/client/modeller/maturity.ts +11 -27
  14. package/client/modeller/palette.ts +1 -1
  15. package/client/modeller/product-session.ts +16 -4
  16. package/client/modeller/refusal-teller.ts +3 -25
  17. package/client/modeller/refusals.ts +20 -33
  18. package/client/pages/figure-modeller-page.ts +7 -22
  19. package/dist-client/modeller/editor-styles.js +16 -0
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +8 -11
  22. package/dist-client/modeller/editor.js +57 -138
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +16 -7
  25. package/dist-client/modeller/fasten-form.js +45 -11
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-facts.js +1 -1
  28. package/dist-client/modeller/figure-facts.js.map +1 -1
  29. package/dist-client/modeller/figure-fasten-dialog.js +3 -3
  30. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  31. package/dist-client/modeller/figure-history-panel.d.ts +0 -16
  32. package/dist-client/modeller/figure-history-panel.js +1 -27
  33. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  34. package/dist-client/modeller/figure-links.js +2 -2
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/figure-preview.js +8 -11
  37. package/dist-client/modeller/figure-preview.js.map +1 -1
  38. package/dist-client/modeller/figure-verdict.d.ts +0 -5
  39. package/dist-client/modeller/figure-verdict.js +2 -21
  40. package/dist-client/modeller/figure-verdict.js.map +1 -1
  41. package/dist-client/modeller/gizmo.d.ts +6 -0
  42. package/dist-client/modeller/gizmo.js +11 -0
  43. package/dist-client/modeller/gizmo.js.map +1 -1
  44. package/dist-client/modeller/keys.d.ts +1 -1
  45. package/dist-client/modeller/keys.js +6 -0
  46. package/dist-client/modeller/keys.js.map +1 -1
  47. package/dist-client/modeller/links.d.ts +16 -22
  48. package/dist-client/modeller/links.js +26 -39
  49. package/dist-client/modeller/links.js.map +1 -1
  50. package/dist-client/modeller/maturity.d.ts +2 -4
  51. package/dist-client/modeller/maturity.js +12 -26
  52. package/dist-client/modeller/maturity.js.map +1 -1
  53. package/dist-client/modeller/palette.js +1 -1
  54. package/dist-client/modeller/palette.js.map +1 -1
  55. package/dist-client/modeller/product-session.d.ts +15 -13
  56. package/dist-client/modeller/product-session.js +15 -5
  57. package/dist-client/modeller/product-session.js.map +1 -1
  58. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  59. package/dist-client/modeller/refusal-teller.js +3 -22
  60. package/dist-client/modeller/refusal-teller.js.map +1 -1
  61. package/dist-client/modeller/refusals.d.ts +7 -17
  62. package/dist-client/modeller/refusals.js +17 -34
  63. package/dist-client/modeller/refusals.js.map +1 -1
  64. package/dist-client/pages/figure-modeller-page.d.ts +1 -3
  65. package/dist-client/pages/figure-modeller-page.js +7 -23
  66. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  67. package/dist-client/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +7 -7
  69. package/test/browser/acceptance-page.js +4 -4
  70. package/test/browser/detail-page.js +1 -3
  71. package/test/browser/detail-tabs-page.js +1 -2
  72. package/test/browser/links.check.mjs +32 -50
  73. package/test/browser/modeller.check.mjs +46 -19
  74. package/test/browser/parts-page.js +2 -2
  75. package/test/browser/parts.check.mjs +51 -12
  76. package/test/browser/product-page.check.mjs +6 -9
  77. package/test/browser/product-page.js +1 -2
  78. package/test/browser/screen-page.js +2 -6
  79. package/test/browser/screen.check.mjs +8 -65
  80. package/test/browser/section.check.mjs +27 -0
  81. package/test/fasten-form.test.ts +42 -2
  82. package/test/first-minute.test.ts +2 -3
  83. package/test/fixtures/joint-column.json +1 -66
  84. package/test/fixtures/joint-roller.json +1 -66
  85. package/test/fixtures/robot-arm-6dof.json +2 -67
  86. package/test/gizmo.test.ts +12 -1
  87. package/test/keys.test.ts +11 -0
  88. package/test/links.test.ts +39 -34
  89. package/test/parts-wiring.test.ts +1 -1
  90. package/test/properties.test.ts +9 -12
  91. package/test/refusals.test.ts +20 -42
  92. package/test/resize-with-placement.test.ts +13 -15
  93. package/test/revision.test.ts +1 -2
  94. package/test/verdict.test.ts +1 -15
  95. package/translations/en.json +8 -32
  96. package/translations/es.json +8 -32
  97. package/translations/ja.json +8 -32
  98. package/translations/ko.json +8 -32
  99. package/translations/ms.json +8 -32
  100. package/translations/zh.json +8 -32
  101. package/client/modeller/occupancy.ts +0 -51
  102. package/dist-client/modeller/occupancy.d.ts +0 -31
  103. package/dist-client/modeller/occupancy.js +0 -36
  104. package/dist-client/modeller/occupancy.js.map +0 -1
  105. package/test/release-panel.test.ts +0 -53
@@ -3,14 +3,14 @@ import { customElement, property, query, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { compileFigureAsset } from '@hatiolab/figure-model'
6
- import type { FigureAsset, FigureDataRow, FigureDataValue } from '@hatiolab/figure-model'
6
+ import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
7
7
  import { create, 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'
11
11
  import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
12
12
  import { derivedOf, type FigureRevision } from './revision.js'
13
- import { authoredSizeOf, occupancyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeRangeOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
13
+ import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
14
14
  import { previewSizeWordsOf } from './preview-size.js'
15
15
 
16
16
  /**
@@ -331,11 +331,9 @@ export class FigurePreview extends localize(i18next)(LitElement) {
331
331
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
332
332
  private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
333
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
334
  return html`<div sizes>
337
335
  ${(['x', 'y', 'z'] as const).map(axis => {
338
- const range = sizeRangeOf(asset, axis)
336
+ const range = sizeSpanOf(asset, axis)
339
337
  if (!range) return nothing
340
338
  return html`<label>
341
339
  <span>${i18next.t(`figure.label.measure-${axis}`)}</span>
@@ -364,8 +362,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
364
362
  if (used === undefined || !this.trying) return
365
363
  this.trying = { ...this.trying, [axis]: used }
366
364
  const instance = this.scene?.root?.components?.[0] as { model?: { width?: number; height?: number }; set(props: Record<string, unknown>): void } | undefined
367
- /* The instance's box is the space the figure takes at the sizes tried, not the sizes (occupancyAtSize). */
368
- const box = occupancyAtSize(asset!, this.trying) ?? this.trying
365
+ /* The instance's box is the body at the sizes tried, not the sizes (bodyAtSize). */
366
+ const box = bodyAtSize(asset!, this.trying) ?? this.trying
369
367
  instance?.set({ width: box.x, depth: box.y, height: box.z })
370
368
  const { width = 0, height = 0 } = instance?.model ?? {}
371
369
  instance?.set({ left: this.board.width / 2 - width / 2, top: this.board.height / 2 - height / 2 })
@@ -377,7 +375,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
377
375
  this.scene?.root?.fitCameraToModel?.()
378
376
  }
379
377
 
380
- private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
378
+ private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
381
379
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
382
380
  return html`<div clips>
383
381
  ${changed.length
@@ -600,8 +598,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
600
598
  this.valuing = {}
601
599
  registerFigure(type, asset)
602
600
  this.registered = type
603
- /* Each instance stands in the space the figure takes at the size it is asked (occupancyAtSize), as a board reads it. */
604
- const boxOf = (asked: { x: number; y: number; z: number }) => occupancyAtSize(asset, asked) ?? asked
601
+ /* Each instance stands in its body at the size it is asked (bodyAtSize), as a board reads it. */
602
+ const boxOf = (asked: { x: number; y: number; z: number }) => bodyAtSize(asset, asked) ?? asked
605
603
  const { components, board } = this.way === 'one' ? previewOneInstance(type, boxOf(size)) : previewInstances(type, size, boxOf)
606
604
  this.board = board
607
605
  this.trying = this.way === 'one' ? { ...size } : undefined
@@ -609,8 +607,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
609
607
  type: 'model-layer',
610
608
  width: board.width,
611
609
  height: board.height,
612
- /* As tall as the figure may grow: twice for the eight, the declared height range's top for the one. */
613
- 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),
610
+ /* As tall as the figure may grow: twice for the eight, the top of the height slider for the one. */
611
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeSpanOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
614
612
  threed: true,
615
613
  sky: 'color',
616
614
  ...sceneLook(view),
@@ -392,8 +392,6 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
392
392
 
393
393
  /** The report over the source being edited. Absent when nothing is drawn, or the format refuses the source. */
394
394
  @state() private report?: FigureReport
395
- /** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
396
- @state() private bodies = 0
397
395
  /** Whether the trend chart is open at its own size. */
398
396
  @state() private expanded = false
399
397
  @state() private inspection?: FigureInspection
@@ -428,9 +426,8 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
428
426
  }
429
427
 
430
428
  private measure() {
431
- const { report, bodies, told } = verdictMeasureOf(this.revision?.asset)
429
+ const { report, told } = verdictMeasureOf(this.revision?.asset)
432
430
  this.report = report
433
- this.bodies = bodies
434
431
  if (!report) this.expanded = false
435
432
  this.dispatchEvent(new CustomEvent('capacity-changed', { detail: told }))
436
433
  }
@@ -766,7 +763,7 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
766
763
  ${blocking.length
767
764
  ? html`<ul gate>
768
765
  <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
769
- ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code), this.partNames()))}
766
+ ${blocking.map(finding => renderGateFinding(finding, true, nothing, this.partNames()))}
770
767
  </ul>`
771
768
  : nothing}
772
769
  ${advisory.length
@@ -782,16 +779,4 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
782
779
  private partNames(): string[] {
783
780
  return (this.revision?.asset.appearance ?? []).map(look => look.target)
784
781
  }
785
-
786
- /** The same way out the release panel offers for the same two findings — declare the space from the parts. */
787
- private actionsFor(code: string) {
788
- if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
789
- /* A new figure with no parts is refused as undeclared too, but fitting needs parts to measure (seen 2026-09-24). */
790
- if (!this.bodies) return nothing
791
- return html`<button fit @click=${this.fit}>${i18next.t('figure.button.fit-base-to-parts')}</button>`
792
- }
793
-
794
- private fit = () => {
795
- this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
796
- }
797
782
  }
@@ -111,6 +111,16 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
111
111
  }
112
112
 
113
113
  /** Where a drag left the part, in the asset frame: millimetres, degrees and per-axis scale. */
114
+ /**
115
+ * The size handles that change one measure (ADR-0140 4.1): a round part's radius across is one value, so its X and Z
116
+ * handles are the same diameter, and a sphere's three are one. Said on the canvas while the size tool is on, to see how
117
+ * it handles before deciding to show fewer. Nothing for a part whose handles each change their own measure.
118
+ */
119
+ export function sameMeasureAxesOf(reach: GizmoReach): Array<'x' | 'y' | 'z'> | undefined {
120
+ if (!reach.scale || !reach.round) return undefined
121
+ return reach.round.along.length ? ['x', 'z'] : ['x', 'y', 'z']
122
+ }
123
+
114
124
  export interface GizmoResult {
115
125
  position?: [number, number, number]
116
126
  rotation?: [number, number, number]
@@ -5,7 +5,13 @@
5
5
  * Typing is never taken: a key pressed in a field belongs to the field -- Backspace in a name box deletes a letter,
6
6
  * not the part.
7
7
  */
8
- export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | GizmoKey
8
+ /*
9
+ * `copy` · `paste` are the modeller's own: a part copied and pasted is the kernel's duplicate, with a name of its own.
10
+ * Left to the scene, Ctrl+C / Ctrl+V copied the canvas object with the part's name -- two objects of one name, the copy
11
+ * moving the original (user 2026-10-21). `hold` is a key kept from the scene that the modeller has no use for: the
12
+ * scene's cut and bulk paste act on canvas objects alone and leave the figure out of step with them.
13
+ */
14
+ export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | 'copy' | 'paste' | 'hold' | GizmoKey
9
15
 
10
16
  /** The gizmo's modes on W, E and R, as the scene has them. */
11
17
  export type GizmoKey = 'translate' | 'rotate' | 'scale'
@@ -36,6 +42,9 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
36
42
  const key = e.key.toLowerCase()
37
43
  if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
38
44
  if (mod && key === 'y') return 'redo'
45
+ if (mod && key === 'c') return selected ? 'copy' : 'hold'
46
+ if (mod && key === 'v') return e.shiftKey ? 'hold' : 'paste'
47
+ if (mod && key === 'x') return 'hold'
39
48
  if (!selected) return null
40
49
  if (mod && key === 'd') return 'duplicate'
41
50
  if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
@@ -1,5 +1,5 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
- import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeRangeOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureFollow, FigureInstanceSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureSurfacePlace } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
5
  import { boxOf } from './align.js'
@@ -733,53 +733,48 @@ export const holdsThings = (asset: FigureAsset) => (asset.document.capabilities.
733
733
  export const acceptsOf = (typed: string) => [...new Set(typed.split(',').map(one => one.trim()).filter(Boolean))]
734
734
 
735
735
  /**
736
- * The sizes a board may give this figure: the range it declares now, and the range measured afresh -- the default
737
- * (1 mm to ten times the authored size) narrowed only where the figure cannot follow, by figure-model's own functions.
738
- * A range measured when a part filled what the others left (ROBOT_ARM: Y from 1,854 mm) stays narrow after the part is
739
- * made to grow with the figure; measuring again gives the figure its whole range back. Nothing when no size is declared.
740
- */
741
- export function instanceRangeOf(asset: FigureAsset): { declared: FigureSizeRange; measured: FigureSizeRange } | undefined {
742
- const inputs = modelOf(asset).inputs
743
- const declared = {} as FigureSizeRange
744
- const authored = {} as Record<Axis, number>
745
- for (const axis of AXES) {
746
- const input = inputs.find(i => i.id === `size.${axis}` && i.role === 'design')
747
- const at = asset.designInputs[`size.${axis}`]
748
- if (!input || typeof at !== 'number') return undefined
749
- declared[axis] = { min: input.min ?? 0, max: input.max ?? Infinity }
750
- authored[axis] = at
751
- }
752
- const start = defaultInstanceSizeRange(authored)
753
- const measured = foldInstanceSizeRange(start, figureInstanceSizeLimitsOf(asset, start)).range
754
- return { declared, measured }
736
+ * The sizes a board may give this figure, per axis: the ends its author narrowed, and the rest as far as its parts
737
+ * allow, computed by figure-model (`figureInstanceSizeRangeOf`, ADR-0087 decision 6) -- the screen does not measure
738
+ * on its own. Nothing when no size is declared.
739
+ */
740
+ export function instanceRangeOf(asset: FigureAsset): Record<Axis, FigureInstanceSizeRange> | undefined {
741
+ const range = figureInstanceSizeRangeOf(asset)
742
+ return AXES.every(axis => range[axis]) ? (range as Record<Axis, FigureInstanceSizeRange>) : undefined
755
743
  }
756
744
 
757
745
  /**
758
- * The size range to declare for a figure that has none yet: its parts' extent now as the size it is authored at, and
759
- * as wide as its parts allow around it (the same measure 「다시 재기」 uses). Undefined when the figure already has a
760
- * size or has nothing to measure. Without a declared size a board cannot resize the figure, and no part is offered
761
- * to follow its size -- every figure made in the modeller was left there (user, 2026-09-25).
746
+ * The size to declare for a figure that has none yet: its parts' extent now, the size it is authored at. Undefined when
747
+ * the figure already has a size or has nothing to measure. Without a declared size a board cannot resize the figure,
748
+ * and no part is offered to follow its size -- every figure made in the modeller was left there (user, 2026-09-25).
762
749
  */
763
- export function instanceRangeToDeclare(asset: FigureAsset): { range: FigureSizeRange; at: Record<Axis, number> } | undefined {
750
+ export function instanceSizeToDeclare(asset: FigureAsset): { at: Record<Axis, number> } | undefined {
764
751
  if (instanceRangeOf(asset) || !asset.appearance.length) return undefined
765
752
  /* The parts as they rest -- not the room their joints swing them through, which declared a door 8208 mm every way. */
766
753
  const at = figurePartsExtentOf(asset) as Record<Axis, number>
767
754
  if (AXES.some(axis => !(at[axis] > 0))) return undefined
768
- const start = defaultInstanceSizeRange(at)
769
- return { range: foldInstanceSizeRange(start, figureInstanceSizeLimitsOf(asset, start)).range, at }
755
+ return { at }
770
756
  }
771
757
 
772
- /** Whether measuring again would change the declared range (to a micrometre). */
773
758
  /**
774
- * The declared range with one end typed by the author: the sizes a figure may be placed at are a claim about the
775
- * product (a gantry spans metres, not 30 cm to 29 m), and measuring only ever widens them. Nothing when the typed
776
- * value is not a number; whether the figure holds there is the command's to say.
759
+ * The ends the author narrowed, with one end typed or cleared: a number narrows that end, an empty field opens it
760
+ * again to what the parts allow. Whether the figure holds there is the command's to say.
777
761
  */
778
- export function rangeWith(declared: FigureSizeRange, axis: Axis, bound: 'min' | 'max', value: number): FigureSizeRange | undefined {
779
- if (!Number.isFinite(value)) return undefined
780
- const next = structuredClone(declared)
781
- next[axis][bound] = value
782
- return next
762
+ export function rangeWith(
763
+ range: Record<Axis, FigureInstanceSizeRange>,
764
+ axis: Axis,
765
+ bound: 'min' | 'max',
766
+ value: number
767
+ ): Partial<Record<Axis, { min?: number; max?: number }>> {
768
+ const out: Partial<Record<Axis, { min?: number; max?: number }>> = {}
769
+ for (const a of AXES) {
770
+ const ends: { min?: number; max?: number } = {}
771
+ for (const b of ['min', 'max'] as const) {
772
+ const typed = a === axis && b === bound
773
+ if (typed ? Number.isFinite(value) : range[a].narrowed[b]) ends[b] = typed ? value : range[a][b]
774
+ }
775
+ if (Object.keys(ends).length) out[a] = ends
776
+ }
777
+ return out
783
778
  }
784
779
 
785
780
  /**
@@ -807,10 +802,6 @@ export function valueNow(revision: FigureRevision, ref: string | undefined): num
807
802
  }
808
803
  }
809
804
 
810
- export function rangeDiffers(a: FigureSizeRange, b: FigureSizeRange): boolean {
811
- return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
812
- }
813
-
814
805
  /**
815
806
  * Whether the figure is a mechanism -- a part turns about a joint. Such a figure should grow by one ratio (chief
816
807
  * architect's ruling 2026-09-24); the modeller proposes it, the author declares it.
@@ -137,7 +137,7 @@ export function figureReportOf(asset: FigureAsset): FigureReport | undefined {
137
137
  },
138
138
  { key: 'detail', blocked: 'figure.text.maturity-blocked-no-detail-level' },
139
139
  { key: 'reactive', blocked: 'figure.text.maturity-blocked-no-binding' },
140
- readable(asset, plan, drawn, writers)
140
+ readable(drawn, writers)
141
141
  ]
142
142
 
143
143
  return {
@@ -229,13 +229,10 @@ const roundFootprint = (shape: { extrusion?: { axis: string; section: { kind: st
229
229
  * finer segment count does not raise it: H = −Σ p·log p over each material's share of the footprint plus the
230
230
  * background, divided by log(`LIMITS.distinctMaterials` + 1), the most this measure can reach.
231
231
  *
232
- * The footprint is the base box: the space the asset declares when it declares one (ADR-0044's box is the model's
233
- * occupancy), otherwise the drawn bodies' own extent. Bodies are their axis-aligned world boxes
232
+ * The footprint is the drawn bodies' own extent, the box a board gives it (ADR-0145). Bodies are their axis-aligned world boxes
234
233
  * (`figureWorldBoxesOf`, the gate's reading); the higher one covers the lower.
235
234
  */
236
235
  function readable(
237
- asset: FigureAsset,
238
- plan: ReturnType<typeof compileFigureDocument>,
239
236
  drawn: FigureAssetGeometry[],
240
237
  writers: Map<string, string>
241
238
  ): FigureMaturityAxis {
@@ -245,20 +242,12 @@ function readable(
245
242
  .map(body => ({ body, box: figureWorldBoxesOf([body], writers)[0]! }))
246
243
  if (placed.length === 0) return { key: 'readable', blocked: 'figure.text.maturity-blocked-no-parts' }
247
244
 
248
- let frame: { x0: number; x1: number; z0: number; z1: number }
249
- if (asset.occupancy) {
250
- const values = plan.evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values
251
- const at = (ref: string) => values[ref] as number
252
- const b = asset.occupancy.bounds
253
- frame = { x0: at(b.x.min), x1: at(b.x.max), z0: at(b.z.min), z1: at(b.z.max) }
254
- } else {
255
- frame = { x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity }
256
- for (const { box } of placed) {
257
- frame.x0 = Math.min(frame.x0, box.centre.x - box.extent.x / 2)
258
- frame.x1 = Math.max(frame.x1, box.centre.x + box.extent.x / 2)
259
- frame.z0 = Math.min(frame.z0, box.centre.z - box.extent.z / 2)
260
- frame.z1 = Math.max(frame.z1, box.centre.z + box.extent.z / 2)
261
- }
245
+ const frame = { x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity }
246
+ for (const { box } of placed) {
247
+ frame.x0 = Math.min(frame.x0, box.centre.x - box.extent.x / 2)
248
+ frame.x1 = Math.max(frame.x1, box.centre.x + box.extent.x / 2)
249
+ frame.z0 = Math.min(frame.z0, box.centre.z - box.extent.z / 2)
250
+ frame.z1 = Math.max(frame.z1, box.centre.z + box.extent.z / 2)
262
251
  }
263
252
 
264
253
  const regions = topViewRegions(placed, frame)
@@ -417,26 +406,21 @@ export interface FigureBudgetLine {
417
406
  }
418
407
 
419
408
  /**
420
- * What the verdict tab measures of the figure on screen once it settles: the report, the bodies there are to fit the
421
- * space to (drawn or hidden -- a hidden part is still a part to measure), and what it tells the page: the count for its
422
- * tab and the parts list's line, from the same report. A source the format refuses has no report.
409
+ * What the verdict tab measures of the figure on screen once it settles: the report, and what it tells the page: the
410
+ * count for its tab and the parts list's line, from the same report. A source the format refuses has no report.
423
411
  */
424
412
  export function verdictMeasureOf(asset: FigureAsset | undefined): {
425
413
  report?: FigureReport
426
- bodies: number
427
414
  told: { instances?: number; budget?: FigureBudgetLine }
428
415
  } {
429
416
  let report: FigureReport | undefined
430
- let bodies = 0
431
417
  try {
432
- const cost = asset ? figureCostOf(asset) : undefined
433
- bodies = cost ? cost.groups + cost.hidden : 0
434
418
  report = asset ? figureReportOf(asset) : undefined
435
419
  } catch {
436
420
  /* A source the format refuses has no report; the server's finding says why. */
437
421
  report = undefined
438
422
  }
439
- return { report, bodies, told: { instances: report?.capacity.instances, budget: budgetOf(report) } }
423
+ return { report, told: { instances: report?.capacity.instances, budget: budgetOf(report) } }
440
424
  }
441
425
 
442
426
  export function budgetOf(report: FigureReport | undefined): FigureBudgetLine | undefined {
@@ -16,7 +16,7 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
16
16
  case 'rounded-box':
17
17
  return { ...standing(100), kind, dimensions: { width: 100, height: 100, depth: 100, round: 10 } }
18
18
  case 'sphere':
19
- return { ...standing(100), kind, dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 } }
19
+ return { ...standing(100), kind, dimensions: { radius: 50, radiusY: 50 } }
20
20
  case 'frustum':
21
21
  return { ...standing(100), kind, dimensions: { radiusTop: 0, radiusBottom: 50, height: 100 } }
22
22
  case 'polygon':
@@ -5,6 +5,8 @@ import {
5
5
  createFigureEditorWorkspace,
6
6
  serializeFigureAsset,
7
7
  parseFigureAsset,
8
+ figureSizeRangeFitsOf,
9
+ type FigureSizeRangeFit,
8
10
  type FigureInputChange,
9
11
  type FigurePrimitiveCreation,
10
12
  type FigurePartEdit,
@@ -71,10 +73,14 @@ export function createFigureProductSession(store: FigureProductStore) {
71
73
  */
72
74
  let done: Array<string | undefined> = [],
73
75
  undone: Array<string | undefined> = []
74
- function tracked<T>(kind: 'do' | 'undo' | 'redo', run: () => T, tag?: string): T {
75
- const before = workspace.read().revision
76
+ /* The author's size ends the last step moved without being asked (ADR-0087 decision 6), told once. */
77
+ let fitted: FigureSizeRangeFit[] = []
78
+ function tracked<T>(kind: 'do' | 'undo' | 'redo', run: () => T, tag?: string, setsRange = false): T {
79
+ const was = workspace.read()
76
80
  const result = run()
77
- if (workspace.read().revision === before) return result
81
+ const now = workspace.read()
82
+ if (now.revision === was.revision) return result
83
+ fitted = kind === 'do' && !setsRange && was.asset && now.asset ? figureSizeRangeFitsOf(was.asset, now.asset) : []
78
84
  if (kind === 'do') {
79
85
  done.push(tag)
80
86
  undone = []
@@ -250,7 +256,13 @@ export function createFigureProductSession(store: FigureProductStore) {
250
256
  },
251
257
  /** One authoring command — what a person means, not a graph edit — as one step of the same history. */
252
258
  command(action: FigureAuthoringAction) {
253
- return tracked('do', () => workspace.command({ ...writable(), action, ...consented() }))
259
+ return tracked('do', () => workspace.command({ ...writable(), action, ...consented() }), undefined, action.kind === 'declare-instance-size')
260
+ },
261
+ /** The ends of the size on a drawing the last step moved in to what the parts allow, once: then nothing until the next. */
262
+ takeSizeRangeFits(): FigureSizeRangeFit[] {
263
+ const out = fitted
264
+ fitted = []
265
+ return out
254
266
  },
255
267
  /**
256
268
  * The first edit made inside `run` may let go of exactly these clips -- the ones a CLIPS_DROPPED refusal named and
@@ -2,29 +2,23 @@ import type { PropertyValues } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
3
  import { notify } from '@operato/layout'
4
4
  import { OxPrompt } from '@operato/popup'
5
- import type { FigureAuthoringAction, FigureClipLoss } from '@hatiolab/figure-model'
5
+ import type { FigureClipLoss } from '@hatiolab/figure-model'
6
6
  import type { FigureProductSession } from './product-session.js'
7
7
  import { clipLossOf } from './refusals.js'
8
8
 
9
- /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
10
- export type Retry = { label: string; action?: FigureAuthoringAction; run?: () => void }
11
-
12
9
  /** What the teller reads of the editor, and what it asks of it. */
13
10
  export interface RefusalHost {
14
11
  session(): FigureProductSession | undefined
15
12
  error(): string
16
13
  notice(): string
17
- retry(): Retry | undefined
18
14
  /** Said as the editor's refusal. */
19
15
  refuse(message: string): void
20
- /** A narrowed link, confirmed: run as the editor runs a command. */
21
- command(action: FigureAuthoringAction): void
22
16
  }
23
17
 
24
18
  /**
25
19
  * Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
26
20
  * notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
27
- * delete and fit the space again, narrow the range -- asks in the app's prompt and goes on when confirmed; any
21
+ * delete and fit the space again, let a clip go -- asks in the app's prompt and goes on when confirmed; any
28
22
  * other refusal is the app's notification, which stays until it is closed.
29
23
  */
30
24
  export class RefusalTeller {
@@ -35,11 +29,7 @@ export class RefusalTeller {
35
29
  /** After the editor's update: what changed in its refusal and notice, and the session's own error, said once. */
36
30
  tell(changed: PropertyValues) {
37
31
  const error = this.of.error()
38
- if (changed.has('error') && error) {
39
- const retry = this.of.retry()
40
- if (retry) this.ask(error, retry)
41
- else notify({ level: 'error', message: error })
42
- }
32
+ if (changed.has('error') && error) notify({ level: 'error', message: error })
43
33
  const notice = this.of.notice()
44
34
  if (changed.has('notice') && notice) notify({ level: 'info', message: notice })
45
35
  const viewError = this.of.session()?.read()?.error ?? ''
@@ -91,16 +81,4 @@ export class RefusalTeller {
91
81
  }
92
82
  asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
93
83
  }
94
-
95
- private async ask(message: string, retry: Retry) {
96
- const agreed = await OxPrompt.open({
97
- type: 'question',
98
- text: message,
99
- confirmButton: { text: retry.label },
100
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
101
- })
102
- if (!agreed) return
103
- if (retry.run) retry.run()
104
- else this.of.command(retry.action!)
105
- }
106
84
  }
@@ -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 } from '@hatiolab/figure-model'
10
+ import { FIGURE_INSTANCE_SIZE_INPUTS, 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']
@@ -23,36 +23,29 @@ const numberOf = (value: number) => value.toLocaleString(undefined, { maximumFra
23
23
  /** Bytes in KiB, rounded up: a figure a few bytes over its limit is not said to be at it. */
24
24
  const kibOf = (bytes: number) => Math.ceil(bytes / 1024).toLocaleString()
25
25
 
26
- /** A size range the figure cannot be drawn over, or one that leaves out its own size, in words; nothing for any other refusal. */
26
+ /**
27
+ * The ends an author typed for the size on a drawing, refused: they leave out the size the figure is drawn at, or start
28
+ * at zero. The only refusals the size range has left (ADR-0087 decision 6); nothing for any other refusal.
29
+ */
27
30
  export function sizeRangeRefusalOf(error: unknown, say: Say): string | null {
28
31
  if (codeOf(error) !== 'INSTANCE_SIZE_RANGE') return null
29
- const reach = detailsOf(error, 'size-cannot-reach')
30
- if (reach)
31
- return say(reach.bound === 'min' ? 'figure.text.size-range-too-small' : 'figure.text.size-range-too-large', { axis: reach.axis.toUpperCase(), mm: mmOf(reach.need), part: reach.part ?? '' })
32
32
  const outside = detailsOf(error, 'size-outside-range')
33
- if (outside) return say('figure.text.size-range-leaves-out-size', { axis: outside.axis.toUpperCase(), at: mmOf(outside.at), min: mmOf(outside.min), max: mmOf(outside.max) })
33
+ if (outside)
34
+ return say('figure.text.size-range-leaves-out-size', {
35
+ axis: say(`figure.label.resize-${outside.axis}`, {}),
36
+ range: `${mmOf(outside.min ?? 0)} – ${outside.max === undefined ? '∞' : mmOf(outside.max)}`,
37
+ at: mmOf(outside.at)
38
+ })
34
39
  const zero = detailsOf(error, 'size-min-not-positive')
35
- if (zero) return say('figure.text.size-range-from-zero', { axis: zero.axis.toUpperCase() })
36
- const gone = detailsOf(error, 'size-already-gone')
37
- if (gone) return say('figure.text.size-range-part-gone', { axis: gone.axis.toUpperCase(), at: mmOf(gone.at) })
40
+ if (zero) return say('figure.text.size-range-from-zero', { axis: say(`figure.label.resize-${zero.axis}`, {}) })
38
41
  return null
39
42
  }
40
43
 
41
- /**
42
- * A link refused because the size range it would leave cannot hold: measuring again cannot help (the link is not there
43
- * to measure), so the author is offered the same link that narrows the range to what the figure then allows.
44
- */
45
- export function narrowingRetryOf<A extends { kind: string }>(action: A, error: unknown, say: Say): { message: string; label: string; retry: A & { narrowRange: true } } | null {
46
- if (action.kind !== 'link-dimension' || codeOf(error) !== 'INSTANCE_SIZE_RANGE') return null
47
- const reach = detailsOf(error, 'size-cannot-reach')
48
- if (!reach) return null
49
- const words = { axis: reach.axis.toUpperCase(), mm: mmOf(reach.need), part: reach.part ?? '' }
50
- const below = reach.bound === 'min'
51
- return {
52
- message: say(below ? 'figure.text.link-needs-smaller-range' : 'figure.text.link-needs-larger-range', words),
53
- label: say(below ? 'figure.button.narrow-range-from' : 'figure.button.narrow-range-to', words),
54
- retry: { ...action, narrowRange: true }
55
- }
44
+ /** What a command did to the ends the author typed, in one line each: what moved, from what to what. */
45
+ export function sizeRangeFitsOf(fits: readonly FigureSizeRangeFit[], say: Say): string {
46
+ return fits
47
+ .map(f => say(`figure.text.size-range-moved-${f.bound}`, { axis: say(`figure.label.resize-${f.axis}`, {}), from: mmOf(f.from), to: mmOf(f.to) }))
48
+ .join(' ')
56
49
  }
57
50
 
58
51
  /**
@@ -83,20 +76,14 @@ export function clipLossOf(error: unknown): FigureClipLoss[] | null {
83
76
  return codeOf(error) === 'CLIPS_DROPPED' ? (detailsOf(error, 'clips-dropped')?.clips ?? []) : null
84
77
  }
85
78
 
86
- /** A part refused because the declared room reads it -- the one refusal deleting it with the room refitted answers. */
87
- export function inDeclaredRoom(error: unknown): boolean {
88
- return codeOf(error) === 'PART_REFERENCED' && !!detailsOf(error, 'in-declared-space')
89
- }
90
-
91
79
  /**
92
80
  * Why a part cannot be deleted, in the author's words. Since the chief architect's ruling of 2026-09-27 a part that
93
81
  * holds others, leads a motion or is followed goes, and what depended on it is let go of (`removalNoticeOf`); what is
94
- * left is the declared space (with a way on: fit it again) and a row -- one laid along the part, or hanging in its row.
82
+ * left is a row -- one laid along the part, or hanging in its row.
95
83
  */
96
84
  export function partRefusalOf(error: unknown, part: string, say: Say): string | null {
97
85
  const code = codeOf(error)
98
86
  if (code !== 'PART_REFERENCED' && code !== 'PART_ROW') return null
99
- if (detailsOf(error, 'in-declared-space')) return say('figure.text.part-in-declared-space', { part })
100
87
  const along = detailsOf(error, 'row-along')
101
88
  if (along) return say('figure.text.part-row-along', { part, rows: along.rows.join(', ') })
102
89
  const carried = detailsOf(error, 'hangs-in-row')
@@ -297,7 +284,7 @@ export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
297
284
  const setting = detailsOf(error, 'motion-setting')
298
285
  if (setting) return say('figure.text.motion-setting-' + setting.why, { group: setting.group, clip: setting.clip ?? '' })
299
286
  const key = codeOf(error) === 'MOTION_GROUP' ? detailsOf(error, 'value-outside-range') : undefined
300
- if (key) return say('figure.text.motion-key-outside-range', { input: key.input, value: numberOf(key.value), min: numberOf(key.min), max: numberOf(key.max) })
287
+ if (key) return say('figure.text.motion-key-outside-range', { input: key.input, value: numberOf(key.value), min: numberOf(key.min!), max: numberOf(key.max!) })
301
288
  return null
302
289
  }
303
290
 
@@ -310,7 +297,7 @@ export function inputRangeRefusalOf(error: unknown, say: Say): string | null {
310
297
  const range = detailsOf(error, 'value-outside-range')
311
298
  if (!range) return null
312
299
  const axis = (Object.entries(FIGURE_INSTANCE_SIZE_INPUTS) as Array<[string, string]>).find(([, id]) => id === range.input)?.[0]
313
- const params = { value: numberOf(range.value), min: numberOf(range.min), max: numberOf(range.max) }
300
+ const params = { value: numberOf(range.value), min: range.min === undefined ? '−∞' : numberOf(range.min), max: range.max === undefined ? '∞' : numberOf(range.max) }
314
301
  return axis ? say('figure.text.input-range-size', { ...params, axis: axis.toUpperCase() }) : say('figure.text.input-range', params)
315
302
  }
316
303
 
@@ -10,7 +10,6 @@ import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureT
10
10
  import { figureProposalOutcome } from '../figure-assistant.js'
11
11
  import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
12
12
  import type { RecipeTemplate } from '../modeller/recipes.js'
13
- import { occupancyFromParts } from '../modeller/occupancy.js'
14
13
  import type { FigureAuthoringAction } from '@hatiolab/figure-model'
15
14
  import type { FigureGateFinding } from '../types.js'
16
15
  import '../modeller/editor.js'
@@ -290,13 +289,7 @@ export class FigureModellerPage extends PageView {
290
289
  input.value = this.row.type ?? ''
291
290
  }
292
291
  }
293
- /** 「부품에 맞추기」 — 공간을 지금 부품에서 다시 선언한다. 저장은 저작자가 따로 한다. */
294
- private fitToParts = () => {
295
- const shown = this.session.revisionNow()
296
- /* Nothing open, nothing to fit: the button stands only beside an open figure. */
297
- if (shown) this.runCommand(() => occupancyFromParts(shown.asset))
298
- }
299
- /** A command from the properties tab — rename, declare the room, the mounting face. */
292
+ /** A command from the properties tab — rename, the mounting face. */
300
293
  private onCommand = (event: Event) => {
301
294
  const action = (event as CustomEvent).detail?.action as FigureAuthoringAction | undefined
302
295
  if (action) this.runCommand(() => action)
@@ -311,18 +304,12 @@ export class FigureModellerPage extends PageView {
311
304
  this.commandError = ''
312
305
  this.changed()
313
306
  } catch (error) {
314
- /*
315
- This used to set the page error, which replaces the whole editor with one raw line — for a figure with no
316
- parts that line was 「OCCUPANCY_NO_PARTS: occupancy: no part could be measured」 (2026-09-24).
317
- */
318
- this.commandError =
319
- (error as { code?: string })?.code === 'OCCUPANCY_NO_PARTS'
320
- ? i18next.t('figure.text.fit-needs-parts')
321
- : (error as { code?: string })?.code
322
- ? editRefusalOf(error, (key, params) => i18next.t(key, params))
323
- : error instanceof Error
324
- ? error.message
325
- : String(error)
307
+ /* Said in the author's words beside the side panel, not as the page error that replaces the whole editor. */
308
+ this.commandError = (error as { code?: string })?.code
309
+ ? editRefusalOf(error, (key, params) => i18next.t(key, params))
310
+ : error instanceof Error
311
+ ? error.message
312
+ : String(error)
326
313
  }
327
314
  }
328
315
  /**
@@ -562,7 +549,6 @@ export class FigureModellerPage extends PageView {
562
549
  @capacity-changed=${(event: CustomEvent) => {
563
550
  this.budget = event.detail.budget
564
551
  }}
565
- @fit-base-to-parts=${this.fitToParts}
566
552
  @release-verdict=${(event: CustomEvent<{ blocking: FigureGateFinding[] }>) => (this.releaseBlockers = event.detail.blocking)}
567
553
  ></figure-verdict>
568
554
  <figure-history-panel
@@ -575,7 +561,6 @@ export class FigureModellerPage extends PageView {
575
561
  .dirty=${!!view.dirty}
576
562
  .saveFirst=${this.saveBeforeRevert}
577
563
  .serverSource=${() => this.session.confirmedSource()}
578
- @fit-base-to-parts=${this.fitToParts}
579
564
  @figure-released=${this.released}
580
565
  @figure-reverted=${this.reverted}
581
566
  ></figure-history-panel>