@things-factory/figure-ui 10.1.203 → 10.1.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +91 -9
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +86 -8
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +82 -2
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -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,7 +30,8 @@ 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, 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'
@@ -52,7 +53,7 @@ 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 }
@@ -62,7 +63,8 @@ import type { FigureGateFinding } from '../types.js'
62
63
  import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, 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, instanceRangeToDeclare, proportionToFollow, rangeDiffers, 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
  /**
@@ -139,12 +141,21 @@ export class FigureEditor extends LitElement {
139
141
  /** The view settings or the release history, open over the work from the bar. */
140
142
  @state() private overlay?: BarOverlay
141
143
  /**
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.
144
+ * The fastening window open for the selected part (user 2026-10-17), in the standard popup, moved by its head
145
+ * (2026-10-18; put back 2026-10-20 -- the side panel it was moved to could not be moved), with no backdrop, so the
146
+ * canvas can be pressed while it is open: chosen there, applied once. It closes with nothing written when the part,
147
+ * the tab or the middle changes under it.
146
148
  */
147
149
  @state() private fastening = false
150
+ private fastenPopup?: { close: () => void }
151
+ private fastenWindow?: HTMLElement & {
152
+ revision?: FigureRevision
153
+ partId: string
154
+ locked: boolean
155
+ previewWords: string[]
156
+ error: string
157
+ aimAt?: (at: { id: string; point?: number[]; normal?: number[] }) => void
158
+ }
148
159
  /** Why the window's 「적용」 was refused, said in it. */
149
160
  @state() private fastenError = ''
150
161
  /** 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 +216,8 @@ export class FigureEditor extends LitElement {
205
216
  @state() private hiddenParts = new Set<string>()
206
217
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
207
218
  @state() private live: LiveValues = NOTHING_LIVE
219
+ /** A size drag in hand, drawn from figure-model each frame (ADR-0140 4.4); gone with the release. */
220
+ @state() private sizing?: { part: string; drag: SizeDrag; frame?: SizeDragFrame; through?: number }
208
221
  /** The part whose drawn section is open in the section editor (ADR-0100 §3.2), or null. */
209
222
  @state() private sectionEditing: string | null = null
210
223
  private lastContext: string | null = null
@@ -234,7 +247,7 @@ export class FigureEditor extends LitElement {
234
247
  aria-pressed=${String(this.gizmoMode === tool.mode)}
235
248
  ?disabled=${!tool.can}
236
249
  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'))}`}
250
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
238
251
  @click=${() => this.useGizmo(tool.mode)}
239
252
  >
240
253
  <md-icon>${tool.icon}</md-icon>
@@ -292,7 +305,7 @@ export class FigureEditor extends LitElement {
292
305
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
293
306
  if (_changed.has('selected')) this.gizmoMode = undefined
294
307
  /* 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()
308
+ if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit')) this.closeFastening()
296
309
  /*
297
310
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
298
311
  * own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
@@ -322,13 +335,10 @@ export class FigureEditor extends LitElement {
322
335
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
323
336
  /*
324
337
  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.
338
+ put on it to drag in vain (:3300, 10.1.196) -- the tool's own words are said instead.
327
339
  */
328
340
  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'))
341
+ this.refusals.say(i18next.t('figure.text.gizmo-not-own'))
332
342
  return
333
343
  }
334
344
  this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.gizmo(mode)
@@ -354,6 +364,7 @@ export class FigureEditor extends LitElement {
354
364
  } finally {
355
365
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
356
366
  this.live = NOTHING_LIVE
367
+ this.sizing = undefined
357
368
  }
358
369
  }
359
370
  /** The bodies drawn with the value in hand, or null when nothing is in hand (or the figure cannot take it). */
