@things-factory/figure-ui 10.1.204 → 10.1.206

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 (116) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +23 -22
  3. package/client/modeller/editor.ts +217 -184
  4. package/client/modeller/fasten-form.ts +130 -12
  5. package/client/modeller/figure-fasten-dialog.ts +45 -11
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +6 -6
  9. package/client/modeller/figure-section-editor.ts +6 -6
  10. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  11. package/client/modeller/gizmo.ts +22 -1
  12. package/client/modeller/keys.ts +10 -1
  13. package/client/modeller/links.ts +79 -46
  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 +19 -26
  18. package/client/modeller/section-drawing.ts +7 -7
  19. package/client/modeller/size-drag.ts +112 -0
  20. package/client/modeller/slot-layout-host.ts +3 -3
  21. package/client/modeller/stage-model.ts +6 -9
  22. package/client/pages/figure-modeller-page.ts +1 -4
  23. package/dist-client/modeller/canvas.d.ts +24 -3
  24. package/dist-client/modeller/canvas.js +96 -11
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/editor-styles.js +23 -22
  27. package/dist-client/modeller/editor-styles.js.map +1 -1
  28. package/dist-client/modeller/editor.d.ts +35 -35
  29. package/dist-client/modeller/editor.js +221 -193
  30. package/dist-client/modeller/editor.js.map +1 -1
  31. package/dist-client/modeller/fasten-form.d.ts +46 -4
  32. package/dist-client/modeller/fasten-form.js +120 -11
  33. package/dist-client/modeller/fasten-form.js.map +1 -1
  34. package/dist-client/modeller/figure-fasten-dialog.d.ts +18 -4
  35. package/dist-client/modeller/figure-fasten-dialog.js +49 -12
  36. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  37. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  38. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  39. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  40. package/dist-client/modeller/figure-links.d.ts +1 -1
  41. package/dist-client/modeller/figure-links.js +2 -2
  42. package/dist-client/modeller/figure-links.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.js +4 -4
  44. package/dist-client/modeller/figure-preview.js.map +1 -1
  45. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  46. package/dist-client/modeller/figure-section-editor.js +4 -4
  47. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  48. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  49. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  50. package/dist-client/modeller/gizmo.d.ts +18 -1
  51. package/dist-client/modeller/gizmo.js +19 -0
  52. package/dist-client/modeller/gizmo.js.map +1 -1
  53. package/dist-client/modeller/keys.d.ts +1 -1
  54. package/dist-client/modeller/keys.js +6 -0
  55. package/dist-client/modeller/keys.js.map +1 -1
  56. package/dist-client/modeller/links.d.ts +30 -24
  57. package/dist-client/modeller/links.js +67 -42
  58. package/dist-client/modeller/links.js.map +1 -1
  59. package/dist-client/modeller/palette.js +1 -1
  60. package/dist-client/modeller/palette.js.map +1 -1
  61. package/dist-client/modeller/product-session.d.ts +15 -13
  62. package/dist-client/modeller/product-session.js +15 -5
  63. package/dist-client/modeller/product-session.js.map +1 -1
  64. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  65. package/dist-client/modeller/refusal-teller.js +3 -22
  66. package/dist-client/modeller/refusal-teller.js.map +1 -1
  67. package/dist-client/modeller/refusals.d.ts +6 -14
  68. package/dist-client/modeller/refusals.js +16 -27
  69. package/dist-client/modeller/refusals.js.map +1 -1
  70. package/dist-client/modeller/section-drawing.d.ts +3 -4
  71. package/dist-client/modeller/section-drawing.js +10 -9
  72. package/dist-client/modeller/section-drawing.js.map +1 -1
  73. package/dist-client/modeller/size-drag.d.ts +38 -0
  74. package/dist-client/modeller/size-drag.js +85 -0
  75. package/dist-client/modeller/size-drag.js.map +1 -0
  76. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  77. package/dist-client/modeller/slot-layout-host.js +3 -3
  78. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  79. package/dist-client/modeller/stage-model.d.ts +5 -7
  80. package/dist-client/modeller/stage-model.js +6 -6
  81. package/dist-client/modeller/stage-model.js.map +1 -1
  82. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  83. package/dist-client/pages/figure-modeller-page.js +1 -6
  84. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  85. package/dist-client/route.d.ts +1 -1
  86. package/dist-client/tsconfig.tsbuildinfo +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +7 -7
  89. package/test/align.test.ts +7 -11
  90. package/test/browser/acceptance-page.js +11 -1
  91. package/test/browser/editing.check.mjs +17 -13
  92. package/test/browser/links-page.js +2 -2
  93. package/test/browser/links.check.mjs +124 -69
  94. package/test/browser/modeller-page.js +30 -18
  95. package/test/browser/modeller-revision.check.mjs +39 -0
  96. package/test/browser/modeller.check.mjs +77 -26
  97. package/test/browser/parts.check.mjs +49 -0
  98. package/test/browser/screen-page.js +0 -2
  99. package/test/browser/screen.check.mjs +11 -13
  100. package/test/browser/section.check.mjs +29 -2
  101. package/test/fasten-form.test.ts +102 -1
  102. package/test/gizmo.test.ts +25 -16
  103. package/test/keys.test.ts +11 -0
  104. package/test/links.test.ts +46 -36
  105. package/test/refusals.test.ts +17 -21
  106. package/test/resize-with-placement.test.ts +14 -13
  107. package/test/revision.test.ts +3 -2
  108. package/test/section-editor.test.ts +9 -10
  109. package/test/size-drag.test.ts +114 -0
  110. package/test/slot-layout-host.test.ts +13 -8
  111. package/translations/en.json +32 -34
  112. package/translations/es.json +32 -34
  113. package/translations/ja.json +32 -34
  114. package/translations/ko.json +32 -34
  115. package/translations/ms.json +32 -34
  116. package/translations/zh.json +32 -34
