@things-factory/figure-ui 10.1.204 → 10.1.205

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 (86) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +85 -6
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +80 -5
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +62 -1
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -7,7 +7,7 @@ import { openPopup } from '@operato/layout';
7
7
  import { OxPrompt } from '@operato/popup';
8
8
  import { customElement, property, state } from 'lit/decorators.js';
9
9
  import { live } from 'lit/directives/live.js';
10
- import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
10
+ import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
11
11
  import './figure-recipe-catalog.js';
12
12
  import { captureFigureThumbnail } from './thumbnail.js';
13
13
  import { drawableFigureParts } from './renderer.js';
@@ -28,7 +28,8 @@ import './figure-preview.js';
28
28
  import './figure-settings.js';
29
29
  import { DEFAULT_VIEW } from '@things-factory/figure-viewer/dist-client/figure-view.js';
30
30
  import './figure-capability-panel.js';
31
- import { gizmoChanges, gizmoReachOf } from './gizmo.js';
31
+ import { gizmoChanges, gizmoReachOf, sizeKeepOf } from './gizmo.js';
32
+ import { keptBySizeOf, partsAtFrame, SIZE_KEEPS_WORDS, startSizeDrag, throughPlaneOf } from './size-drag.js';
32
33
  import { ModellerKeys } from './modeller-keys.js';
33
34
  import { PaneFold } from './pane-fold.js';
34
35
  import { PanelMemory } from './panel-memory.js';
@@ -50,7 +51,8 @@ import { nudgeOf, placementOf } from './stage-model.js';
50
51
  import { occupancyFromParts, roomRefitRowsOf } from './occupancy.js';
51
52
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js';
52
53
  import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js';
53
- import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
54
+ import { HelpToggles } from './help.js';
55
+ import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
54
56
  import { jointFanOf } from './joint-fan.js';
55
57
  import { derivedOf } from './revision.js';
56
58
  import { jointDragOf } from './joint-drag.js';
