@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
@@ -6,7 +6,7 @@ import { openPopup } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -30,12 +30,13 @@ import './figure-preview.js'
30
30
  import './figure-settings.js'
31
31
  import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
32
32
  import './figure-capability-panel.js'
33
- import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './gizmo.js'
33
+ import { gizmoChanges, gizmoReachOf, sameMeasureAxesOf, sizeKeepOf, type GizmoReach, type GizmoResult } from './gizmo.js'
34
+ import { keptBySizeOf, partsAtFrame, SIZE_KEEPS_WORDS, startSizeDrag, throughPlaneOf, type SizeDrag, type SizeDragFrame } from './size-drag.js'
34
35
  import type { KeyAction } from './keys.js'
35
36
  import { ModellerKeys } from './modeller-keys.js'
36
37
  import { PaneFold } from './pane-fold.js'
37
38
  import { PanelMemory } from './panel-memory.js'
38
- import { RefusalTeller, type Retry } from './refusal-teller.js'
39
+ import { RefusalTeller } from './refusal-teller.js'
39
40
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
40
41
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
41
42
  import { EditorStyles } from './editor-styles.js'
@@ -52,17 +53,18 @@ const NO_MARKS: FigureRenderPart[] = []
52
53
  import { apexOver, drawnOf } from './section-drawing.js'
53
54
  import { END_FACE } from './section-controls.js'
54
55
  import type { FigureCanvas } from './canvas.js'
55
- import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
56
+ import { nudgeOf, placementOf } from './stage-model.js'
56
57
 
57
58
  /** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
58
59
  type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
59
60
  import { occupancyFromParts, roomRefitRowsOf } from './occupancy.js'
60
61
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
61
62
  import type { FigureGateFinding } from '../types.js'
62
- import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
63
+ import { editRefusalOf, inDeclaredRoom, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
63
64
  import type { FigureRenderPart } from './renderer.js'
64
65
  import type { FigureBudgetLine } from './maturity.js'
65
- import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
66
+ import { HelpToggles } from './help.js'
67
+ import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
66
68
  import { jointFanOf } from './joint-fan.js'
67
69
  import { derivedOf, type FigureRevision } from './revision.js'
68
70
  import { jointDragOf } from './joint-drag.js'
@@ -114,8 +116,8 @@ export class FigureEditor extends LitElement {
114
116
  /** Why the last recipe was refused, in the kernel's words. */
115
117
  @state() private recipeError = ''
116
118
  @property({ type: Number }) revision = 0
117
- /** The verdict's count, drawn on its tab so it stays in sight while another tab is open. */
118
- @property({ type: Number }) instances?: number
119
+ /** The 「?」 beside a group's title and the sentence it opens. */
120
+ private readonly help = new HelpToggles(this)
119
121
  /** The cost line the parts list shows, from the maturity report. */
120
122
  @property({ attribute: false }) budget?: FigureBudgetLine
121
123
  /**
@@ -133,18 +135,25 @@ export class FigureEditor extends LitElement {
133
135
  * edit was made (`from`).
134
136
  */
135
137
  @state() private faceMapping?: { action: FigureAuthoringAction; need: FigureFaceMappingNeed; from: 'section-editor' | 'panel' }
136
- /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
137
- @state() private retry?: Retry
138
138
  @state() private tab: SideTab = 'properties'
139
139
  /** The view settings or the release history, open over the work from the bar. */
140
140
  @state() private overlay?: BarOverlay
141
141
  /**
142
- * The fastening window open for the selected part (user 2026-10-17): chosen there, applied once. It stands over the
143
- * side panel, not over the canvas, so what is chosen is seen in 3D as it is chosen (user 2026-10-19: the standard
144
- * popup, always modal, covered the preview). It closes with nothing written when the part, the tab, the middle or a
145
- * bar panel changes under it.
142
+ * The fastening window open for the selected part (user 2026-10-17), in the standard popup, moved by its head
143
+ * (2026-10-18; put back 2026-10-20 -- the side panel it was moved to could not be moved), with no backdrop, so the
144
+ * canvas can be pressed while it is open: chosen there, applied once. It closes with nothing written when the part,
145
+ * the tab or the middle changes under it.
146
146
  */
147
147
  @state() private fastening = false
148
+ private fastenPopup?: { close: () => void }
149
+ private fastenWindow?: HTMLElement & {
150
+ revision?: FigureRevision
151
+ partId: string
152
+ locked: boolean
153
+ previewWords: string[]
154
+ error: string
155
+ aimAt?: (at: { id: string; point?: number[]; normal?: number[] }) => void
156
+ }
148
157
  /** Why the window's 「적용」 was refused, said in it. */
149
158
  @state() private fastenError = ''
150
159
  /** The face of the part a row of the window's list is on, lit on the canvas while the pointer or the arrows are there. */