@@ -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, sameMeasureAxesOf, 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';
@@ -49,8 +50,9 @@ import { END_FACE } from './section-controls.js';
49
50
  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
- 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';
53
+ import { editRefusalOf, inDeclaredRoom, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js';
54
+ import { HelpToggles } from './help.js';
55
+ import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, 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. */
@@ -133,19 +137,18 @@ let FigureEditor = class FigureEditor extends LitElement {
133
137
  selected: () => this.selected,
134
138
  act: action => this.keyAction(action)
135
139
  });
140
+ /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
141
+ this.copied = '';
136
142
  this.paneFold = new PaneFold(this, () => this.panes);
137
143
  this.refusals = new RefusalTeller({
138
144
  session: () => this.session,
139
145
  error: () => this.error,
140
146
  notice: () => this.notice,
141
- retry: () => this.retry,
142
- refuse: message => (this.error = message),
143
- command: action => this.linkCommand(action)
147
+ refuse: message => (this.error = message)
144
148
  });
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,13 +224,20 @@ 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>
228
231
  </button>`)}
229
232
  </span>`;
230
233
  }
234
+ /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
235
+ pasteCopied() {
236
+ const asset = this.session?.revisionNow()?.asset;
237
+ if (!this.copied || !asset?.document.model.nodes.some(n => n.op === 'place@1' && n.id === this.copied))
238
+ return;
239
+ this.duplicatePart(this.copied);
240
+ }
231
241
  keyAction(action) {
232
242
  const view = this.session.read();
233
243
  const canvas = this.renderRoot.querySelector('figure-canvas');
@@ -239,6 +249,12 @@ let FigureEditor = class FigureEditor extends LitElement {
239
249
  view.canRedo && this.run(() => this.session.redo());
240
250
  else if (action === 'duplicate')
241
251
  this.duplicatePart(this.selected);
252
+ else if (action === 'copy')
253
+ this.copied = this.selected;
254
+ else if (action === 'paste')
255
+ this.pasteCopied();
256
+ else if (action === 'hold')
257
+ return;
242
258
  else if (action === 'remove')
243
259
  this.removePart(this.selected);
244
260
  else
@@ -263,7 +279,7 @@ let FigureEditor = class FigureEditor extends LitElement {
263
279
  if (_changed.has('selected'))
264
280
  this.gizmoMode = undefined;
265
281
  /* 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))
282
+ if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit'))
267
283
  this.closeFastening();
268
284
  /*
269
285
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
@@ -292,19 +308,23 @@ let FigureEditor = class FigureEditor extends LitElement {
292
308
  this.selected = next;
293
309
  });
294
310
  }
311
+ /** While the size tool is on a round part: which of its handles change the one diameter (ADR-0140 4.1). */
312
+ renderSameMeasure(unavailable) {
313
+ if (unavailable || this.gizmoMode !== 'scale' || !this.selected)
314
+ return nothing;
315
+ const axes = sameMeasureAxesOf(this.reachOf(this.selected));
316
+ return axes
317
+ ? html `<p same-measure role="status">${i18next.t('figure.text.gizmo-same-measure', { axes: axes.map(a => a.toUpperCase()).join(' · ') })}</p>`
318
+ : nothing;
319
+ }
295
320
  /** The scene toggles: the mode already shown takes the gizmo away. */
296
321
  useGizmo(mode) {
297
322
  /*
298
323
  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.
324
+ put on it to drag in vain (:3300, 10.1.196) -- the tool's own words are said instead.
301
325
  */
302
326
  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'));
327
+ this.refusals.say(i18next.t('figure.text.gizmo-not-own'));
308
328
  return;
309
329
  }
310
330
  this.renderRoot.querySelector('figure-canvas')?.gizmo(mode);
@@ -333,6 +353,7 @@ let FigureEditor = class FigureEditor extends LitElement {
333
353
  finally {
334
354
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
335
355
  this.live = NOTHING_LIVE;
356
+ this.sizing = undefined;
336
357
  }
337
358
  }
338
359
  /** The bodies drawn with the value in hand, or null when nothing is in hand (or the figure cannot take it). */
@@ -342,7 +363,8 @@ let FigureEditor = class FigureEditor extends LitElement {
342
363
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
343
364
  drawnParts(geometry, live) {
344
365
  const parts = live ?? drawableFigureParts(geometry ?? []);
345
- return parts.filter(body => !this.hiddenParts.has(ownerOf(body.id)));
366
+ const frame = this.sizing?.frame;
367
+ return (frame ? partsAtFrame(parts, frame.moved) : parts).filter(body => !this.hiddenParts.has(ownerOf(body.id)));
346
368
  }
347
369
  /**
348
370
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -380,6 +402,10 @@ let FigureEditor = class FigureEditor extends LitElement {
380
402
  this.live = action ? { ...rest, action } : rest;
381
403
  }
382
404
  changed() {
405
+ /* What the step did to the size ends the author typed, said with whatever else it said. */
406
+ const fits = this.session?.takeSizeRangeFits() ?? [];
407
+ if (fits.length)
408
+ this.notice = [this.notice, sizeRangeFitsOf(fits, (key, params) => i18next.t(key, params))].filter(Boolean).join(' ');
383
409
  this.requestUpdate();
384
410
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }));
385
411
  }