@@ -76,14 +78,16 @@ let FigureEditor = class FigureEditor extends LitElement {
76
78
  /** Why the last recipe was refused, in the kernel's words. */
77
79
  this.recipeError = '';
78
80
  this.revision = 0;
81
+ /** The 「?」 beside a group's title and the sentence it opens. */
82
+ this.help = new HelpToggles(this);
79
83
  this.selected = '';
80
84
  this.error = '';
81
85
  this.tab = 'properties';
82
86
  /**
83
- * The fastening window open for the selected part (user 2026-10-17): chosen there, applied once. It stands over the
84
- * side panel, not over the canvas, so what is chosen is seen in 3D as it is chosen (user 2026-10-19: the standard
85
- * popup, always modal, covered the preview). It closes with nothing written when the part, the tab, the middle or a
86
- * bar panel changes under it.
87
+ * The fastening window open for the selected part (user 2026-10-17), in the standard popup, moved by its head
88
+ * (2026-10-18; put back 2026-10-20 -- the side panel it was moved to could not be moved), with no backdrop, so the
89
+ * canvas can be pressed while it is open: chosen there, applied once. It closes with nothing written when the part,
90
+ * the tab or the middle changes under it.
87
91
  */
88
92
  this.fastening = false;
89
93
  /** Why the window's 「적용」 was refused, said in it. */
@@ -145,7 +149,6 @@ let FigureEditor = class FigureEditor extends LitElement {
145
149
  this.panelMemory = new PanelMemory(() => this.renderRoot);
146
150
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
147
151
  this.previewMounted = false;
148
- this.lastAnchors = {};
149
152
  /** Data tried in the data tab, given to the preview's figures as their `data`; null when none is (ADR-0051 §8). */
150
153
  this.dataSample = null;
151
154
  /*
@@ -221,7 +224,7 @@ let FigureEditor = class FigureEditor extends LitElement {
221
224
  aria-pressed=${String(this.gizmoMode === tool.mode)}
222
225
  ?disabled=${!tool.can}
223
226
  aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
224
- title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + (tool.mode === 'scale' && this.sizeDragTurned(this.asset(), this.selected) ? this.sizeDragRefusalOf(this.selected) : i18next.t('figure.text.gizmo-not-own'))}`}
227
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
225
228
  @click=${() => this.useGizmo(tool.mode)}
226
229
  >
227
230
  <md-icon>${tool.icon}</md-icon>
@@ -263,7 +266,7 @@ let FigureEditor = class FigureEditor extends LitElement {
263
266
  if (_changed.has('selected'))
264
267
  this.gizmoMode = undefined;
265
268
  /* The window is about the part chosen on the properties tab, being edited: it does not follow anywhere else. */
266
- if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit' || this.overlay))
269
+ if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit'))
267
270
  this.closeFastening();
268
271
  /*
269
272
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
@@ -296,15 +299,10 @@ let FigureEditor = class FigureEditor extends LitElement {
296
299
  useGizmo(mode) {
297
300
  /*
298
301
  R does what the bar's resize tool does: on a part whose size is not its own to drag the tool is off, so no gizmo is
299
- put on it to drag in vain (:3300, 10.1.196) -- the tool's own words are said instead, and a part turned is sent to
300
- its size fields.
302
+ put on it to drag in vain (:3300, 10.1.196) -- the tool's own words are said instead.
301
303
  */
302
304
  if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale' && !this.reachOf(this.selected).scale) {
303
- const asset = this.asset();
304
- if (asset && this.sizeDragTurned(asset, this.selected))
305
- this.tellSizeByFields(this.selected);
306
- else
307
- this.refusals.say(i18next.t('figure.text.gizmo-not-own'));
305
+ this.refusals.say(i18next.t('figure.text.gizmo-not-own'));
308
306
  return;
309
307
  }
310
308
  this.renderRoot.querySelector('figure-canvas')?.gizmo(mode);
@@ -333,6 +331,7 @@ let FigureEditor = class FigureEditor extends LitElement {
333
331
  finally {
334
332
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
335
333
  this.live = NOTHING_LIVE;
334
+ this.sizing = undefined;
336
335
  }
337
336
  }
338
337
  /** The bodies drawn with the value in hand, or null when nothing is in hand (or the figure cannot take it). */
@@ -342,7 +341,8 @@ let FigureEditor = class FigureEditor extends LitElement {
342
341
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
343
342
  drawnParts(geometry, live) {
344
343
  const parts = live ?? drawableFigureParts(geometry ?? []);
345
- return parts.filter(body => !this.hiddenParts.has(ownerOf(body.id)));
344
+ const frame = this.sizing?.frame;
345
+ return (frame ? partsAtFrame(parts, frame.moved) : parts).filter(body => !this.hiddenParts.has(ownerOf(body.id)));
346
346
  }
347
347
  /**
348
348
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -560,64 +560,45 @@ let FigureEditor = class FigureEditor extends LitElement {
560
560
  return none;
561
561
  try {
562
562
  const asset = this.shown().asset;
563
- const reach = gizmoReachOf(asset, id);
564
- /*
565
- A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
566
- say which face that is -- a part fastened turned or twisted -- the drag would show one result and write another,
567
- so sizing by the gizmo is not offered there; the fields still size it (designer 2026-09-27).
568
- */
569
- if (reach.scale && this.sizeDragUnsure(asset, id))
570
- return { ...reach, scale: false };
571
- return reach;
563
+ /* Turned any way, fastened or not: a size drag is drawn from figure-model's one size function, frame by frame (ADR-0140 4.4). */
564
+ return gizmoReachOf(asset, id);
572
565
  }
573
566
  catch {
574
567
  return none;
575
568
  }
576
569
  }
577
570
  /**
578
- * Whether this part's size is its own to drag but the drag is withheld because it is turned: then the size fields are
579
- * the way, and the notice says so. A part whose size is worked out from another part or from the figure's size is not
580
- * its own to drag at all; it is not told it is attached turned, nor sent to fields it cannot type in (:3300,
581
- * 2026-10-19, the fan's blade-2). Its tool says it is placed by something else, as before.
571
+ * A size drag in hand, a frame of it: the sizes the handle comes to, through figure-model's size function and the
572
+ * evaluator stepped to them, drawn and written nowhere (ADR-0140 4.4). Begun at the first frame, from the figure as it
573
+ * is drawn and the handle the scene says was held; the release writes the last frame's changes.
582
574
  */