@@ -205,6 +214,8 @@ export class FigureEditor extends LitElement {
205
214
  @state() private hiddenParts = new Set<string>()
206
215
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
207
216
  @state() private live: LiveValues = NOTHING_LIVE
217
+ /** A size drag in hand, drawn from figure-model each frame (ADR-0140 4.4); gone with the release. */
218
+ @state() private sizing?: { part: string; drag: SizeDrag; frame?: SizeDragFrame; through?: number }
208
219
  /** The part whose drawn section is open in the section editor (ADR-0100 §3.2), or null. */
209
220
  @state() private sectionEditing: string | null = null
210
221
  private lastContext: string | null = null
@@ -234,7 +245,7 @@ export class FigureEditor extends LitElement {
234
245
  aria-pressed=${String(this.gizmoMode === tool.mode)}
235
246
  ?disabled=${!tool.can}
236
247
  aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
237
- 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'))}`}
248
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
238
249
  @click=${() => this.useGizmo(tool.mode)}
239
250
  >
240
251
  <md-icon>${tool.icon}</md-icon>
@@ -255,6 +266,14 @@ export class FigureEditor extends LitElement {
255
266
  selected: () => this.selected,
256
267
  act: action => this.keyAction(action)
257
268
  })
269
+ /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
270
+ private copied = ''
271
+ /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
272
+ private pasteCopied() {
273
+ const asset = this.session?.revisionNow()?.asset
274
+ if (!this.copied || !asset?.document.model.nodes.some(n => n.op === 'place@1' && n.id === this.copied)) return
275
+ this.duplicatePart(this.copied)
276
+ }
258
277
  private keyAction(action: KeyAction) {
259
278
  const view = this.session!.read()
260
279
  const canvas = this.renderRoot.querySelector<FigureCanvas>('figure-canvas')
@@ -262,6 +281,9 @@ export class FigureEditor extends LitElement {
262
281
  else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
263
282
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
264
283
  else if (action === 'duplicate') this.duplicatePart(this.selected)
284
+ else if (action === 'copy') this.copied = this.selected
285
+ else if (action === 'paste') this.pasteCopied()
286
+ else if (action === 'hold') return
265
287
  else if (action === 'remove') this.removePart(this.selected)
266
288
  else this.selected = ''
267
289
  }
@@ -270,9 +292,7 @@ export class FigureEditor extends LitElement {
270
292
  session: () => this.session,
271
293
  error: () => this.error,
272
294
  notice: () => this.notice,
273
- retry: () => this.retry,
274
- refuse: message => (this.error = message),
275
- command: action => this.linkCommand(action)
295
+ refuse: message => (this.error = message)
276
296
  })
277
297
  private panelMemory = new PanelMemory(() => this.renderRoot)
278
298
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
@@ -292,7 +312,7 @@ export class FigureEditor extends LitElement {
292
312
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
293
313
  if (_changed.has('selected')) this.gizmoMode = undefined
294
314
  /* The window is about the part chosen on the properties tab, being edited: it does not follow anywhere else. */
295
- if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit' || this.overlay)) this.closeFastening()
315
+ if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit')) this.closeFastening()
296
316
  /*
297
317
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
298
318
  * own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
@@ -318,17 +338,22 @@ export class FigureEditor extends LitElement {
318
338
  if (closed && this.selected === editing) this.selected = next
319
339
  })
320
340
  }
341
+ /** While the size tool is on a round part: which of its handles change the one diameter (ADR-0140 4.1). */
342
+ private renderSameMeasure(unavailable: boolean) {
343
+ if (unavailable || this.gizmoMode !== 'scale' || !this.selected) return nothing
344
+ const axes = sameMeasureAxesOf(this.reachOf(this.selected))
345
+ return axes
346
+ ? html`<p same-measure role="status">${i18next.t('figure.text.gizmo-same-measure', { axes: axes.map(a => a.toUpperCase()).join(' · ') })}</p>`
347
+ : nothing
348
+ }
321
349
  /** The scene toggles: the mode already shown takes the gizmo away. */
322
350
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
323
351
  /*
324
352
  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
325
- 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
326
- its size fields.
353
+ put on it to drag in vain (:3300, 10.1.196) -- the tool's own words are said instead.
327
354
  */
328
355
  if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale' && !this.reachOf(this.selected).scale) {
329
- const asset = this.asset()
330
- if (asset && this.sizeDragTurned(asset, this.selected)) this.tellSizeByFields(this.selected)
331
- else this.refusals.say(i18next.t('figure.text.gizmo-not-own'))
356
+ this.refusals.say(i18next.t('figure.text.gizmo-not-own'))
332
357
  return
333
358
  }
334
359
  this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.gizmo(mode)
@@ -354,6 +379,7 @@ export class FigureEditor extends LitElement {
354
379
  } finally {
355
380
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
356
381
  this.live = NOTHING_LIVE
382
+ this.sizing = undefined
357
383
  }
358
384
  }
359
385
  /** The bodies drawn with the value in hand, or null when nothing is in hand (or the figure cannot take it). */