@@ -483,7 +509,6 @@ let FigureEditor = class FigureEditor extends LitElement {
483
509
  this.run(() => this.session.edit({ kind: 'move-part', target: id, to }));
484
510
  }
485
511
  removePart(id, refitRoom = false) {
486
- this.retry = undefined;
487
512
  try {
488
513
  const view = this.session.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) });
489
514
  this.error = '';
@@ -560,64 +585,45 @@ let FigureEditor = class FigureEditor extends LitElement {
560
585
  return none;
561
586
  try {
562
587
  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;
588
+ /* 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). */
589
+ return gizmoReachOf(asset, id);
572
590
  }
573
591
  catch {
574
592
  return none;
575
593
  }
576
594
  }
577
595
  /**
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.
596
+ * A size drag in hand, a frame of it: the sizes the handle comes to, through figure-model's size function and the
597
+ * evaluator stepped to them, drawn and written nowhere (ADR-0140 4.4). Begun at the first frame, from the figure as it
598
+ * is drawn and the handle the scene says was held; the release writes the last frame's changes.
582
599
  */
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) {
600
+ resizing(id, result) {
601
+ const part = ownerOf(id);
602
+ const shown = this.shown();
603
+ if (!shown || !result.scale)
604
+ return;
593
605
  try {
594
- return !figureScaleAnchorOf(asset, ownerOf(id));
606
+ const drawn = drawableFigureParts(this.session.read().geometry ?? []);
607
+ if (this.sizing?.part !== part)
608
+ this.sizing = { part, drag: startSizeDrag(shown.asset, part, shown.inputs, sizeKeepOf(result.grab)), through: throughPlaneOf(shown.asset, drawn, part) };
609
+ const sizes = gizmoChanges(id, this.reachOf(id), { scale: result.scale }, { ...shown.inputs });
610
+ this.sizing = { ...this.sizing, frame: this.sizing.drag.frame(sizes) };
595
611
  }
596
612
  catch {
597
- return true;
613
+ /* A size the figure cannot take on the way: the figure as it stands is drawn, and the release says why. */
614
+ this.sizing = undefined;
598
615
  }
599
616
  }
600
617
  /**
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.
618
+ * A free part a size change takes past the mounting plane: said, with 「…에 붙이기」 to fasten it there (ADR-0140 5). The
619
+ * size rule is not changed for it: what the author picks is the fastening, one step of the history.
603
620
  */
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();
621
+ tellThroughPlane(part, mm) {
622
+ const plane = i18next.t('figure.label.resize-place-' + (this.asset()?.placement ?? 'floor'));
623
+ this.refusals.point(i18next.t('figure.text.size-through-plane', { part: partLabelOf(part), plane, mm: String(mm) }), {
624
+ label: i18next.t('figure.button.fasten-to-plane', { plane }),
625
+ run: () => this.run(() => this.session.command({ kind: 'attach', part, to: { plane: 'mounting-plane' } }))
626
+ });
621
627
  }
622
628
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
623
629
  transformed(id, result) {
@@ -645,21 +651,22 @@ let FigureEditor = class FigureEditor extends LitElement {
645
651
  the shift had been taken for a move of the joint, and the size was dropped).
646
652
  */
647
653
  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
654
  /*
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).
655
+ A size dragged is written as its last frame was drawn: figure-model's one size function, keeping the side opposite
656
+ the handle held -- or, fastened, its fastening (ADR-0140 4.1 · 4.4). What the scene put the part at is not read.
656
657
  */
657
658
  const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4);
658
659
  const taken = jointed || scaled ? { scale: result.scale } : result;
659
660
  const written = gizmoChanges(id, this.reachOf(id), taken, sizes);
660
- const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written;
661
+ const drawnLast = this.sizing?.part === ownerOf(id) ? this.sizing.frame : undefined;
662
+ const changes = !scaled ? written : drawnLast ? drawnLast.changes : figureSizeEditOf(asset, ownerOf(id), written, sizeKeepOf(result.grab)).changes;
661
663
  if (changes.length) {
664
+ const through = scaled ? (this.sizing?.part === ownerOf(id) ? this.sizing.through : undefined) ?? throughPlaneOf(asset, drawableFigureParts(view.geometry), ownerOf(id)) : 0;
662
665
  this.run(() => this.session.apply(changes));
666
+ /* 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). */
667
+ const now = scaled ? throughPlaneOf(this.shown().asset, drawableFigureParts(this.session.read().geometry ?? []), ownerOf(id)) : 0;
668
+ if (now > through + 0.5)
669
+ this.tellThroughPlane(ownerOf(id), now);
663
670
  return;
664
671
  }
665
672
  if (jointed && result.scale) {
@@ -859,6 +866,7 @@ let FigureEditor = class FigureEditor extends LitElement {
859
866
  ${groups.singles.length
860
867
  ? html `<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
861
868
  : nothing}
869
+ ${this.renderSizeKeeps()}
862
870
  </div>`
863
871
  : nothing}
864
872
  ${place.length && !placeLocked
@@ -942,26 +950,6 @@ let FigureEditor = class FigureEditor extends LitElement {
942
950
  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
951
  ${panel('history', 'figure.button.release', html `<slot name="history"></slot>`)}`;
944
952
  }
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
953
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
966
954
  renderControls(view, unavailable) {
967
955
  const asset = this.shown()?.asset;
@@ -999,7 +987,6 @@ let FigureEditor = class FigureEditor extends LitElement {
999
987
  linkCommand(action, from = 'panel') {
1000
988
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
1001
989
  this.holdAction(null);
1002
- this.retry = undefined;
1003
990
  this.notice = '';
1004
991
  const hadSize = this.hasSize();
1005
992
  const hadSpace = !!this.shown()?.asset.occupancy;
@@ -1032,12 +1019,6 @@ let FigureEditor = class FigureEditor extends LitElement {
1032
1019
  this.error = '';
1033
1020
  return false;
1034
1021
  }
1035
- const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params));
1036
- if (narrowing) {
1037
- this.error = narrowing.message;
1038
- this.retry = { label: narrowing.label, action: narrowing.retry };
1039
- return false;
1040
- }
1041
1022
  this.error =
1042
1023
  code === 'DIMENSION_CYCLE'
1043
1024
  ? i18next.t('figure.text.follow-would-loop')
@@ -1049,10 +1030,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1049
1030
  }
1050
1031
  changeField(field, event) {
1051
1032
  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. */
1033
+ /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1053
1034
  const part = this.selected;
1054
1035
  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]));
1036
+ const asset = this.shown()?.asset;
1037
+ const before = asset && part ? throughPlaneOf(asset, drawableFigureParts(this.session.read().geometry ?? []), part) : 0;
1038
+ this.run(() => this.session.apply(field.role !== 'state' && part ? figureSizeEditOf(this.shown().asset, part, [change], { keep: 'centre' }).changes : [change]));
1039
+ /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1040
+ const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown().asset, drawableFigureParts(this.session.read().geometry ?? []), part) : 0;
1041
+ if (after > before + 0.5)
1042
+ this.tellThroughPlane(part, after);
1056
1043
  // Rejected edits must also restore the displayed numeric value.
1057
1044
  input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
1058
1045
  }
@@ -1060,7 +1047,25 @@ let FigureEditor = class FigureEditor extends LitElement {
1060
1047
  typing(field, event) {
1061
1048
  const value = event.target.valueAsNumber;
1062
1049
  if (Number.isFinite(value))
1063
- this.hold({ [field.id]: value });
1050
+ this.hold(this.heldOf(field, value));
1051
+ }
1052
+ /**
1053
+ * The values in hand for a number typed or scrubbed: what its release writes -- a size with the part's middle kept,
1054
+ * or its fastening (figure-model's one size function, ADR-0140 4.1), so the figure does not jump on the release.
1055
+ */
1056
+ heldOf(field, value, sized) {
1057
+ const part = this.selected;
1058
+ const asset = this.shown()?.asset;
1059
+ if (field.role === 'state' || !part || !asset)
1060
+ return { [field.id]: value };
1061
+ const change = { kind: 'set-design', input: field.id, unit: field.unit ?? 'mm', value };
1062
+ try {
1063
+ const { changes } = sized ? sized([change], { keep: 'centre' }) : figureSizeEditOf(asset, part, [change], { keep: 'centre' });
1064
+ return Object.fromEntries(changes.map(c => [c.input, c.value]));
1065
+ }
1066
+ catch {
1067
+ return { [field.id]: value };
1068
+ }
1064
1069
  }
1065
1070
  /**
1066
1071
  * Drag a field's label (or an axis letter) sideways to scrub its value, as vector tools do: the figure follows the
@@ -1077,10 +1082,18 @@ let FigureEditor = class FigureEditor extends LitElement {
1077
1082
  label.setPointerCapture(down.pointerId);
1078
1083
  const start = field.value;
1079
1084
  let value = start;
1085
+ /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1086
+ const asset = this.shown()?.asset;
1087
+ const sized = asset && this.selected && field.role !== 'state' ? (() => { try {
1088
+ return figureSizeEditorOf(asset, this.selected);
1089
+ }
1090
+ catch {
1091
+ return undefined;
1092
+ } })() : undefined;
1080
1093
  const move = (e) => {
1081
1094
  value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey));
1082
1095
  input.value = String(value);
1083
- this.hold({ [field.id]: value });
1096
+ this.hold(this.heldOf(field, value, sized));
1084
1097
  };
1085
1098
  const up = (e) => {
1086
1099
  label.removeEventListener('pointermove', move);
@@ -1101,12 +1114,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1101
1114
  label.addEventListener('pointerup', up);
1102
1115
  label.addEventListener('pointercancel', up);
1103
1116
  }
1104
- /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
1117
+ /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1105
1118
  renderInstanceRange(shown, unavailable) {
1106
1119
  if (!shown)
1107
1120
  return nothing;
1108
1121
  const asset = shown.asset;
1109
- /* Measured once per version: measuring evaluates the figure at its edges. */
1122
+ /* Computed once per version: figure-model evaluates the figure at its edges. */
1110
1123
  const measured = derivedOf(shown, 'instance-range', () => {
1111
1124
  let of;
1112
1125
  let toDeclare;
@@ -1117,7 +1130,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1117
1130
  of = undefined;
1118
1131
  }
1119
1132
  try {
1120
- toDeclare = of ? undefined : instanceRangeToDeclare(asset);
1133
+ toDeclare = of ? undefined : instanceSizeToDeclare(asset);
1121
1134
  }
1122
1135
  catch {
1123
1136
  toDeclare = undefined;
@@ -1125,52 +1138,57 @@ let FigureEditor = class FigureEditor extends LitElement {
1125
1138
  return { of, toDeclare };
1126
1139
  });
1127
1140
  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. */
1141
+ /*
1142
+ In words a person reads (user 2026-10-19/20): what the figure does on a drawing, not the inputs behind it. Fixed,
1143
+ it says the size it is always drawn at; resizable, the sizes it may take on each axis and the parts that change
1144
+ with it. The way to it stays one press -- a figure no part follows (a robot scaled whole) reaches the one-ratio
1145
+ rule only from here -- and the help mark says how parts are made to follow.
1146
+ */
1147
+ const mm = (v) => Math.round(v).toLocaleString();
1129
1148
  const toDeclare = measured.toDeclare;
1149
+ const title = (explained) => html `<h3>${i18next.t('figure.label.board-size')}${this.help.button(explained)}</h3>${this.help.hint(explained)}`;
1130
1150
  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>
1134
- <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')}
1151
+ return html `<section board-size>
1152
+ ${title('figure.text.board-size-fixed-explained')}
1153
+ <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>
1154
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', at: toDeclare.at })}>
1155
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1136
1156
  </button>
1137
1157
  </section>`;
1138
1158
  if (!range)
1139
1159
  return nothing;
1140
- const mm = (v) => Math.round(v).toLocaleString();
1141
- const differs = rangeDiffers(range.declared, range.measured);
1142
- return html `<section>
1143
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1160
+ const followers = sizeFollowersOf(asset);
1161
+ /*
1162
+ An end the author typed shows its number; an open end is empty, with what the parts allow written faintly in it
1163
+ (ADR-0087 decision 6). Typing narrows that end, emptying the field opens it again.
1164
+ */
1165
+ const shownEnd = (r, bound) => (bound === 'max' && !Number.isFinite(r.max) ? '∞' : mm(r[bound]));
1166
+ return html `<section board-size>
1167
+ ${title('figure.text.board-size-range-explained')}
1144
1168
  ${['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
1169
+ <label>${i18next.t('figure.label.resize-' + axis)} <span unit>mm</span></label>
1170
+ <div pair range>
1171
+ ${['min', 'max'].map((bound, i) => html `${i ? html `<span dash aria-hidden="true">–</span>` : nothing}<input
1148
1172
  aria-label=${`size.${axis}.${bound}`}
1149
1173
  type="number"
1150
1174
  step="any"
1151
- .value=${live(String(Math.round(range.declared[axis][bound])))}
1175
+ .value=${live(range[axis].narrowed[bound] ? String(Math.round(range[axis][bound])) : '')}
1176
+ placeholder=${shownEnd(range[axis], bound)}
1152
1177
  ?disabled=${unavailable}
1153
1178
  @change=${(e) => {
1154
- const next = rangeWith(range.declared, axis, bound, e.target.valueAsNumber);
1155
- if (next)
1156
- this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true });
1179
+ const typed = e.target.valueAsNumber;
1180
+ this.linkCommand({ kind: 'declare-instance-size', range: rangeWith(range, axis, bound, typed), replace: true });
1157
1181
  }}
1158
1182
  />`)}
1159
1183
  </div>
1160
1184
  </div>`)}
1161
- ${differs
1162
- ? html `<p hint>
1163
- ${i18next.t('figure.text.instance-range-can-widen', {
1164
- ranges: ['x', 'y', 'z']
1165
- .filter(axis => rangeDiffers(range.declared, { ...range.declared, [axis]: range.measured[axis] }))
1166
- .map(axis => `${axis.toUpperCase()} ${mm(range.measured[axis].min)} – ${mm(range.measured[axis].max)}`)
1167
- .join(' · ')
1168
- })}
1169
- </p>
1170
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })}>
1171
- <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
1172
- </button>`
1173
- : nothing}
1185
+ <p followers>
1186
+ ${followers.length
1187
+ ? i18next.t('figure.text.board-size-followers', {
1188
+ 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(', ')
1189
+ })
1190
+ : i18next.t('figure.text.board-size-no-followers')}
1191
+ </p>
1174
1192
  ${this.renderSizingRule(asset, unavailable)}
1175
1193
  </section>`;
1176
1194
  }
@@ -1325,7 +1343,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1325
1343
  const form = this.publishing?.part === part
1326
1344
  ? this.publishing
1327
1345
  : listed && input
1328
- ? { part, label: listed.label, min: input.min, max: input.max }
1346
+ ? /* A published input has both ends (figure-model checkPublicInput). */ { part, label: listed.label, min: input.min, max: input.max }
1329
1347
  : undefined;
1330
1348
  const open = !!form;
1331
1349
  const edit = (key) => (e) => {
@@ -1504,46 +1522,51 @@ let FigureEditor = class FigureEditor extends LitElement {
1504
1522
  this.openFastening();
1505
1523
  }
1506
1524
  openFastening() {
1507
- if (!this.selected || this.fastening)
1525
+ if (!this.selected || this.fastenPopup)
1508
1526
  return;
1509
1527
  this.fastenError = '';
1510
1528
  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
1529
  this.fastening = true;
1530
+ const content = document.createElement('figure-fasten-dialog');
1531
+ content.addEventListener('preview-command', e => this.commandPreview.ask(e.detail));
1532
+ content.addEventListener('fasten-hover', e => this.hoverFastening(e.detail));
1533
+ content.addEventListener('fasten-apply', e => this.applyFastening(e.detail.action));
1534
+ content.addEventListener('fasten-cancel', () => this.closeFastening());
1535
+ this.fastenWindow = content;
1536
+ this.tellFastening();
1537
+ /* No backdrop: the canvas stays reachable while it is open -- a part pressed there is aimed at (ADR-0140 stage 4). */
1538
+ const popup = openPopup(html `${content}`, { title: i18next.t('figure.label.fastening'), size: 'fit', backdrop: false, movable: true, closable: true, escapable: true });
1539
+ this.fastenPopup = popup;
1540
+ /* Closed by its own X or Esc as well: the editor lets go of it the same way. */
1541
+ popup.result.then(() => this.fastenPopup === popup && this.fastenClosed());
1514
1542
  }
1515
1543
  closeFastening() {
1544
+ const popup = this.fastenPopup;
1545
+ this.fastenClosed();
1546
+ popup?.close();
1547
+ }
1548
+ fastenClosed() {
1549
+ this.fastenPopup = undefined;
1550
+ this.fastenWindow = undefined;
1516
1551
  this.fastening = false;
1517
1552
  this.fastenError = '';
1518
1553
  this.fastenHover = null;
1519
1554
  }
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>`;
1555
+ /** What the window shows is the editor's: the figure on screen, the part, and what the preview and a refusal said. */
1556
+ tellFastening() {
1557
+ const content = this.fastenWindow;
1558
+ const view = this.session?.read();
1559
+ if (!content || !view)
1560
+ return;
1561
+ content.revision = this.shown();
1562
+ content.partId = this.selected;
1563
+ content.locked = view.loading || !!view.error || view.saving || !view.context;
1564
+ content.previewWords = this.commandPreview.words();
1565
+ content.error = this.fastenError;
1566
+ }
1567
+ disconnectedCallback() {
1568
+ super.disconnectedCallback();
1569
+ this.closeFastening();
1547
1570
  }
1548
1571
  /** 「적용」: the command, one step; refused, the window stays open and says why. */
1549
1572
  applyFastening(action) {
@@ -1606,15 +1629,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1606
1629
  return this.shown()?.asset;
1607
1630
  }
1608
1631
  /** 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) {
1632
+ renderTab(tab, icon, label) {
1610
1633
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1611
1634
  const name = i18next.t('figure.label.tab-short-' + tab);
1612
1635
  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>`}
1636
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1614
1637
  </button>`;
1615
1638
  }
1616
1639
  updated(changed) {
1617
1640
  super.updated?.(changed);
1641
+ this.tellFastening();
1618
1642
  /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1619
1643
  if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen)
1620
1644
  this.blockersOpen = false;
@@ -1641,7 +1665,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1641
1665
  return nothing;
1642
1666
  const asset = this.asset();
1643
1667
  const measures = figureSectionMeasuresOf(asset, this.selected);
1644
- const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis];
1668
+ /* What a measure typed here keeps: the part's middle, or its fastening (ADR-0140 4.1). */
1669
+ const kept = keptBySizeOf(asset, this.selected);
1645
1670
  return html `<figure-section-editor
1646
1671
  .body=${{ ...part, id: this.selected }}
1647
1672
  .measures=${measures}
@@ -1793,13 +1818,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1793
1818
  held = undefined;
1794
1819
  }
1795
1820
  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';
1821
+ /*
1822
+ Only what is under way is said. Saved or not is the save button's: filled and pressable when there is something
1823
+ to save, dimmed when not -- the saved and unsaved words beside it said the same thing again in the primary
1824
+ colour, read as a button (screen copy rule: a state is the control's).
1825
+ */
1826
+ const status = view.loading ? 'figure.text.loading-figure' : view.saving ? 'figure.text.saving-figure' : '';
1803
1827
  /*
1804
1828
  A figure with no parts yet is not one that failed: what blocks its release is said once it has something to
1805
1829
  release (first 15 minutes, 2026-10-16 evening -- red from the first click read as a failure). The verdict tab
@@ -1825,7 +1849,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1825
1849
  ${['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
1850
  ${i18next.t('figure.label.mode-' + mode)}
1827
1851
  </button>`)}