583
- sizeDragTurned(asset, id) {
584
- try {
585
- return gizmoReachOf(asset, id).scale && this.sizeDragUnsure(asset, id);
586
- }
587
- catch {
588
- return false;
589
- }
590
- }
591
- /** Whether a size drag on this part could not show what is written: figure-model gives no face it keeps. */
592
- sizeDragUnsure(asset, id) {
575
+ resizing(id, result) {
576
+ const part = ownerOf(id);
577
+ const shown = this.shown();
578
+ if (!shown || !result.scale)
579
+ return;
593
580
  try {
594
- return !figureScaleAnchorOf(asset, ownerOf(id));
581
+ const drawn = drawableFigureParts(this.session.read().geometry ?? []);
582
+ if (this.sizing?.part !== part)
583
+ this.sizing = { part, drag: startSizeDrag(shown.asset, part, shown.inputs, sizeKeepOf(result.grab)), through: throughPlaneOf(shown.asset, drawn, part) };
584
+ const sizes = gizmoChanges(id, this.reachOf(id), { scale: result.scale }, { ...shown.inputs });
585
+ this.sizing = { ...this.sizing, frame: this.sizing.drag.frame(sizes) };
595
586
  }
596
587
  catch {
597
- return true;
588
+ /* A size the figure cannot take on the way: the figure as it stands is drawn, and the release says why. */
589
+ this.sizing = undefined;
598
590
  }
599
591
  }
600
592
  /**
601
- * Why this part's size is not dragged, in one line (user 2026-10-19: the reason told the faces kept, and was not
602
- * understood): attached turned or twisted, or turned off its upright.
593
+ * A free part a size change takes past the mounting plane: said, with 「…에 붙이기」 to fasten it there (ADR-0140 5). The
594
+ * size rule is not changed for it: what the author picks is the fastening, one step of the history.
603
595
  */
604
- sizeDragRefusalOf(id) {
605
- const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(id));
606
- return i18next.t(fastened ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-size-tilted', { part: partLabelOf(id) });
607
- }
608
- /** The size drag refused, said as a notice with the way to the part's size fields. */
609
- tellSizeByFields(id) {
610
- this.refusals.point(this.sizeDragRefusalOf(id), { label: i18next.t('figure.button.to-size-fields'), run: () => this.toSizeFields(id) });
611
- }
612
- /** To the part's first size field it takes typed, on the properties tab, ready to type over. */
613
- async toSizeFields(id) {
614
- this.middle = 'edit';
615
- this.tab = 'properties';
616
- this.selected = id;
617
- await this.updateComplete;
618
- const field = this.renderRoot.querySelector('[slot="size"] input:not([disabled]):not([readonly])');
619
- field?.focus();
620
- field?.select();
596
+ tellThroughPlane(part, mm) {
597
+ const plane = i18next.t('figure.label.resize-place-' + (this.asset()?.placement ?? 'floor'));
598
+ this.refusals.point(i18next.t('figure.text.size-through-plane', { part: partLabelOf(part), plane, mm: String(mm) }), {
599
+ label: i18next.t('figure.button.fasten-to-plane', { plane }),
600
+ run: () => this.run(() => this.session.command({ kind: 'attach', part, to: { plane: 'mounting-plane' } }))
601
+ });
621
602
  }
622
603
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
623
604
  transformed(id, result) {
@@ -645,21 +626,22 @@ let FigureEditor = class FigureEditor extends LitElement {
645
626
  the shift had been taken for a move of the joint, and the size was dropped).
646
627
  */
647
628
  const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id));
648
- /* A size drag this part cannot show truly is not written: it springs back, and the reason is said. */
649
- if (result.scale && result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragTurned(asset, id)) {
650
- this.tellSizeByFields(id);
651
- return this.requestUpdate();
652
- }
653
629
  /*
654
- A size dragged keeps the face the figure's placement keeps, as a size typed does (figure-model's one rule): the
655
- gizmo's own anchoring on the opposite face is what the drag shows, not what is written (designer 2026-09-27).
630
+ A size dragged is written as its last frame was drawn: figure-model's one size function, keeping the side opposite
631
+ the handle held -- or, fastened, its fastening (ADR-0140 4.1 · 4.4). What the scene put the part at is not read.
656
632
  */
657
633
  const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4);