@@ -363,7 +389,8 @@ export class FigureEditor extends LitElement {
363
389
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
364
390
  private drawnParts(geometry: Parameters<typeof drawableFigureParts>[0] | undefined, live: FigureRenderPart[] | null) {
365
391
  const parts = live ?? drawableFigureParts(geometry ?? [])
366
- return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
392
+ const frame = this.sizing?.frame
393
+ return (frame ? partsAtFrame(parts, frame.moved) : parts).filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
367
394
  }
368
395
  /**
369
396
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -400,6 +427,9 @@ export class FigureEditor extends LitElement {
400
427
  }
401
428
 
402
429
  private changed() {
430
+ /* What the step did to the size ends the author typed, said with whatever else it said. */
431
+ const fits = this.session?.takeSizeRangeFits() ?? []
432
+ if (fits.length) this.notice = [this.notice, sizeRangeFitsOf(fits, (key, params) => i18next.t(key, params))].filter(Boolean).join(' ')
403
433
  this.requestUpdate()
404
434
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
405
435
  }
@@ -495,7 +525,6 @@ export class FigureEditor extends LitElement {
495
525
  this.run(() => this.session!.edit({ kind: 'move-part', target: id, to }))
496
526
  }
497
527
  private removePart(id: string, refitRoom = false) {
498
- this.retry = undefined
499
528
  try {
500
529
  const view = this.session!.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) }) as { removal?: Parameters<typeof removalNoticeOf>[0] }
501
530
  this.error = ''
@@ -563,60 +592,42 @@ export class FigureEditor extends LitElement {
563
592
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
564
593
  try {
565
594
  const asset = this.shown()!.asset
566
- const reach = gizmoReachOf(asset, id)
567
- /*
568
- A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
569
- say which face that is -- a part fastened turned or twisted -- the drag would show one result and write another,
570
- so sizing by the gizmo is not offered there; the fields still size it (designer 2026-09-27).
571
- */
572
- if (reach.scale && this.sizeDragUnsure(asset, id)) return { ...reach, scale: false }
573
- return reach
595
+ /* 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). */
596
+ return gizmoReachOf(asset, id)
574
597
  } catch {
575
598
  return none
576
599
  }
577
600
  }
578
601
  /**
579
- * Whether this part's size is its own to drag but the drag is withheld because it is turned: then the size fields are
580
- * 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
581
- * its own to drag at all; it is not told it is attached turned, nor sent to fields it cannot type in (:3300,
582
- * 2026-10-19, the fan's blade-2). Its tool says it is placed by something else, as before.
602
+ * A size drag in hand, a frame of it: the sizes the handle comes to, through figure-model's size function and the
603
+ * evaluator stepped to them, drawn and written nowhere (ADR-0140 4.4). Begun at the first frame, from the figure as it
604
+ * is drawn and the handle the scene says was held; the release writes the last frame's changes.
583
605
  */