1828
- <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1852
+ ${status ? html `<span slot="status" class="status" role="status">${i18next.t(status)}</span>` : nothing}
1829
1853
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1830
1854
  ${blockers.length
1831
1855
  ? html `<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
@@ -1907,7 +1931,6 @@ let FigureEditor = class FigureEditor extends LitElement {
1907
1931
  <figure-canvas
1908
1932
  .view=${this.view}
1909
1933
  .parts=${this.drawnParts(view.geometry, live)}
1910
- .anchors=${this.anchorsOf(asset)}
1911
1934
  .fan=${this.fanOf(shown, live)}
1912
1935
  .ghost=${this.previewShown().ghost}
1913
1936
  .highlight=${this.previewShown().highlight}
@@ -1915,13 +1938,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1915
1938
  .palette=${view.palette ?? {}}
1916
1939
  .context=${view.context ?? ''}
1917
1940
  .selected=${this.selected}
1941
+ ?aiming=${this.fastening}
1918
1942
  .reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
1943
+ @part-sizing=${(e) => this.resizing(e.detail.id, e.detail.result)}
1919
1944
  @part-transformed=${(e) => this.transformed(e.detail.id, e.detail.result)}
1920
1945
  @part-selected=${(e) => this.choose(e.detail)}
1946
+ @part-aimed=${(e) => this.fastenWindow?.aimAt?.(e.detail)}
1921
1947
  @render-error=${(e) => {
1922
1948
  this.error = e.detail.message;
1923
1949
  }}
1924
- ></figure-canvas></div>
1950
+ ></figure-canvas>${this.renderSameMeasure(unavailable)}</div>
1925
1951
  <aside slot="end">
1926
1952
  <div tabs>
1927
1953
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1929,7 +1955,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1929
1955
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1930
1956
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1931
1957
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1932
- ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1958
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'))}
1933
1959
  </div>
1934
1960
  <slot name="notice"></slot>
1935
1961
  <div panel ?off=${tab !== 'properties'}>
@@ -2005,7 +2031,7 @@ let FigureEditor = class FigureEditor extends LitElement {
2005
2031
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
2006
2032
  </aside>
2007
2033
  </ox-three-pane>
2008
- ${this.renderOverlay()} ${this.renderFastening(unavailable)}`;
2034
+ ${this.renderOverlay()}`;
2009
2035
  }
2010
2036
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
2011
2037
  renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null, held) {
@@ -2076,6 +2102,12 @@ let FigureEditor = class FigureEditor extends LitElement {
2076
2102
  * rule" named nothing): a fastened part's place and turn are its fastening's; other numbers are worked out from
2077
2103
  * something else. A size that follows something is said at the panel's head, so not again here.
2078
2104
  */
2105
+ /** 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). */
2106
+ renderSizeKeeps() {
2107
+ const asset = this.asset();
2108
+ const kept = asset && this.selected ? keptBySizeOf(asset, this.selected) : undefined;
2109
+ return kept ? html `<p hint size-keeps>${i18next.t(SIZE_KEEPS_WORDS[kept])}</p>` : nothing;
2110
+ }
2079
2111
  renderHeldAxes(vectors) {
2080
2112
  const fastened = !!(this.asset())?.joints?.some(j => j.body1.part === ownerOf(this.selected));
2081
2113
  const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && ['x', 'y', 'z'].some(axis => !g.cells[axis]));
@@ -2196,10 +2228,6 @@ __decorate([
2196
2228
  property({ type: Number }),
2197
2229
  __metadata("design:type", Object)
2198
2230
  ], FigureEditor.prototype, "revision", void 0);
2199
- __decorate([
2200
- property({ type: Number }),
2201
- __metadata("design:type", Number)
2202
- ], FigureEditor.prototype, "instances", void 0);
2203
2231
  __decorate([
2204
2232
  property({ attribute: false }),
2205
2233
  __metadata("design:type", Object)
@@ -2224,10 +2252,6 @@ __decorate([
2224
2252
  state(),
2225
2253
  __metadata("design:type", Object)
2226
2254
  ], FigureEditor.prototype, "faceMapping", void 0);
2227
- __decorate([
2228
- state(),
2229
- __metadata("design:type", Object)
2230
- ], FigureEditor.prototype, "retry", void 0);
2231
2255
  __decorate([
2232
2256
  state(),
2233
2257
  __metadata("design:type", String)
@@ -2284,6 +2308,10 @@ __decorate([
2284
2308
  state(),
2285
2309
  __metadata("design:type", Object)
2286
2310
  ], FigureEditor.prototype, "live", void 0);
2311
+ __decorate([
2312
+ state(),
2313
+ __metadata("design:type", Object)
2314
+ ], FigureEditor.prototype, "sizing", void 0);
2287
2315
  __decorate([
2288
2316
  state(),
2289
2317
  __metadata("design:type", Object)