658
634
  const taken = jointed || scaled ? { scale: result.scale } : result;
659
635
  const written = gizmoChanges(id, this.reachOf(id), taken, sizes);
660
- const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written;
636
+ const drawnLast = this.sizing?.part === ownerOf(id) ? this.sizing.frame : undefined;
637
+ const changes = !scaled ? written : drawnLast ? drawnLast.changes : figureSizeEditOf(asset, ownerOf(id), written, sizeKeepOf(result.grab)).changes;
661
638
  if (changes.length) {
639
+ const through = scaled ? (this.sizing?.part === ownerOf(id) ? this.sizing.through : undefined) ?? throughPlaneOf(asset, drawableFigureParts(view.geometry), ownerOf(id)) : 0;
662
640
  this.run(() => this.session.apply(changes));
641
+ /* Taken past the mounting plane by the size it was given: said with how far, once it is written, and the way to fasten it (ADR-0140 5). */
642
+ const now = scaled ? throughPlaneOf(this.shown().asset, drawableFigureParts(this.session.read().geometry ?? []), ownerOf(id)) : 0;
643
+ if (now > through + 0.5)
644
+ this.tellThroughPlane(ownerOf(id), now);
663
645
  return;
664
646
  }
665
647
  if (jointed && result.scale) {
@@ -859,6 +841,7 @@ let FigureEditor = class FigureEditor extends LitElement {
859
841
  ${groups.singles.length
860
842
  ? html `<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
861
843
  : nothing}
844
+ ${this.renderSizeKeeps()}
862
845
  </div>`
863
846
  : nothing}
864
847
  ${place.length && !placeLocked
@@ -942,26 +925,6 @@ let FigureEditor = class FigureEditor extends LitElement {
942
925
  return html `${panel('view', 'figure.label.tab-environment', html `<figure-settings .view=${this.view} @update-view=${(e) => this.setView(e.detail.view)}></figure-settings>`)}
943
926
  ${panel('history', 'figure.button.release', html `<slot name="history"></slot>`)}`;
944
927
  }
945
- /**
946
- * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
947
- * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
948
- * rule can say, and the gizmo keeps its own (the face across from the handle).
949
- */
950
- anchorsOf(asset) {
951
- let next = {};
952
- try {
953
- const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined;
954
- if (anchor)
955
- next = { [this.selected]: anchor };
956
- }
957
- catch {
958
- next = {};
959
- }
960
- /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
961
- if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors))
962
- this.lastAnchors = next;
963
- return this.lastAnchors;
964
- }
965
928
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
966
929
  renderControls(view, unavailable) {
967
930
  const asset = this.shown()?.asset;
@@ -1049,10 +1012,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1049
1012
  }
1050
1013
  changeField(field, event) {
1051
1014
  const input = event.target, value = input.valueAsNumber;
1052
- /* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
1015
+ /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1053
1016
  const part = this.selected;
1054
1017
  const change = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value };
1055
- this.run(() => this.session.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.shown().asset, part, [change]) : [change]));
1018
+ const asset = this.shown()?.asset;
1019
+ const before = asset && part ? throughPlaneOf(asset, drawableFigureParts(this.session.read().geometry ?? []), part) : 0;
1020
+ this.run(() => this.session.apply(field.role !== 'state' && part ? figureSizeEditOf(this.shown().asset, part, [change], { keep: 'centre' }).changes : [change]));
1021
+ /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1022
+ const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown().asset, drawableFigureParts(this.session.read().geometry ?? []), part) : 0;
1023
+ if (after > before + 0.5)
1024
+ this.tellThroughPlane(part, after);
1056
1025
  // Rejected edits must also restore the displayed numeric value.
1057
1026
  input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
1058
1027
  }
@@ -1060,7 +1029,25 @@ let FigureEditor = class FigureEditor extends LitElement {
1060
1029
  typing(field, event) {
1061
1030
  const value = event.target.valueAsNumber;
1062
1031
  if (Number.isFinite(value))
1063
- this.hold({ [field.id]: value });
1032
+ this.hold(this.heldOf(field, value));
1033
+ }
1034
+ /**
1035
+ * The values in hand for a number typed or scrubbed: what its release writes -- a size with the part's middle kept,
1036
+ * or its fastening (figure-model's one size function, ADR-0140 4.1), so the figure does not jump on the release.
1037
+ */
1038
+ heldOf(field, value, sized) {
1039
+ const part = this.selected;
1040
+ const asset = this.shown()?.asset;
1041
+ if (field.role === 'state' || !part || !asset)
1042
+ return { [field.id]: value };
1043
+ const change = { kind: 'set-design', input: field.id, unit: field.unit ?? 'mm', value };
1044
+ try {
1045
+ const { changes } = sized ? sized([change], { keep: 'centre' }) : figureSizeEditOf(asset, part, [change], { keep: 'centre' });
1046
+ return Object.fromEntries(changes.map(c => [c.input, c.value]));
1047
+ }
1048
+ catch {
1049
+ return { [field.id]: value };
1050
+ }
1064
1051
  }
1065
1052
  /**
1066
1053
  * Drag a field's label (or an axis letter) sideways to scrub its value, as vector tools do: the figure follows the
@@ -1077,10 +1064,18 @@ let FigureEditor = class FigureEditor extends LitElement {
1077
1064
  label.setPointerCapture(down.pointerId);
1078
1065
  const start = field.value;
1079
1066
  let value = start;
1067
+ /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1068
+ const asset = this.shown()?.asset;
1069
+ const sized = asset && this.selected && field.role !== 'state' ? (() => { try {
1070
+ return figureSizeEditorOf(asset, this.selected);
1071
+ }
1072
+ catch {
1073
+ return undefined;
1074
+ } })() : undefined;
1080
1075
  const move = (e) => {
1081
1076
  value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey));
1082
1077
  input.value = String(value);
1083
- this.hold({ [field.id]: value });
1078
+ this.hold(this.heldOf(field, value, sized));
1084
1079
  };
1085
1080
  const up = (e) => {
1086
1081
  label.removeEventListener('pointermove', move);
@@ -1125,26 +1120,33 @@ let FigureEditor = class FigureEditor extends LitElement {
1125
1120
  return { of, toDeclare };
1126
1121
  });
1127
1122
  const range = measured.of;
1128
- /* No size yet: the figure is declared its size from its parts, so a board can resize it and parts can follow it. */
1123
+ /*
1124
+ In words a person reads (user 2026-10-19/20): what the figure does on a drawing, not the inputs behind it. Fixed,
1125
+ it says the size it is always drawn at; resizable, the sizes it may take on each axis and the parts that change
1126
+ with it. The way to it stays one press -- a figure no part follows (a robot scaled whole) reaches the one-ratio
1127
+ rule only from here -- and the help mark says how parts are made to follow.
1128
+ */
1129
+ const mm = (v) => Math.round(v).toLocaleString();
1129
1130
  const toDeclare = measured.toDeclare;
1131
+ const title = (explained) => html `<h3>${i18next.t('figure.label.board-size')}${this.help.button(explained)}</h3>${this.help.hint(explained)}`;
1130
1132
  if (!range && toDeclare)
1131
- return html `<section>
1132
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1133
- <p hint>${i18next.t('figure.text.instance-size-undeclared')}</p>
1133
+ return html `<section board-size>
1134
+ ${title('figure.text.board-size-fixed-explained')}
1135
+ <p fixed-size>${i18next.t('figure.text.board-size-fixed', { x: mm(toDeclare.at.x), y: mm(toDeclare.at.y), z: mm(toDeclare.at.z) })}</p>
1134
1136
  <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })}>