584
- private sizeDragTurned(asset: FigureAsset, id: string): boolean {
585
- try {
586
- return gizmoReachOf(asset, id).scale && this.sizeDragUnsure(asset, id)
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
- private sizeDragUnsure(asset: FigureAsset, id: string): boolean {
606
+ private resizing(id: string, result: GizmoResult) {
607
+ const part = ownerOf(id)
608
+ const shown = this.shown()
609
+ if (!shown || !result.scale) return
593
610
  try {
594
- return !figureScaleAnchorOf(asset, ownerOf(id))
611
+ const drawn = drawableFigureParts(this.session!.read().geometry ?? [])
612
+ if (this.sizing?.part !== part)
613
+ this.sizing = { part, drag: startSizeDrag(shown.asset, part, shown.inputs, sizeKeepOf(result.grab)), through: throughPlaneOf(shown.asset, drawn, part) }
614
+ const sizes = gizmoChanges(id, this.reachOf(id), { scale: result.scale }, { ...shown.inputs })
615
+ this.sizing = { ...this.sizing, frame: this.sizing.drag.frame(sizes) }
595
616
  } catch {
596
- return true
617
+ /* A size the figure cannot take on the way: the figure as it stands is drawn, and the release says why. */
618
+ this.sizing = undefined
597
619
  }
598
620
  }
599
621
  /**
600
- * Why this part's size is not dragged, in one line (user 2026-10-19: the reason told the faces kept, and was not
601
- * understood): attached turned or twisted, or turned off its upright.
622
+ * A free part a size change takes past the mounting plane: said, with 「…에 붙이기」 to fasten it there (ADR-0140 5). The
623
+ * size rule is not changed for it: what the author picks is the fastening, one step of the history.
602
624
  */
603
- private sizeDragRefusalOf(id: string): string {
604
- const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(id))
605
- return i18next.t(fastened ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-size-tilted', { part: partLabelOf(id) })
606
- }
607
- /** The size drag refused, said as a notice with the way to the part's size fields. */
608
- private tellSizeByFields(id: string) {
609
- this.refusals.point(this.sizeDragRefusalOf(id), { label: i18next.t('figure.button.to-size-fields'), run: () => this.toSizeFields(id) })
610
- }
611
- /** To the part's first size field it takes typed, on the properties tab, ready to type over. */
612
- private async toSizeFields(id: string) {
613
- this.middle = 'edit'
614
- this.tab = 'properties'
615
- this.selected = id
616
- await this.updateComplete
617
- const field = this.renderRoot.querySelector<HTMLInputElement>('[slot="size"] input:not([disabled]):not([readonly])')
618
- field?.focus()
619
- field?.select()
625
+ private tellThroughPlane(part: string, mm: number) {
626
+ const plane = i18next.t('figure.label.resize-place-' + (this.asset()?.placement ?? 'floor'))
627
+ this.refusals.point(i18next.t('figure.text.size-through-plane', { part: partLabelOf(part), plane, mm: String(mm) }), {
628
+ label: i18next.t('figure.button.fasten-to-plane', { plane }),
629
+ run: () => this.run(() => this.session!.command({ kind: 'attach', part, to: { plane: 'mounting-plane' } }))
630
+ })
620
631
  }
621
632
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
622
633
  private transformed(id: string, result: GizmoResult) {
@@ -642,21 +653,21 @@ export class FigureEditor extends LitElement {
642
653
  the shift had been taken for a move of the joint, and the size was dropped).
643
654
  */
644
655
  const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
645
- /* A size drag this part cannot show truly is not written: it springs back, and the reason is said. */
646
- if (result.scale && result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragTurned(asset, id)) {
647
- this.tellSizeByFields(id)
648
- return this.requestUpdate()
649
- }
650
656
  /*
651
- A size dragged keeps the face the figure's placement keeps, as a size typed does (figure-model's one rule): the
652
- gizmo's own anchoring on the opposite face is what the drag shows, not what is written (designer 2026-09-27).
657
+ A size dragged is written as its last frame was drawn: figure-model's one size function, keeping the side opposite
658
+ the handle held -- or, fastened, its fastening (ADR-0140 4.1 · 4.4). What the scene put the part at is not read.
653
659
  */
654
660
  const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4)
655
661
  const taken = jointed || scaled ? { scale: result.scale } : result
656
662
  const written = gizmoChanges(id, this.reachOf(id), taken, sizes)
657
- const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written
663
+ const drawnLast = this.sizing?.part === ownerOf(id) ? this.sizing.frame : undefined
664
+ const changes = !scaled ? written : drawnLast ? drawnLast.changes : figureSizeEditOf(asset, ownerOf(id), written, sizeKeepOf(result.grab)).changes
658
665
  if (changes.length) {
666
+ const through = scaled ? (this.sizing?.part === ownerOf(id) ? this.sizing.through : undefined) ?? throughPlaneOf(asset, drawableFigureParts(view.geometry), ownerOf(id)) : 0
659
667
  this.run(() => this.session!.apply(changes))
668
+ /* 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). */
669
+ const now = scaled ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), ownerOf(id)) : 0
670
+ if (now > through + 0.5) this.tellThroughPlane(ownerOf(id), now)
660
671
  return
661
672
  }
662
673
  if (jointed && result.scale) {
@@ -850,6 +861,7 @@ export class FigureEditor extends LitElement {
850
861
  ${groups.singles.length
851
862
  ? html`<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
852
863
  : nothing}
864
+ ${this.renderSizeKeeps()}
853
865
  </div>`
854
866
  : nothing}
855
867
  ${place.length && !placeLocked
@@ -934,24 +946,6 @@ export class FigureEditor extends LitElement {
934
946
  return html`${panel('view', 'figure.label.tab-environment', html`<figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>`)}
935
947
  ${panel('history', 'figure.button.release', html`<slot name="history"></slot>`)}`
936
948
  }
937
- /**
938
- * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
939
- * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
940
- * rule can say, and the gizmo keeps its own (the face across from the handle).
941
- */
942
- private anchorsOf(asset: FigureAsset | undefined): Record<string, ScaleAnchor> {
943
- let next: Record<string, ScaleAnchor> = {}
944
- try {
945
- const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined
946
- if (anchor) next = { [this.selected]: anchor }
947
- } catch {
948
- next = {}
949
- }
950
- /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
951
- if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors)) this.lastAnchors = next
952
- return this.lastAnchors
953
- }
954
- private lastAnchors: Record<string, ScaleAnchor> = {}
955
949
  /** Data tried in the data tab, given to the preview's figures as their `data`; null when none is (ADR-0051 §8). */
956
950
  @state() private dataSample: unknown = null
957
951
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
@@ -988,7 +982,6 @@ export class FigureEditor extends LitElement {
988
982
  private linkCommand(action: FigureAuthoringAction, from: 'section-editor' | 'panel' = 'panel'): boolean {
989
983
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
990
984
  this.holdAction(null)
991
- this.retry = undefined
992
985
  this.notice = ''
993
986
  const hadSize = this.hasSize()
994
987
  const hadSpace = !!this.shown()?.asset.occupancy
@@ -1019,12 +1012,6 @@ export class FigureEditor extends LitElement {
1019
1012
  this.error = ''
1020
1013
  return false
1021
1014
  }
1022
- const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
1023
- if (narrowing) {
1024
- this.error = narrowing.message
1025
- this.retry = { label: narrowing.label, action: narrowing.retry as FigureAuthoringAction }
1026
- return false
1027
- }
1028
1015
  this.error =
1029
1016
  code === 'DIMENSION_CYCLE'
1030
1017
  ? i18next.t('figure.text.follow-would-loop')
@@ -1037,17 +1024,38 @@ export class FigureEditor extends LitElement {
1037
1024
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
1038
1025
  const input = event.target as HTMLInputElement,
1039
1026
  value = input.valueAsNumber
1040
- /* 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. */
1027
+ /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1041
1028
  const part = this.selected
1042
1029
  const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1043
- this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.shown()!.asset, part, [change]) : [change]))
1030
+ const asset = this.shown()?.asset
1031
+ const before = asset && part ? throughPlaneOf(asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1032
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditOf(this.shown()!.asset, part, [change], { keep: 'centre' }).changes : [change]))
1033
+ /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1034
+ const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1035
+ if (after > before + 0.5) this.tellThroughPlane(part, after)
1044
1036
  // Rejected edits must also restore the displayed numeric value.
1045
1037
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1046
1038
  }
1047
1039
  /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
1048
- private typing(field: { id: string }, event: Event) {
1040
+ private typing(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, event: Event) {
1049
1041
  const value = (event.target as HTMLInputElement).valueAsNumber
1050
- if (Number.isFinite(value)) this.hold({ [field.id]: value })
1042
+ if (Number.isFinite(value)) this.hold(this.heldOf(field, value))
1043
+ }
1044
+ /**
1045
+ * The values in hand for a number typed or scrubbed: what its release writes -- a size with the part's middle kept,
1046
+ * or its fastening (figure-model's one size function, ADR-0140 4.1), so the figure does not jump on the release.
1047
+ */
1048
+ private heldOf(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, value: number, sized?: ReturnType<typeof figureSizeEditorOf>): Record<string, number> {
1049
+ const part = this.selected
1050
+ const asset = this.shown()?.asset
1051
+ if (field.role === 'state' || !part || !asset) return { [field.id]: value }
1052
+ const change: FigureInputChange = { kind: 'set-design', input: field.id, unit: field.unit ?? 'mm', value }
1053
+ try {
1054
+ const { changes } = sized ? sized([change], { keep: 'centre' }) : figureSizeEditOf(asset, part, [change], { keep: 'centre' })
1055
+ return Object.fromEntries(changes.map(c => [c.input, c.value as number]))
1056
+ } catch {
1057
+ return { [field.id]: value }
1058
+ }
1051
1059
  }
1052
1060
  /**
1053
1061
  * Drag a field's label (or an axis letter) sideways to scrub its value, as vector tools do: the figure follows the
@@ -1062,10 +1070,13 @@ export class FigureEditor extends LitElement {
1062
1070
  label.setPointerCapture(down.pointerId)
1063
1071
  const start = field.value
1064
1072
  let value = start
1073
+ /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1074
+ const asset = this.shown()?.asset
1075
+ const sized = asset && this.selected && field.role !== 'state' ? (() => { try { return figureSizeEditorOf(asset, this.selected) } catch { return undefined } })() : undefined
1065
1076
  const move = (e: PointerEvent) => {
1066
1077
  value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey))
1067
1078
  input.value = String(value)
1068
- this.hold({ [field.id]: value })
1079
+ this.hold(this.heldOf(field, value, sized))
1069
1080
  }
1070
1081
  const up = (e: PointerEvent) => {
1071
1082
  label.removeEventListener('pointermove', move)
@@ -1085,75 +1096,82 @@ export class FigureEditor extends LitElement {
1085
1096
  label.addEventListener('pointerup', up)
1086
1097
  label.addEventListener('pointercancel', up)
1087
1098
  }
1088
- /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
1099
+ /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1089
1100
  private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
1090
1101
  if (!shown) return nothing
1091
1102
  const asset = shown.asset
1092
- /* Measured once per version: measuring evaluates the figure at its edges. */
1103
+ /* Computed once per version: figure-model evaluates the figure at its edges. */
1093
1104
  const measured = derivedOf(shown, 'instance-range', () => {
1094
1105
  let of: ReturnType<typeof instanceRangeOf>
1095
- let toDeclare: ReturnType<typeof instanceRangeToDeclare>
1106
+ let toDeclare: ReturnType<typeof instanceSizeToDeclare>
1096
1107
  try {
1097
1108
  of = instanceRangeOf(asset)
1098
1109
  } catch {
1099
1110
  of = undefined
1100
1111
  }
1101
1112
  try {
1102
- toDeclare = of ? undefined : instanceRangeToDeclare(asset)
1113
+ toDeclare = of ? undefined : instanceSizeToDeclare(asset)
1103
1114
  } catch {
1104
1115
  toDeclare = undefined
1105
1116
  }
1106
1117
  return { of, toDeclare }
1107
1118
  })
1108
1119
  const range = measured.of
1109
- /* No size yet: the figure is declared its size from its parts, so a board can resize it and parts can follow it. */
1120
+ /*
1121
+ In words a person reads (user 2026-10-19/20): what the figure does on a drawing, not the inputs behind it. Fixed,
1122
+ it says the size it is always drawn at; resizable, the sizes it may take on each axis and the parts that change
1123
+ with it. The way to it stays one press -- a figure no part follows (a robot scaled whole) reaches the one-ratio
1124
+ rule only from here -- and the help mark says how parts are made to follow.
1125
+ */
1126
+ const mm = (v: number) => Math.round(v).toLocaleString()
1110
1127
  const toDeclare = measured.toDeclare
1128
+ const title = (explained: string) =>
1129
+ html`<h3>${i18next.t('figure.label.board-size')}${this.help.button(explained)}</h3>${this.help.hint(explained)}`
1111
1130
  if (!range && toDeclare)
1112
- return html`<section>
1113
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1114
- <p hint>${i18next.t('figure.text.instance-size-undeclared')}</p>
1115
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })}>
1116
- <md-icon>straighten</md-icon>${i18next.t('figure.button.declare-instance-size')}
1131
+ return html`<section board-size>
1132
+ ${title('figure.text.board-size-fixed-explained')}
1133
+ <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
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', at: toDeclare.at })}>
1135
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1117
1136
  </button>
1118
1137
  </section>`
1119
1138
  if (!range) return nothing
1120
- const mm = (v: number) => Math.round(v).toLocaleString()
1121
- const differs = rangeDiffers(range.declared, range.measured)
1122
- return html`<section>
1123
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1139
+ const followers = sizeFollowersOf(asset)
1140
+ /*
1141
+ An end the author typed shows its number; an open end is empty, with what the parts allow written faintly in it
1142
+ (ADR-0087 decision 6). Typing narrows that end, emptying the field opens it again.
1143
+ */
1144
+ const shownEnd = (r: (typeof range)['x'], bound: 'min' | 'max') => (bound === 'max' && !Number.isFinite(r.max) ? '∞' : mm(r[bound]))
1145
+ return html`<section board-size>
1146
+ ${title('figure.text.board-size-range-explained')}
1124
1147
  ${(['x', 'y', 'z'] as const).map(
1125
1148
  axis => html`<div inline-field>
1126
- <label>${axis.toUpperCase()} <span unit>mm</span></label>
1127
- <div pair>
1149
+ <label>${i18next.t('figure.label.resize-' + axis)} <span unit>mm</span></label>
1150
+ <div pair range>
1128
1151
  ${(['min', 'max'] as const).map(
1129
- bound => html`<input
1152
+ (bound, i) => html`${i ? html`<span dash aria-hidden="true">–</span>` : nothing}<input
1130
1153
  aria-label=${`size.${axis}.${bound}`}
1131
1154
  type="number"
1132
1155
  step="any"
1133
- .value=${live(String(Math.round(range.declared[axis][bound])))}
1156
+ .value=${live(range[axis].narrowed[bound] ? String(Math.round(range[axis][bound])) : '')}
1157
+ placeholder=${shownEnd(range[axis], bound)}
1134
1158
  ?disabled=${unavailable}
1135
1159
  @change=${(e: Event) => {
1136
- const next = rangeWith(range.declared, axis, bound, (e.target as HTMLInputElement).valueAsNumber)
1137
- if (next) this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true })
1160
+ const typed = (e.target as HTMLInputElement).valueAsNumber
1161
+ this.linkCommand({ kind: 'declare-instance-size', range: rangeWith(range, axis, bound, typed), replace: true })
1138
1162
  }}
1139
1163
  />`
1140
1164
  )}
1141
1165
  </div>
1142
1166
  </div>`
1143
1167
  )}