@@ -363,7 +374,8 @@ export class FigureEditor extends LitElement {
363
374
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
364
375
  private drawnParts(geometry: Parameters<typeof drawableFigureParts>[0] | undefined, live: FigureRenderPart[] | null) {
365
376
  const parts = live ?? drawableFigureParts(geometry ?? [])
366
- return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
377
+ const frame = this.sizing?.frame
378
+ return (frame ? partsAtFrame(parts, frame.moved) : parts).filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
367
379
  }
368
380
  /**
369
381
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -563,60 +575,42 @@ export class FigureEditor extends LitElement {
563
575
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
564
576
  try {
565
577
  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
578
+ /* 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). */
579
+ return gizmoReachOf(asset, id)
574
580
  } catch {
575
581
  return none
576
582
  }
577
583
  }
578
584
  /**
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.
585
+ * A size drag in hand, a frame of it: the sizes the handle comes to, through figure-model's size function and the
586
+ * evaluator stepped to them, drawn and written nowhere (ADR-0140 4.4). Begun at the first frame, from the figure as it
587
+ * is drawn and the handle the scene says was held; the release writes the last frame's changes.
583
588
  */
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 {
589
+ private resizing(id: string, result: GizmoResult) {
590
+ const part = ownerOf(id)
591
+ const shown = this.shown()
592
+ if (!shown || !result.scale) return
593
593
  try {
594
- return !figureScaleAnchorOf(asset, ownerOf(id))
594
+ const drawn = drawableFigureParts(this.session!.read().geometry ?? [])
595
+ if (this.sizing?.part !== part)
596
+ this.sizing = { part, drag: startSizeDrag(shown.asset, part, shown.inputs, sizeKeepOf(result.grab)), through: throughPlaneOf(shown.asset, drawn, part) }
597
+ const sizes = gizmoChanges(id, this.reachOf(id), { scale: result.scale }, { ...shown.inputs })
598
+ this.sizing = { ...this.sizing, frame: this.sizing.drag.frame(sizes) }
595
599
  } catch {
596
- return true
600
+ /* A size the figure cannot take on the way: the figure as it stands is drawn, and the release says why. */
601
+ this.sizing = undefined
597
602
  }
598
603
  }
599
604
  /**
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.
605
+ * A free part a size change takes past the mounting plane: said, with 「…에 붙이기」 to fasten it there (ADR-0140 5). The
606
+ * size rule is not changed for it: what the author picks is the fastening, one step of the history.
602
607
  */
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()
608
+ private tellThroughPlane(part: string, mm: number) {
609
+ const plane = i18next.t('figure.label.resize-place-' + (this.asset()?.placement ?? 'floor'))
610
+ this.refusals.point(i18next.t('figure.text.size-through-plane', { part: partLabelOf(part), plane, mm: String(mm) }), {
611
+ label: i18next.t('figure.button.fasten-to-plane', { plane }),
612
+ run: () => this.run(() => this.session!.command({ kind: 'attach', part, to: { plane: 'mounting-plane' } }))
613
+ })
620
614
  }
621
615
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
622
616
  private transformed(id: string, result: GizmoResult) {
@@ -642,21 +636,21 @@ export class FigureEditor extends LitElement {
642
636
  the shift had been taken for a move of the joint, and the size was dropped).
643
637
  */
644
638
  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
639
  /*
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).
640
+ A size dragged is written as its last frame was drawn: figure-model's one size function, keeping the side opposite
641
+ the handle held -- or, fastened, its fastening (ADR-0140 4.1 · 4.4). What the scene put the part at is not read.
653
642
  */
654
643
  const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4)
655
644
  const taken = jointed || scaled ? { scale: result.scale } : result
656
645
  const written = gizmoChanges(id, this.reachOf(id), taken, sizes)
657
- const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written
646
+ const drawnLast = this.sizing?.part === ownerOf(id) ? this.sizing.frame : undefined
647
+ const changes = !scaled ? written : drawnLast ? drawnLast.changes : figureSizeEditOf(asset, ownerOf(id), written, sizeKeepOf(result.grab)).changes
658
648
  if (changes.length) {
649
+ const through = scaled ? (this.sizing?.part === ownerOf(id) ? this.sizing.through : undefined) ?? throughPlaneOf(asset, drawableFigureParts(view.geometry), ownerOf(id)) : 0
659
650
  this.run(() => this.session!.apply(changes))
651
+ /* 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). */
652
+ const now = scaled ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), ownerOf(id)) : 0
653
+ if (now > through + 0.5) this.tellThroughPlane(ownerOf(id), now)
660
654
  return