1135
- <md-icon>straighten</md-icon>${i18next.t('figure.button.declare-instance-size')}
1137
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1136
1138
  </button>
1137
1139
  </section>`;
1138
1140
  if (!range)
1139
1141
  return nothing;
1140
- const mm = (v) => Math.round(v).toLocaleString();
1141
1142
  const differs = rangeDiffers(range.declared, range.measured);
1142
- return html `<section>
1143
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1143
+ const followers = sizeFollowersOf(asset);
1144
+ return html `<section board-size>
1145
+ ${title('figure.text.board-size-range-explained')}
1144
1146
  ${['x', 'y', 'z'].map(axis => html `<div inline-field>
1145
- <label>${axis.toUpperCase()} <span unit>mm</span></label>
1146
- <div pair>
1147
- ${['min', 'max'].map(bound => html `<input
1147
+ <label>${i18next.t('figure.label.resize-' + axis)} <span unit>mm</span></label>
1148
+ <div pair range>
1149
+ ${['min', 'max'].map((bound, i) => html `${i ? html `<span dash aria-hidden="true">–</span>` : nothing}<input
1148
1150
  aria-label=${`size.${axis}.${bound}`}
1149
1151
  type="number"
1150
1152
  step="any"
@@ -1158,6 +1160,13 @@ let FigureEditor = class FigureEditor extends LitElement {
1158
1160
  />`)}
1159
1161
  </div>
1160
1162
  </div>`)}
1163
+ <p followers>
1164
+ ${followers.length
1165
+ ? i18next.t('figure.text.board-size-followers', {
1166
+ parts: followers.length > 4 ? i18next.t('figure.text.board-size-followers-more', { first: followers.slice(0, 4).join(', '), rest: String(followers.length - 4) }) : followers.join(', ')
1167
+ })
1168
+ : i18next.t('figure.text.board-size-no-followers')}
1169
+ </p>
1161
1170
  ${differs
1162
1171
  ? html `<p hint>
1163
1172
  ${i18next.t('figure.text.instance-range-can-widen', {
@@ -1504,46 +1513,51 @@ let FigureEditor = class FigureEditor extends LitElement {
1504
1513
  this.openFastening();
1505
1514
  }
1506
1515
  openFastening() {
1507
- if (!this.selected || this.fastening)
1516
+ if (!this.selected || this.fastenPopup)
1508
1517
  return;
1509
1518
  this.fastenError = '';
1510
1519
  this.fastenHover = null;
1511
- /* One panel over the side at a time: the view or the history gives way to the window. */
1512
- this.overlay = undefined;
1513
1520
  this.fastening = true;
1521
+ const content = document.createElement('figure-fasten-dialog');
1522
+ content.addEventListener('preview-command', e => this.commandPreview.ask(e.detail));
1523
+ content.addEventListener('fasten-hover', e => this.hoverFastening(e.detail));
1524
+ content.addEventListener('fasten-apply', e => this.applyFastening(e.detail.action));
1525
+ content.addEventListener('fasten-cancel', () => this.closeFastening());
1526
+ this.fastenWindow = content;
1527
+ this.tellFastening();
1528
+ /* No backdrop: the canvas stays reachable while it is open -- a part pressed there is aimed at (ADR-0140 stage 4). */
1529
+ const popup = openPopup(html `${content}`, { title: i18next.t('figure.label.fastening'), size: 'fit', backdrop: false, movable: true, closable: true, escapable: true });
1530
+ this.fastenPopup = popup;
1531
+ /* Closed by its own X or Esc as well: the editor lets go of it the same way. */
1532
+ popup.result.then(() => this.fastenPopup === popup && this.fastenClosed());
1514
1533
  }
1515
1534
  closeFastening() {
1535
+ const popup = this.fastenPopup;
1536
+ this.fastenClosed();
1537
+ popup?.close();
1538
+ }
1539
+ fastenClosed() {
1540
+ this.fastenPopup = undefined;
1541
+ this.fastenWindow = undefined;
1516
1542
  this.fastening = false;
1517
1543
  this.fastenError = '';
1518
1544
  this.fastenHover = null;
1519
1545
  }
1520
- /**
1521
- * The fastening window over the side panel, as the bar's panels stand there: the figure on screen, the part, and what
1522
- * the preview and a refusal said are the editor's. Its own Esc and 「취소」 close it, as its X does.
1523
- */
1524
- renderFastening(unavailable) {
1525
- if (!this.fastening || !this.selected)
1526
- return nothing;
1527
- return html `<div side-overlay fasten-panel>
1528
- <div overlay-head>
1529
- <h3>${i18next.t('figure.label.fastening')}</h3>
1530
- <button quiet fasten-close aria-label=${i18next.t('figure.button.close')} title=${i18next.t('figure.button.close')} @click=${() => this.closeFastening()}>
1531
- <md-icon>close</md-icon>
1532
- </button>
1533
- </div>
1534
- <figure-fasten-dialog
1535
- overlay-body
1536
- .revision=${this.shown()}
1537
- .partId=${this.selected}
1538
- ?locked=${unavailable}
1539
- .previewWords=${this.commandPreview.words()}
1540
- .error=${this.fastenError}
1541
- @preview-command=${(e) => this.commandPreview.ask(e.detail)}
1542
- @fasten-hover=${(e) => this.hoverFastening(e.detail)}
1543
- @fasten-apply=${(e) => this.applyFastening(e.detail.action)}
1544
- @fasten-cancel=${() => this.closeFastening()}
1545
- ></figure-fasten-dialog>
1546
- </div>`;
1546
+ /** What the window shows is the editor's: the figure on screen, the part, and what the preview and a refusal said. */
1547
+ tellFastening() {
1548
+ const content = this.fastenWindow;
1549
+ const view = this.session?.read();
1550
+ if (!content || !view)
1551
+ return;
1552
+ content.revision = this.shown();
1553
+ content.partId = this.selected;
1554
+ content.locked = view.loading || !!view.error || view.saving || !view.context;
1555
+ content.previewWords = this.commandPreview.words();
1556
+ content.error = this.fastenError;
1557
+ }
1558
+ disconnectedCallback() {
1559
+ super.disconnectedCallback();
1560
+ this.closeFastening();
1547
1561
  }
1548
1562
  /** 「적용」: the command, one step; refused, the window stays open and says why. */
1549
1563
  applyFastening(action) {
@@ -1606,15 +1620,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1606
1620
  return this.shown()?.asset;
1607
1621
  }
1608
1622
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1609
- renderTab(tab, icon, label, badge) {
1623
+ renderTab(tab, icon, label) {
1610
1624
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1611
1625
  const name = i18next.t('figure.label.tab-short-' + tab);
1612
1626
  return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1613
- <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html `<span badge>${badge.toLocaleString()}</span>`}
1627
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1614
1628
  </button>`;
1615
1629
  }
1616
1630
  updated(changed) {
1617
1631
  super.updated?.(changed);
1632
+ this.tellFastening();
1618
1633
  /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1619
1634
  if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen)
1620
1635
  this.blockersOpen = false;
@@ -1641,7 +1656,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1641
1656
  return nothing;
1642
1657
  const asset = this.asset();
1643
1658
  const measures = figureSectionMeasuresOf(asset, this.selected);
1644
- const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis];
1659
+ /* What a measure typed here keeps: the part's middle, or its fastening (ADR-0140 4.1). */
1660
+ const kept = keptBySizeOf(asset, this.selected);
1645
1661
  return html `<figure-section-editor
1646
1662
  .body=${{ ...part, id: this.selected }}
1647
1663
  .measures=${measures}
@@ -1793,13 +1809,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1793
1809
  held = undefined;
1794
1810
  }
1795
1811
  const roomFields = own.filter(i => roomOnly.includes(i.id));
1796
- const status = view.loading
1797
- ? 'figure.text.loading-figure'
1798
- : view.saving
1799
- ? 'figure.text.saving-figure'
1800
- : view.dirty || !view.id
1801
- ? 'figure.text.unsaved-changes'
1802
- : 'figure.text.saved';
1812
+ /*
1813
+ Only what is under way is said. Saved or not is the save button's: filled and pressable when there is something
1814
+ to save, dimmed when not -- the saved and unsaved words beside it said the same thing again in the primary
1815
+ colour, read as a button (screen copy rule: a state is the control's).
1816
+ */
1817
+ const status = view.loading ? 'figure.text.loading-figure' : view.saving ? 'figure.text.saving-figure' : '';
1803
1818
  /*
1804
1819
  A figure with no parts yet is not one that failed: what blocks its release is said once it has something to
1805
1820
  release (first 15 minutes, 2026-10-16 evening -- red from the first click read as a failure). The verdict tab
@@ -1825,7 +1840,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1825
1840
  ${['edit', 'preview'].map(mode => html `<button slot="mode" mode aria-pressed=${String(this.middle === mode)} title=${i18next.t('figure.text.mode-' + mode + '-explained')} @click=${() => (this.middle = mode)}>