1144
- ${differs
1145
- ? html`<p hint>
1146
- ${i18next.t('figure.text.instance-range-can-widen', {
1147
- ranges: (['x', 'y', 'z'] as const)
1148
- .filter(axis => rangeDiffers(range.declared, { ...range.declared, [axis]: range.measured[axis] }))
1149
- .map(axis => `${axis.toUpperCase()} ${mm(range.measured[axis].min)} – ${mm(range.measured[axis].max)}`)
1150
- .join(' · ')
1151
- })}
1152
- </p>
1153
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })}>
1154
- <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
1155
- </button>`
1156
- : nothing}
1168
+ <p followers>
1169
+ ${followers.length
1170
+ ? i18next.t('figure.text.board-size-followers', {
1171
+ 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(', ')
1172
+ })
1173
+ : i18next.t('figure.text.board-size-no-followers')}
1174
+ </p>
1157
1175
  ${this.renderSizingRule(asset, unavailable)}
1158
1176
  </section>`
1159
1177
  }
@@ -1309,7 +1327,7 @@ export class FigureEditor extends LitElement {
1309
1327
  this.publishing?.part === part
1310
1328
  ? this.publishing
1311
1329
  : listed && input
1312
- ? { part, label: listed.label, min: input.min, max: input.max }
1330
+ ? /* A published input has both ends (figure-model checkPublicInput). */ { part, label: listed.label, min: input.min!, max: input.max! }
1313
1331
  : undefined
1314
1332
  const open = !!form
1315
1333
  const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
@@ -1488,44 +1506,49 @@ export class FigureEditor extends LitElement {
1488
1506
  this.openFastening()
1489
1507
  }
1490
1508
  private openFastening() {
1491
- if (!this.selected || this.fastening) return
1509
+ if (!this.selected || this.fastenPopup) return
1492
1510
  this.fastenError = ''
1493
1511
  this.fastenHover = null
1494
- /* One panel over the side at a time: the view or the history gives way to the window. */
1495
- this.overlay = undefined
1496
1512
  this.fastening = true
1513
+ const content = document.createElement('figure-fasten-dialog') as NonNullable<typeof this.fastenWindow>
1514
+ content.addEventListener('preview-command', e => this.commandPreview.ask((e as CustomEvent).detail))
1515
+ content.addEventListener('fasten-hover', e => this.hoverFastening((e as CustomEvent).detail))
1516
+ content.addEventListener('fasten-apply', e => this.applyFastening((e as CustomEvent).detail.action))
1517
+ content.addEventListener('fasten-cancel', () => this.closeFastening())
1518
+ this.fastenWindow = content
1519
+ this.tellFastening()
1520
+ /* No backdrop: the canvas stays reachable while it is open -- a part pressed there is aimed at (ADR-0140 stage 4). */
1521
+ const popup = openPopup(html`${content}`, { title: i18next.t('figure.label.fastening'), size: 'fit', backdrop: false, movable: true, closable: true, escapable: true })
1522
+ this.fastenPopup = popup
1523
+ /* Closed by its own X or Esc as well: the editor lets go of it the same way. */
1524
+ popup.result.then(() => this.fastenPopup === popup && this.fastenClosed())
1497
1525
  }
1498
1526
  private closeFastening() {
1527
+ const popup = this.fastenPopup
1528
+ this.fastenClosed()
1529
+ popup?.close()
1530
+ }
1531
+ private fastenClosed() {
1532
+ this.fastenPopup = undefined
1533
+ this.fastenWindow = undefined
1499
1534
  this.fastening = false
1500
1535
  this.fastenError = ''
1501
1536
  this.fastenHover = null
1502
1537
  }
1503
- /**
1504
- * The fastening window over the side panel, as the bar's panels stand there: the figure on screen, the part, and what
1505
- * the preview and a refusal said are the editor's. Its own Esc and 「취소」 close it, as its X does.
1506
- */
1507
- private renderFastening(unavailable: boolean) {
1508
- if (!this.fastening || !this.selected) return nothing
1509
- return html`<div side-overlay fasten-panel>
1510
- <div overlay-head>
1511
- <h3>${i18next.t('figure.label.fastening')}</h3>
1512
- <button quiet fasten-close aria-label=${i18next.t('figure.button.close')} title=${i18next.t('figure.button.close')} @click=${() => this.closeFastening()}>
1513
- <md-icon>close</md-icon>
1514
- </button>
1515
- </div>
1516
- <figure-fasten-dialog
1517
- overlay-body
1518
- .revision=${this.shown()}
1519
- .partId=${this.selected}
1520
- ?locked=${unavailable}
1521
- .previewWords=${this.commandPreview.words()}
1522
- .error=${this.fastenError}
1523
- @preview-command=${(e: CustomEvent) => this.commandPreview.ask(e.detail)}
1524
- @fasten-hover=${(e: CustomEvent) => this.hoverFastening(e.detail)}
1525
- @fasten-apply=${(e: CustomEvent) => this.applyFastening(e.detail.action)}
1526
- @fasten-cancel=${() => this.closeFastening()}
1527
- ></figure-fasten-dialog>
1528
- </div>`
1538
+ /** What the window shows is the editor's: the figure on screen, the part, and what the preview and a refusal said. */
1539
+ private tellFastening() {
1540
+ const content = this.fastenWindow
1541
+ const view = this.session?.read()
1542
+ if (!content || !view) return
1543
+ content.revision = this.shown()
1544
+ content.partId = this.selected
1545
+ content.locked = view.loading || !!view.error || view.saving || !view.context
1546
+ content.previewWords = this.commandPreview.words()
1547
+ content.error = this.fastenError
1548
+ }
1549
+ disconnectedCallback() {
1550
+ super.disconnectedCallback()
1551
+ this.closeFastening()
1529
1552
  }
1530
1553
  /** 「적용」: the command, one step; refused, the window stays open and says why. */
1531
1554
  private applyFastening(action: FigureAuthoringAction) {
@@ -1584,15 +1607,16 @@ export class FigureEditor extends LitElement {
1584
1607
  return this.shown()?.asset
1585
1608
  }
1586
1609
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1587
- private renderTab(tab: SideTab, icon: string, label: string, badge?: number) {
1610
+ private renderTab(tab: SideTab, icon: string, label: string) {
1588
1611
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1589
1612
  const name = i18next.t('figure.label.tab-short-' + tab)
1590
1613
  return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1591
- <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
1614
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1592
1615
  </button>`