661
655
  }
662
656
  if (jointed && result.scale) {
@@ -850,6 +844,7 @@ export class FigureEditor extends LitElement {
850
844
  ${groups.singles.length
851
845
  ? html`<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
852
846
  : nothing}
847
+ ${this.renderSizeKeeps()}
853
848
  </div>`
854
849
  : nothing}
855
850
  ${place.length && !placeLocked
@@ -934,24 +929,6 @@ export class FigureEditor extends LitElement {
934
929
  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
930
  ${panel('history', 'figure.button.release', html`<slot name="history"></slot>`)}`
936
931
  }
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
932
  /** Data tried in the data tab, given to the preview's figures as their `data`; null when none is (ADR-0051 §8). */
956
933
  @state() private dataSample: unknown = null
957
934
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
@@ -1037,17 +1014,38 @@ export class FigureEditor extends LitElement {
1037
1014
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
1038
1015
  const input = event.target as HTMLInputElement,
1039
1016
  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. */
1017
+ /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1041
1018
  const part = this.selected
1042
1019
  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]))
1020
+ const asset = this.shown()?.asset
1021
+ const before = asset && part ? throughPlaneOf(asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1022
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditOf(this.shown()!.asset, part, [change], { keep: 'centre' }).changes : [change]))
1023
+ /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1024
+ const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1025
+ if (after > before + 0.5) this.tellThroughPlane(part, after)
1044
1026
  // Rejected edits must also restore the displayed numeric value.
1045
1027
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1046
1028
  }
1047
1029
  /** 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) {
1030
+ private typing(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, event: Event) {
1049
1031
  const value = (event.target as HTMLInputElement).valueAsNumber
1050
- if (Number.isFinite(value)) this.hold({ [field.id]: value })
1032
+ if (Number.isFinite(value)) this.hold(this.heldOf(field, value))
1033
+ }
1034
+ /**
1035
+ * The values in hand for a number typed or scrubbed: what its release writes -- a size with the part's middle kept,
1036
+ * or its fastening (figure-model's one size function, ADR-0140 4.1), so the figure does not jump on the release.
1037
+ */
1038
+ private heldOf(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, value: number, sized?: ReturnType<typeof figureSizeEditorOf>): Record<string, number> {
1039
+ const part = this.selected
1040
+ const asset = this.shown()?.asset
1041
+ if (field.role === 'state' || !part || !asset) return { [field.id]: value }
1042
+ const change: FigureInputChange = { kind: 'set-design', input: field.id, unit: field.unit ?? 'mm', value }
1043
+ try {
1044
+ const { changes } = sized ? sized([change], { keep: 'centre' }) : figureSizeEditOf(asset, part, [change], { keep: 'centre' })
1045
+ return Object.fromEntries(changes.map(c => [c.input, c.value as number]))
1046
+ } catch {
1047
+ return { [field.id]: value }
1048
+ }
1051
1049
  }
1052
1050
  /**
1053
1051
  * Drag a field's label (or an axis letter) sideways to scrub its value, as vector tools do: the figure follows the
@@ -1062,10 +1060,13 @@ export class FigureEditor extends LitElement {
1062
1060
  label.setPointerCapture(down.pointerId)
1063
1061
  const start = field.value
1064
1062
  let value = start
1063
+ /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1064
+ const asset = this.shown()?.asset
1065
+ const sized = asset && this.selected && field.role !== 'state' ? (() => { try { return figureSizeEditorOf(asset, this.selected) } catch { return undefined } })() : undefined
1065
1066
  const move = (e: PointerEvent) => {
1066
1067
  value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey))
1067
1068
  input.value = String(value)
1068
- this.hold({ [field.id]: value })
1069
+ this.hold(this.heldOf(field, value, sized))
1069
1070
  }
1070
1071
  const up = (e: PointerEvent) => {
1071
1072
  label.removeEventListener('pointermove', move)
@@ -1106,27 +1107,35 @@ export class FigureEditor extends LitElement {
1106
1107
  return { of, toDeclare }
1107
1108
  })
1108
1109
  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. */
1110
+ /*
1111
+ In words a person reads (user 2026-10-19/20): what the figure does on a drawing, not the inputs behind it. Fixed,
1112
+ it says the size it is always drawn at; resizable, the sizes it may take on each axis and the parts that change
1113
+ with it. The way to it stays one press -- a figure no part follows (a robot scaled whole) reaches the one-ratio
1114
+ rule only from here -- and the help mark says how parts are made to follow.
1115
+ */
1116
+ const mm = (v: number) => Math.round(v).toLocaleString()
1110
1117
  const toDeclare = measured.toDeclare
1118
+ const title = (explained: string) =>
1119
+ html`<h3>${i18next.t('figure.label.board-size')}${this.help.button(explained)}</h3>${this.help.hint(explained)}`
1111
1120
  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>
1121
+ return html`<section board-size>
1122
+ ${title('figure.text.board-size-fixed-explained')}
1123
+ <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>
1115
1124
  <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')}
1125
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1117
1126
  </button>
1118
1127
  </section>`
1119
1128
  if (!range) return nothing
1120
- const mm = (v: number) => Math.round(v).toLocaleString()
1121
1129
  const differs = rangeDiffers(range.declared, range.measured)
1122
- return html`<section>
1123
- <h3>${i18next.t('figure.label.instance-range')}</h3>
1130
+ const followers = sizeFollowersOf(asset)
1131
+ return html`<section board-size>
1132
+ ${title('figure.text.board-size-range-explained')}
1124
1133
  ${(['x', 'y', 'z'] as const).map(
1125
1134
  axis => html`<div inline-field>
1126
- <label>${axis.toUpperCase()} <span unit>mm</span></label>
1127
- <div pair>
1135
+ <label>${i18next.t('figure.label.resize-' + axis)} <span unit>mm</span></label>
1136
+ <div pair range>
1128
1137
  ${(['min', 'max'] as const).map(
1129
- bound => html`<input
1138
+ (bound, i) => html`${i ? html`<span dash aria-hidden="true">–</span>` : nothing}<input
1130
1139
  aria-label=${`size.${axis}.${bound}`}
1131
1140
  type="number"
1132
1141
  step="any"
@@ -1141,6 +1150,13 @@ export class FigureEditor extends LitElement {
1141
1150
  </div>
1142
1151
  </div>`
1143
1152
  )}
1153
+ <p followers>
1154
+ ${followers.length
1155
+ ? i18next.t('figure.text.board-size-followers', {
1156
+ 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(', ')
1157
+ })
1158
+ : i18next.t('figure.text.board-size-no-followers')}
1159
+ </p>
1144
1160
  ${differs
1145
1161
  ? html`<p hint>
1146
1162
  ${i18next.t('figure.text.instance-range-can-widen', {
@@ -1488,44 +1504,49 @@ export class FigureEditor extends LitElement {
1488
1504
  this.openFastening()
1489
1505
  }
1490
1506
  private openFastening() {
1491
- if (!this.selected || this.fastening) return
1507
+ if (!this.selected || this.fastenPopup) return
1492
1508
  this.fastenError = ''
1493
1509
  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
1510
  this.fastening = true
1511
+ const content = document.createElement('figure-fasten-dialog') as NonNullable<typeof this.fastenWindow>
1512
+ content.addEventListener('preview-command', e => this.commandPreview.ask((e as CustomEvent).detail))
1513
+ content.addEventListener('fasten-hover', e => this.hoverFastening((e as CustomEvent).detail))
1514
+ content.addEventListener('fasten-apply', e => this.applyFastening((e as CustomEvent).detail.action))
1515
+ content.addEventListener('fasten-cancel', () => this.closeFastening())
1516
+ this.fastenWindow = content
1517
+ this.tellFastening()
1518
+ /* No backdrop: the canvas stays reachable while it is open -- a part pressed there is aimed at (ADR-0140 stage 4). */
1519
+ const popup = openPopup(html`${content}`, { title: i18next.t('figure.label.fastening'), size: 'fit', backdrop: false, movable: true, closable: true, escapable: true })
1520
+ this.fastenPopup = popup
1521
+ /* Closed by its own X or Esc as well: the editor lets go of it the same way. */
1522
+ popup.result.then(() => this.fastenPopup === popup && this.fastenClosed())
1497
1523
  }
1498
1524
  private closeFastening() {
1525
+ const popup = this.fastenPopup
1526
+ this.fastenClosed()
1527
+ popup?.close()
1528
+ }
1529
+ private fastenClosed() {
1530
+ this.fastenPopup = undefined
1531
+ this.fastenWindow = undefined
1499
1532
  this.fastening = false
1500
1533
  this.fastenError = ''
1501
1534
  this.fastenHover = null
1502
1535
  }
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>`
1536
+ /** What the window shows is the editor's: the figure on screen, the part, and what the preview and a refusal said. */
1537
+ private tellFastening() {
1538
+ const content = this.fastenWindow
1539
+ const view = this.session?.read()
1540
+ if (!content || !view) return
1541
+ content.revision = this.shown()
1542
+ content.partId = this.selected
1543
+ content.locked = view.loading || !!view.error || view.saving || !view.context
1544
+ content.previewWords = this.commandPreview.words()
1545
+ content.error = this.fastenError
1546
+ }
1547
+ disconnectedCallback() {
1548
+ super.disconnectedCallback()
1549
+ this.closeFastening()
1529
1550
  }
1530
1551
  /** 「적용」: the command, one step; refused, the window stays open and says why. */
1531
1552
  private applyFastening(action: FigureAuthoringAction) {
@@ -1584,15 +1605,16 @@ export class FigureEditor extends LitElement {
1584
1605
  return this.shown()?.asset
1585
1606
  }
1586
1607
  /** 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) {
1608
+ private renderTab(tab: SideTab, icon: string, label: string) {
1588
1609
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1589
1610
  const name = i18next.t('figure.label.tab-short-' + tab)
1590
1611
  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>`}
1612
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1592
1613
  </button>`
1593
1614
  }
1594
1615
  updated(changed: PropertyValues<this>) {
1595
1616
  super.updated?.(changed)
1617
+ this.tellFastening()
1596
1618
  /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1597
1619
  if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen) this.blockersOpen = false
1598
1620
  this.refusals.tell(changed)
@@ -1617,7 +1639,8 @@ export class FigureEditor extends LitElement {
1617
1639
  if (!part || !drawn || part.shape.dimensions.jamb !== undefined) return nothing
1618
1640
  const asset = this.asset() as FigureAsset
1619
1641
  const measures = figureSectionMeasuresOf(asset, this.selected)
1620
- const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis]
1642
+ /* What a measure typed here keeps: the part's middle, or its fastening (ADR-0140 4.1). */
1643
+ const kept = keptBySizeOf(asset, this.selected)
1621
1644
  return html`<figure-section-editor
1622
1645
  .body=${{ ...part, id: this.selected }}
1623
1646
  .measures=${measures}
@@ -1765,13 +1788,12 @@ export class FigureEditor extends LitElement {
1765
1788
  held = undefined
1766
1789
  }
1767
1790
  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'
1791
+ /*
1792
+ Only what is under way is said. Saved or not is the save button's: filled and pressable when there is something
1793
+ to save, dimmed when not -- the saved and unsaved words beside it said the same thing again in the primary
1794
+ colour, read as a button (screen copy rule: a state is the control's).
1795
+ */
1796
+ const status = view.loading ? 'figure.text.loading-figure' : view.saving ? 'figure.text.saving-figure' : ''
1775
1797
  /*
1776
1798
  A figure with no parts yet is not one that failed: what blocks its release is said once it has something to
1777
1799
  release (first 15 minutes, 2026-10-16 evening -- red from the first click read as a failure). The verdict tab
@@ -1799,7 +1821,7 @@ export class FigureEditor extends LitElement {
1799
1821
  ${i18next.t('figure.label.mode-' + mode)}
1800
1822
  </button>`
1801
1823
  )}
1802
- <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1824
+ ${status ? html`<span slot="status" class="status" role="status">${i18next.t(status)}</span>` : nothing}
1803
1825
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1804
1826
  ${blockers.length
1805
1827
  ? html`<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
@@ -1882,7 +1904,6 @@ export class FigureEditor extends LitElement {
1882
1904
  <figure-canvas
1883
1905
  .view=${this.view}
1884
1906
  .parts=${this.drawnParts(view.geometry, live)}
1885
- .anchors=${this.anchorsOf(asset)}
1886
1907
  .fan=${this.fanOf(shown, live)}
1887
1908
  .ghost=${this.previewShown().ghost}
1888
1909
  .highlight=${this.previewShown().highlight}
@@ -1890,9 +1911,13 @@ export class FigureEditor extends LitElement {
1890
1911
  .palette=${view.palette ?? {}}
1891
1912
  .context=${view.context ?? ''}
1892
1913
  .selected=${this.selected}
1914
+ ?aiming=${this.fastening}
1893
1915
  .reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
1916
+ @part-sizing=${(e: CustomEvent) => this.resizing(e.detail.id, e.detail.result)}
1894
1917
  @part-transformed=${(e: CustomEvent) => this.transformed(e.detail.id, e.detail.result)}
1895
1918
  @part-selected=${(e: CustomEvent<string>) => this.choose(e.detail)}
1919
+ @part-aimed=${(e: CustomEvent<{ id: string; point?: number[]; normal?: number[] }>) =>
1920
+ this.fastenWindow?.aimAt?.(e.detail)}
1896
1921
  @render-error=${(e: CustomEvent<Error>) => {
1897
1922
  this.error = e.detail.message
1898
1923
  }}
@@ -1904,7 +1929,7 @@ export class FigureEditor extends LitElement {
1904
1929
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1905
1930
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1906
1931
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1907
- ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1932
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'))}
1908
1933
  </div>
1909
1934
  <slot name="notice"></slot>
1910
1935
  <div panel ?off=${tab !== 'properties'}>
@@ -1980,7 +2005,7 @@ export class FigureEditor extends LitElement {
1980
2005
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
1981
2006
  </aside>
1982
2007
  </ox-three-pane>
1983
- ${this.renderOverlay()} ${this.renderFastening(unavailable)}`
2008
+ ${this.renderOverlay()}`
1984
2009
  }
1985
2010
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
1986
2011
  private renderGroups(
@@ -2057,6 +2082,12 @@ export class FigureEditor extends LitElement {
2057
2082
  * rule" named nothing): a fastened part's place and turn are its fastening's; other numbers are worked out from
2058
2083
  * something else. A size that follows something is said at the panel's head, so not again here.
2059
2084
  */
2085
+ /** 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). */
2086
+ private renderSizeKeeps() {
2087
+ const asset = this.asset()
2088
+ const kept = asset && this.selected ? keptBySizeOf(asset, this.selected) : undefined
2089
+ return kept ? html`<p hint size-keeps>${i18next.t(SIZE_KEEPS_WORDS[kept])}</p>` : nothing
2090
+ }
2060
2091
  private renderHeldAxes(vectors: FieldGroup[]) {
2061
2092
  const fastened = !!(this.asset())?.joints?.some(j => j.body1.part === ownerOf(this.selected))
2062
2093
  const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && (['x', 'y', 'z'] as const).some(axis => !g.cells[axis]))