1826
1841
  ${i18next.t('figure.label.mode-' + mode)}
1827
1842
  </button>`)}
1828
- <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1843
+ ${status ? html `<span slot="status" class="status" role="status">${i18next.t(status)}</span>` : nothing}
1829
1844
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1830
1845
  ${blockers.length
1831
1846
  ? html `<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
@@ -1907,7 +1922,6 @@ let FigureEditor = class FigureEditor extends LitElement {
1907
1922
  <figure-canvas
1908
1923
  .view=${this.view}
1909
1924
  .parts=${this.drawnParts(view.geometry, live)}
1910
- .anchors=${this.anchorsOf(asset)}
1911
1925
  .fan=${this.fanOf(shown, live)}
1912
1926
  .ghost=${this.previewShown().ghost}
1913
1927
  .highlight=${this.previewShown().highlight}
@@ -1915,9 +1929,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1915
1929
  .palette=${view.palette ?? {}}
1916
1930
  .context=${view.context ?? ''}
1917
1931
  .selected=${this.selected}
1932
+ ?aiming=${this.fastening}
1918
1933
  .reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
1934
+ @part-sizing=${(e) => this.resizing(e.detail.id, e.detail.result)}
1919
1935
  @part-transformed=${(e) => this.transformed(e.detail.id, e.detail.result)}
1920
1936
  @part-selected=${(e) => this.choose(e.detail)}
1937
+ @part-aimed=${(e) => this.fastenWindow?.aimAt?.(e.detail)}
1921
1938
  @render-error=${(e) => {
1922
1939
  this.error = e.detail.message;
1923
1940
  }}
@@ -1929,7 +1946,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1929
1946
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1930
1947
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1931
1948
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1932
- ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1949
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'))}
1933
1950
  </div>
1934
1951
  <slot name="notice"></slot>
1935
1952
  <div panel ?off=${tab !== 'properties'}>
@@ -2005,7 +2022,7 @@ let FigureEditor = class FigureEditor extends LitElement {
2005
2022
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
2006
2023
  </aside>
2007
2024
  </ox-three-pane>
2008
- ${this.renderOverlay()} ${this.renderFastening(unavailable)}`;
2025
+ ${this.renderOverlay()}`;
2009
2026
  }
2010
2027
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
2011
2028
  renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null, held) {
@@ -2076,6 +2093,12 @@ let FigureEditor = class FigureEditor extends LitElement {
2076
2093
  * rule" named nothing): a fastened part's place and turn are its fastening's; other numbers are worked out from
2077
2094
  * something else. A size that follows something is said at the panel's head, so not again here.
2078
2095
  */
2096
+ /** What a size typed keeps, under the size fields: the part's middle, where it is fastened, or only its size (ADR-0140 4.1). */
2097
+ renderSizeKeeps() {
2098
+ const asset = this.asset();
2099
+ const kept = asset && this.selected ? keptBySizeOf(asset, this.selected) : undefined;
2100
+ return kept ? html `<p hint size-keeps>${i18next.t(SIZE_KEEPS_WORDS[kept])}</p>` : nothing;
2101
+ }
2079
2102
  renderHeldAxes(vectors) {
2080
2103
  const fastened = !!(this.asset())?.joints?.some(j => j.body1.part === ownerOf(this.selected));
2081
2104
  const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && ['x', 'y', 'z'].some(axis => !g.cells[axis]));
@@ -2196,10 +2219,6 @@ __decorate([
2196
2219
  property({ type: Number }),
2197
2220
  __metadata("design:type", Object)
2198
2221
  ], FigureEditor.prototype, "revision", void 0);
2199
- __decorate([
2200
- property({ type: Number }),
2201
- __metadata("design:type", Number)
2202
- ], FigureEditor.prototype, "instances", void 0);
2203
2222
  __decorate([
2204
2223
  property({ attribute: false }),
2205
2224
  __metadata("design:type", Object)
@@ -2284,6 +2303,10 @@ __decorate([
2284
2303
  state(),
2285
2304
  __metadata("design:type", Object)
2286
2305
  ], FigureEditor.prototype, "live", void 0);
2306
+ __decorate([
2307
+ state(),
2308
+ __metadata("design:type", Object)
2309
+ ], FigureEditor.prototype, "sizing", void 0);
2287
2310
  __decorate([
2288
2311
  state(),
2289
2312
  __metadata("design:type", Object)