1593
1616
  }
1594
1617
  updated(changed: PropertyValues<this>) {
1595
1618
  super.updated?.(changed)
1619
+ this.tellFastening()
1596
1620
  /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1597
1621
  if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen) this.blockersOpen = false
1598
1622
  this.refusals.tell(changed)
@@ -1617,7 +1641,8 @@ export class FigureEditor extends LitElement {
1617
1641
  if (!part || !drawn || part.shape.dimensions.jamb !== undefined) return nothing
1618
1642
  const asset = this.asset() as FigureAsset
1619
1643
  const measures = figureSectionMeasuresOf(asset, this.selected)
1620
- const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis]
1644
+ /* What a measure typed here keeps: the part's middle, or its fastening (ADR-0140 4.1). */
1645
+ const kept = keptBySizeOf(asset, this.selected)
1621
1646
  return html`<figure-section-editor
1622
1647
  .body=${{ ...part, id: this.selected }}
1623
1648
  .measures=${measures}
@@ -1765,13 +1790,12 @@ export class FigureEditor extends LitElement {
1765
1790
  held = undefined
1766
1791
  }
1767
1792
  const roomFields = own.filter(i => roomOnly.includes(i.id))
1768
- const status = view.loading
1769
- ? 'figure.text.loading-figure'
1770
- : view.saving
1771
- ? 'figure.text.saving-figure'
1772
- : view.dirty || !view.id
1773
- ? 'figure.text.unsaved-changes'
1774
- : 'figure.text.saved'
1793
+ /*
1794
+ Only what is under way is said. Saved or not is the save button's: filled and pressable when there is something
1795
+ to save, dimmed when not -- the saved and unsaved words beside it said the same thing again in the primary
1796
+ colour, read as a button (screen copy rule: a state is the control's).
1797
+ */
1798
+ const status = view.loading ? 'figure.text.loading-figure' : view.saving ? 'figure.text.saving-figure' : ''
1775
1799
  /*
1776
1800
  A figure with no parts yet is not one that failed: what blocks its release is said once it has something to
1777
1801
  release (first 15 minutes, 2026-10-16 evening -- red from the first click read as a failure). The verdict tab
@@ -1799,7 +1823,7 @@ export class FigureEditor extends LitElement {
1799
1823
  ${i18next.t('figure.label.mode-' + mode)}
1800
1824
  </button>`
1801
1825
  )}
1802
- <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1826
+ ${status ? html`<span slot="status" class="status" role="status">${i18next.t(status)}</span>` : nothing}
1803
1827
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1804
1828
  ${blockers.length
1805
1829
  ? html`<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
@@ -1882,7 +1906,6 @@ export class FigureEditor extends LitElement {
1882
1906
  <figure-canvas
1883
1907
  .view=${this.view}
1884
1908
  .parts=${this.drawnParts(view.geometry, live)}
1885
- .anchors=${this.anchorsOf(asset)}
1886
1909
  .fan=${this.fanOf(shown, live)}
1887
1910
  .ghost=${this.previewShown().ghost}
1888
1911
  .highlight=${this.previewShown().highlight}
@@ -1890,13 +1913,17 @@ export class FigureEditor extends LitElement {
1890
1913
  .palette=${view.palette ?? {}}
1891
1914
  .context=${view.context ?? ''}
1892
1915
  .selected=${this.selected}
1916
+ ?aiming=${this.fastening}
1893
1917
  .reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
1918
+ @part-sizing=${(e: CustomEvent) => this.resizing(e.detail.id, e.detail.result)}
1894
1919
  @part-transformed=${(e: CustomEvent) => this.transformed(e.detail.id, e.detail.result)}
1895
1920
  @part-selected=${(e: CustomEvent<string>) => this.choose(e.detail)}
1921
+ @part-aimed=${(e: CustomEvent<{ id: string; point?: number[]; normal?: number[] }>) =>
1922
+ this.fastenWindow?.aimAt?.(e.detail)}
1896
1923
  @render-error=${(e: CustomEvent<Error>) => {
1897
1924
  this.error = e.detail.message
1898
1925
  }}
1899
- ></figure-canvas></div>
1926
+ ></figure-canvas>${this.renderSameMeasure(unavailable)}</div>
1900
1927
  <aside slot="end">
1901
1928
  <div tabs>
1902
1929
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1904,7 +1931,7 @@ export class FigureEditor extends LitElement {
1904
1931
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1905
1932
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1906
1933
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1907
- ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1934
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'))}
1908
1935
  </div>
1909
1936
  <slot name="notice"></slot>
1910
1937
  <div panel ?off=${tab !== 'properties'}>
@@ -1980,7 +2007,7 @@ export class FigureEditor extends LitElement {
1980
2007
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
1981
2008
  </aside>
1982
2009
  </ox-three-pane>
1983
- ${this.renderOverlay()} ${this.renderFastening(unavailable)}`
2010
+ ${this.renderOverlay()}`
1984
2011
  }
1985
2012
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
1986
2013
  private renderGroups(
@@ -2057,6 +2084,12 @@ export class FigureEditor extends LitElement {
2057
2084
  * rule" named nothing): a fastened part's place and turn are its fastening's; other numbers are worked out from
2058
2085
  * something else. A size that follows something is said at the panel's head, so not again here.
2059
2086
  */
2087
+ /** 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). */
2088
+ private renderSizeKeeps() {
2089
+ const asset = this.asset()
2090
+ const kept = asset && this.selected ? keptBySizeOf(asset, this.selected) : undefined
2091
+ return kept ? html`<p hint size-keeps>${i18next.t(SIZE_KEEPS_WORDS[kept])}</p>` : nothing
2092
+ }
2060
2093
  private renderHeldAxes(vectors: FieldGroup[]) {
2061
2094
  const fastened = !!(this.asset())?.joints?.some(j => j.body1.part === ownerOf(this.selected))
2062
2095
  const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && (['x', 'y', 'z'] as const).some(axis => !g.cells[axis]))