@things-factory/figure-ui 10.1.208 → 10.1.211

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 (92) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +97 -88
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +87 -157
  5. package/client/modeller/figure-size-editor.ts +233 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +87 -36
  9. package/client/modeller/live.ts +33 -0
  10. package/client/modeller/motion-ends.ts +10 -6
  11. package/client/modeller/size-response.ts +95 -0
  12. package/dist-client/modeller/data-interface.d.ts +1 -1
  13. package/dist-client/modeller/data-interface.js +5 -5
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -1
  16. package/dist-client/modeller/editor.js +103 -89
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/fasten-geometry.js +1 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-links.d.ts +3 -4
  21. package/dist-client/modeller/figure-links.js +91 -145
  22. package/dist-client/modeller/figure-links.js.map +1 -1
  23. package/dist-client/modeller/figure-size-editor.d.ts +29 -0
  24. package/dist-client/modeller/figure-size-editor.js +262 -0
  25. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  26. package/dist-client/modeller/joint-drag.d.ts +1 -1
  27. package/dist-client/modeller/joint-drag.js +27 -22
  28. package/dist-client/modeller/joint-drag.js.map +1 -1
  29. package/dist-client/modeller/joint-fan.js +4 -3
  30. package/dist-client/modeller/joint-fan.js.map +1 -1
  31. package/dist-client/modeller/links.d.ts +21 -9
  32. package/dist-client/modeller/links.js +85 -35
  33. package/dist-client/modeller/links.js.map +1 -1
  34. package/dist-client/modeller/live.d.ts +14 -0
  35. package/dist-client/modeller/live.js +31 -0
  36. package/dist-client/modeller/live.js.map +1 -1
  37. package/dist-client/modeller/motion-ends.d.ts +4 -1
  38. package/dist-client/modeller/motion-ends.js +10 -6
  39. package/dist-client/modeller/motion-ends.js.map +1 -1
  40. package/dist-client/modeller/size-response.d.ts +29 -0
  41. package/dist-client/modeller/size-response.js +99 -0
  42. package/dist-client/modeller/size-response.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  45. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  46. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  47. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  48. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  49. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  50. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  51. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  52. package/docs/multipart-response-review-2026-10-24.md +77 -0
  53. package/docs/response-authoring-2026-10-24.md +46 -0
  54. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  55. package/package.json +7 -7
  56. package/test/attachment-size-contract.test.ts +47 -0
  57. package/test/browser/acceptance-page.js +1 -1
  58. package/test/browser/detail-page.js +1 -1
  59. package/test/browser/detail-tabs-page.js +1 -1
  60. package/test/browser/editing-page.js +1 -1
  61. package/test/browser/editing.check.mjs +66 -14
  62. package/test/browser/links.check.mjs +41 -22
  63. package/test/browser/modeller-page.js +1 -1
  64. package/test/browser/modeller-revision.check.mjs +21 -7
  65. package/test/browser/modeller.check.mjs +106 -39
  66. package/test/browser/multipart-response-page.js +8 -0
  67. package/test/browser/parts.check.mjs +2 -1
  68. package/test/browser/product-page.js +1 -1
  69. package/test/browser/props.check.mjs +17 -14
  70. package/test/browser/response-page.js +12 -0
  71. package/test/browser/row-preview.check.mjs +3 -3
  72. package/test/browser/screen.check.mjs +57 -13
  73. package/test/browser/serve-response.mjs +24 -0
  74. package/test/clip-ai-proposal.test.ts +1 -1
  75. package/test/clip-consent.test.ts +2 -2
  76. package/test/command-preview.test.ts +7 -7
  77. package/test/joint-drag.test.ts +6 -6
  78. package/test/joint-frames.test.ts +33 -32
  79. package/test/joints.ts +1 -6
  80. package/test/links.test.ts +60 -20
  81. package/test/multipart-response-fixture.js +17 -0
  82. package/test/multipart-response.test.ts +37 -0
  83. package/test/refusals.test.ts +2 -2
  84. package/test/resize-with-placement.test.ts +23 -1
  85. package/test/size-response.test.ts +49 -0
  86. package/test/size-source-pairing.test.ts +66 -0
  87. package/translations/en.json +47 -10
  88. package/translations/es.json +47 -10
  89. package/translations/ja.json +47 -10
  90. package/translations/ko.json +52 -15
  91. package/translations/ms.json +47 -10
  92. package/translations/zh.json +47 -10
@@ -4,7 +4,7 @@
4
4
  * of data is things-scene's; this only lists what there is to choose, drafts a binding the kernel then checks, and edits
5
5
  * the schema as a tree. Kept apart from the element so it is tested without a browser.
6
6
  */
7
- import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureDataRows, parseFigureAccessor } from '@hatiolab/figure-model'
7
+ import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureAxesOf, figureDataRows, motionStateIdOf, parseFigureAccessor } from '@hatiolab/figure-model'
8
8
  import type { FigureAsset, FigureDataBehavior, FigureDataBinding, FigureDataField, FigureDataNode, FigureDataRow, FigureDataValue, FigureDataValueIssue, FigureProposalStep, FigureStatusPort } from '@hatiolab/figure-model'
9
9
  import { FigureInputs, type FigureDataResult } from '@hatiolab/things-scene'
10
10
 
@@ -286,12 +286,12 @@ export interface EndlessJoint {
286
286
  unit: 'deg' | 'rad'
287
287
  }
288
288
 
289
- /** The joints that turn without end: revolute, with no limits, leading no one's mimic. */
289
+ /** The axes that turn without end: revolute, with no limits, following no one (ADR-0159: every axis of every joint). */
290
290
  export function endlessJointsOf(asset: FigureAsset): EndlessJoint[] {
291
291
  const out: EndlessJoint[] = []
292
- for (const j of asset.joints ?? []) {
293
- if (j.type !== 'revolute' || j.lowerLimit !== undefined || j.upperLimit !== undefined || j.mimic) continue
294
- const input = j.state?.id ?? j.id
292
+ for (const { motion: a } of figureAxesOf(asset.joints)) {
293
+ if (a.type !== 'revolute' || a.lowerLimit !== undefined || a.upperLimit !== undefined || a.mimic) continue
294
+ const input = motionStateIdOf(a)
295
295
  const unit = asset.document.model.inputs.find(i => i.id === input)?.unit
296
296
  if (unit !== 'deg' && unit !== 'rad') continue
297
297
  if (!out.some(e => e.input === input)) out.push({ input, label: asset.stateInputs?.[input]?.label ?? input, unit })
@@ -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, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
9
+ import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } 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'
@@ -63,12 +63,13 @@ import { editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, place
63
63
  import type { FigureRenderPart } from './renderer.js'
64
64
  import type { FigureBudgetLine } from './maturity.js'
65
65
  import { HelpToggles } from './help.js'
66
- import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
66
+ import { sizeInputOf } from './size-response.js'
67
+ import { canFollow, followableAxes, endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
67
68
  import { jointFanOf } from './joint-fan.js'
68
69
  import { derivedOf, type FigureRevision } from './revision.js'
69
70
  import { jointDragOf } from './joint-drag.js'
70
71
  import type { FirstSave } from './first-save.js'
71
- import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
72
+ import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubLabel, type LiveValues } from './live.js'
72
73
  import { startingShapeKindOfBody } from './starting-shape.js'
73
74
  import { groupMenu, GroupMenuStyles } from './group-menu.js'
74
75
 
@@ -360,10 +361,14 @@ export class FigureEditor extends LitElement {
360
361
  }
361
362
  /**
362
363
  * A refusal no particular sentence knows: figure-model's code said as a refusal, never its English; any other error
363
- * (the network, the server) as it came.
364
+ * (the network, the server) as it came. A loop is said as a loop whichever way it was made: a command, or the size
365
+ * editor's steps (a measure made to follow what follows it, 2026-10-24 -- said as a bare refused step before).
364
366
  */
365
367
  private said(error: unknown): string {
366
- return (error as { code?: string })?.code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error)
368
+ const code = (error as { code?: string })?.code
369
+ if (code === 'DIMENSION_CYCLE') return i18next.t('figure.text.follow-would-loop')
370
+ if (code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE') return i18next.t('figure.text.fasten-would-loop')
371
+ return code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error)
367
372
  }
368
373
  private run(action: () => unknown) {
369
374
  try {
@@ -818,6 +823,10 @@ export class FigureEditor extends LitElement {
818
823
  .partId=${this.selected}
819
824
  ?locked=${unavailable}
820
825
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
826
+ @size-recipe=${(e: CustomEvent) => this.applySizeSteps(e.detail.steps)}
827
+ @size-input=${(e: CustomEvent) => this.writeField({ id: e.detail.input, unit: 'mm' }, e.detail.value)}
828
+ @size-typing=${(e: CustomEvent) => this.hold(this.heldOf({ id: e.detail.input, unit: 'mm' }, e.detail.value))}
829
+ @size-let-go=${() => (this.live = NOTHING_LIVE)}
821
830
  @fasten-open=${() => this.openFastening()}
822
831
  .previewWords=${this.commandPreview.words()}
823
832
  .detachWords=${this.commandPreview.detachWords(shown)}
@@ -837,21 +846,25 @@ export class FigureEditor extends LitElement {
837
846
  */
838
847
  private renderPartGroups(shown: FigureRevision | undefined, geometry: readonly { id: string }[], fields: FieldLike[], unavailable: boolean, held?: HeldValues) {
839
848
  const groups = this.withSizeRow(groupFields(fields, this.selected), held)
840
- const size = groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size')
849
+ const integrated = !!shown && canFollow(shown.asset, this.selected)
850
+ const dimensionInputs = new Set(integrated ? followableAxes(shown!.asset, this.selected).map(axis => sizeInputOf(shown!.asset, this.selected, axis)) : [])
851
+ const singles = groups.singles.filter(g => g.kind !== 'single' || !dimensionInputs.has(g.field.id))
852
+ const size = integrated ? [] : groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size')
841
853
  const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size')
842
854
  const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected))
843
855
  const placeLocked = fastened && place.every(g => g.kind === 'vector' && (['x', 'y', 'z'] as const).every(axis => !g.cells[axis]))
844
856
  return this.renderLinks(
845
857
  unavailable,
846
858
  'shape',
847
- html`${size.length || groups.singles.length
848
- ? html`<div slot="size" group-fields>
859
+ html`${size.length || singles.length
860
+ ? html`<section slot="size" group-fields>
861
+ <h3>${i18next.t(integrated ? 'figure.label.group-shape' : 'figure.label.group-size')}</h3>
849
862
  ${size.map(g => this.renderVector(g, unavailable, held))}
850
- ${groups.singles.length
851
- ? html`<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
863
+ ${singles.length
864
+ ? html`<div form>${singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
852
865
  : nothing}
853
- ${this.renderSizeKeeps()}
854
- </div>`
866
+ ${!integrated ? this.renderSizeKeeps() : nothing}
867
+ </section>`
855
868
  : nothing}
856
869
  ${place.length && !placeLocked
857
870
  ? html`<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
@@ -860,7 +873,7 @@ export class FigureEditor extends LitElement {
860
873
  }
861
874
  /** The 「반응형」 tab of a part: the links' response groups, its row's own section put in by slot. */
862
875
  private renderResponse(shown: FigureRevision | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
863
- return this.renderLinks(unavailable, 'response', this.renderRepeat(shown, geometry, unavailable))
876
+ return this.renderLinks(unavailable, 'response', html`<div slot="size-note">${this.renderSizeKeeps()}</div>${this.renderRepeat(shown, geometry, unavailable)}`)
864
877
  }
865
878
  /**
866
879
  * 「더 보기」: what few parts need, folded by default and left as this person last left it (chief architect
@@ -967,6 +980,24 @@ export class FigureEditor extends LitElement {
967
980
  const done = this.linkCommand({ ...held.action, remap } as FigureAuthoringAction, held.from)
968
981
  if (done && held.from === 'section-editor') this.closeSectionEditor()
969
982
  }
983
+ /** The figure's size, declared for a part to follow, in one line (ADR ruling Q1). */
984
+ private sizeDeclaredWords() {
985
+ const size = this.designValues()
986
+ return i18next.t('figure.text.size-declared-to-follow', {
987
+ x: Math.round(size['size.x'] ?? 0).toLocaleString(),
988
+ y: Math.round(size['size.y'] ?? 0).toLocaleString(),
989
+ z: Math.round(size['size.z'] ?? 0).toLocaleString()
990
+ })
991
+ }
992
+ /**
993
+ * The size editor's steps, one step of the history. A measure made to follow the figure on a figure with no size
994
+ * declares it on the way; that is said in one line, as follow-size said it (ADR ruling Q1, 2026-10-24).
995
+ */
996
+ private applySizeSteps(steps: readonly FigureProposalStep[]) {
997
+ const hadSize = this.hasSize()
998
+ this.run(() => this.session!.applyRecipe(steps))
999
+ if (!hadSize && !this.error && this.hasSize()) this.notice = this.sizeDeclaredWords()
1000
+ }
970
1001
  /** Runs a command; true when it was taken. */
971
1002
  private linkCommand(action: FigureAuthoringAction, from: 'section-editor' | 'panel' = 'panel'): boolean {
972
1003
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
@@ -978,14 +1009,7 @@ export class FigureEditor extends LitElement {
978
1009
  this.error = ''
979
1010
  this.faceMapping = undefined
980
1011
  /* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
981
- if (action.kind === 'follow-size' && !hadSize) {
982
- const size = this.designValues()
983
- this.notice = i18next.t('figure.text.size-declared-to-follow', {
984
- x: Math.round(size['size.x'] ?? 0).toLocaleString(),
985
- y: Math.round(size['size.y'] ?? 0).toLocaleString(),
986
- z: Math.round(size['size.z'] ?? 0).toLocaleString()
987
- })
988
- }
1012
+ if (action.kind === 'follow-size' && !hadSize) this.notice = this.sizeDeclaredWords()
989
1013
  this.changed()
990
1014
  return true
991
1015
  } catch (error) {
@@ -998,19 +1022,20 @@ export class FigureEditor extends LitElement {
998
1022
  this.error = ''
999
1023
  return false
1000
1024
  }
1001
- this.error =
1002
- code === 'DIMENSION_CYCLE'
1003
- ? i18next.t('figure.text.follow-would-loop')
1004
- : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
1005
- ? i18next.t('figure.text.fasten-would-loop')
1006
- : this.said(error)
1025
+ this.error = this.said(error)
1007
1026
  return false
1008
1027
  }
1009
1028
  }
1010
1029
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
1011
- const input = event.target as HTMLInputElement,
1012
- value = input.valueAsNumber
1013
- /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1030
+ const input = event.target as HTMLInputElement
1031
+ // Rejected edits must also restore the displayed numeric value.
1032
+ input.value = String(this.writeField(field, input.valueAsNumber))
1033
+ }
1034
+ /**
1035
+ * A number written to an input, from a field, a scrub or the size editor's rows: a size keeps the part's middle, or its
1036
+ * fastening -- figure-model's one size function (ADR-0140 4.1). Gives the value the input holds after, refused or not.
1037
+ */
1038
+ private writeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, value: number): number {
1014
1039
  const part = this.selected
1015
1040
  const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1016
1041
  const asset = this.shown()?.asset
@@ -1019,8 +1044,7 @@ export class FigureEditor extends LitElement {
1019
1044
  /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1020
1045
  const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1021
1046
  if (after > before + 0.5) this.tellThroughPlane(part, after)
1022
- // Rejected edits must also restore the displayed numeric value.
1023
- input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1047
+ return this.session!.read().fields.find(i => i.id === field.id)!.value
1024
1048
  }
1025
1049
  /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
1026
1050
  private typing(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, event: Event) {
@@ -1048,39 +1072,24 @@ export class FigureEditor extends LitElement {
1048
1072
  * pointer, and the release records one step. The field's number follows too, so the value in hand is always read.
1049
1073
  */
1050
1074
  private scrub(field: { id: string; unit: FigureInputChange['unit']; role?: string; value: number }, down: PointerEvent, unavailable: boolean) {
1051
- if (unavailable || down.button !== 0) return
1075
+ if (unavailable) return
1052
1076
  const label = down.currentTarget as HTMLElement
1053
1077
  const input = (label.closest('div[cell]')?.querySelector('input') ?? label.nextElementSibling) as HTMLInputElement | null
1054
1078
  if (!input) return
1055
- down.preventDefault()
1056
- label.setPointerCapture(down.pointerId)
1057
- const start = field.value
1058
- let value = start
1059
1079
  /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1060
1080
  const asset = this.shown()?.asset
1061
1081
  const sized = asset && this.selected && field.role !== 'state' ? (() => { try { return figureSizeEditorOf(asset, this.selected) } catch { return undefined } })() : undefined
1062
- const move = (e: PointerEvent) => {
1063
- value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey))
1064
- input.value = String(value)
1065
- this.hold(this.heldOf(field, value, sized))
1066
- }
1067
- const up = (e: PointerEvent) => {
1068
- label.removeEventListener('pointermove', move)
1069
- label.removeEventListener('pointerup', up)
1070
- label.removeEventListener('pointercancel', up)
1071
- /* A drag the system took away is not the author's value: the field goes back to where it was. */
1072
- if (e.type === 'pointercancel') value = start
1073
- if (value === start) {
1074
- input.value = String(start)
1082
+ scrubLabel(down, field, {
1083
+ move: value => {
1084
+ input.value = String(value)
1085
+ this.hold(this.heldOf(field, value, sized))
1086
+ },
1087
+ release: value => (input.value = String(this.writeField(field, value))),
1088
+ cancel: () => {
1089
+ input.value = String(field.value)
1075
1090
  this.live = NOTHING_LIVE
1076
- return
1077
1091
  }
1078
- input.value = String(value)
1079
- this.changeField(field, { target: input } as unknown as Event)
1080
- }
1081
- label.addEventListener('pointermove', move)
1082
- label.addEventListener('pointerup', up)
1083
- label.addEventListener('pointercancel', up)
1092
+ })
1084
1093
  }
1085
1094
  /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1086
1095
  private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
@@ -1243,45 +1252,45 @@ export class FigureEditor extends LitElement {
1243
1252
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
1244
1253
  const own = `${this.selected}.repeat.pitch`
1245
1254
  /* The pitch as it stands: the part's own number, a constant, or what a pitch that follows a length comes to. */
1246
- const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(shown, layout?.args[4])
1247
1255
  const fits = layout?.op === 'fit-pitch@1'
1248
- /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
1249
- const lengthRef = layout?.args[0] ?? ''
1250
- const length = asset.designInputs[lengthRef]
1251
- const longest = model.inputs.find(i => i.id === lengthRef)?.max
1252
- const limit = model.constants.find(c => c.id === layout?.args[6])?.value
1256
+ const pitch = fits ? asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(shown, layout?.args[4]) : this.rowNow(shown)?.pitch
1253
1257
  const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
1254
- const removable = !!asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1258
+ const record = asset.joints?.find(j => j.body1.part === this.selected && j.repeat)
1259
+ const parent = record && 'part' in record.body0 ? record.body0.part : undefined
1260
+ const removable = !!record
1261
+ const axes = record && 'part' in record.body0 && 'face' in record.body0 ? figureFastenAxesOf(asset, record.body0) : []
1262
+ const update = (change: Partial<NonNullable<typeof record>['repeat']>) => {
1263
+ if (!record?.repeat || unavailable) return
1264
+ this.run(() => this.session!.command({ kind: 'set-joint', joint: { ...record, repeat: { ...record.repeat!, ...change } } }))
1265
+ }
1255
1266
  return html`<section slot="row" row>
1256
1267
  <h3>
1257
1268
  ${i18next.t('figure.label.repeat')}
1258
- ${groupMenu([{ label: i18next.t('figure.button.remove-repeat'), icon: 'close', disabled: unavailable || !removable, run: () => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected })) }])}
1269
+ <span section-state active>${i18next.t('figure.label.repeat-active')}</span>
1259
1270
  </h3>
1260
- <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
1261
- <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
1271
+ <div choice-row role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
1272
+ <label toggle><input type="radio" name="applied-repeat-mode" .checked=${live(fits)} ?disabled=${unavailable || !record} @change=${() => update({ count: undefined, pitch: pitch && pitch > 0 ? pitch : 200 })}>${i18next.t('figure.label.repeat-fills')}</label>
1273
+ <label toggle><input type="radio" name="applied-repeat-mode" .checked=${live(!fits)} ?disabled=${unavailable || !record} @change=${() => update({ count: repeat.count })}>${i18next.t('figure.label.row-by-count')}</label>
1274
+ </div>
1275
+ <p hint>${i18next.t(fits ? 'figure.text.repeat-mode-pitch' : 'figure.text.repeat-mode-count')}</p>
1276
+ <div inline-field><label>${i18next.t('figure.label.size-source')}</label><span value>${parent ?? '—'}</span></div>
1277
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label>${axes.length ? html`<select aria-label=${i18next.t('figure.label.repeat-axis')} ?disabled=${unavailable} .value=${live(axis)} @change=${(e: Event) => update({ axis: (e.target as HTMLSelectElement).value.toUpperCase() as 'X' | 'Y' | 'Z' })}>${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}</select>` : html`<span value>${axis.toUpperCase()}</span>`}</div>
1278
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label>${!fits && record ? html`<input type="number" min="1" step="1" aria-label=${i18next.t('figure.label.repeat-count')} .value=${live(String(repeat.count))} ?disabled=${unavailable} @change=${(e: Event) => update({ count: (e.target as HTMLInputElement).valueAsNumber })}>` : html`<span value>${repeat.count}</span>`}</div>
1262
1279
  <div inline-field>
1263
1280
  <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
1264
- <input
1281
+ ${fits ? html`<input
1265
1282
  type="number"
1266
1283
  step="any"
1267
- aria-label=${own}
1268
- .value=${String(pitch ?? '')}
1269
- ?disabled=${unavailable || !fits}
1284
+ aria-label=${i18next.t('figure.label.repeat-gap')}
1285
+ .value=${live(String(pitch ?? ''))}
1286
+ ?disabled=${unavailable}
1270
1287
  @change=${(e: Event) => this.setPitch(field, e)}
1271
- />
1288
+ />` : html`<span value>${pitch === undefined ? '—' : Number(pitch.toFixed(3))}</span>`}
1272
1289
  </div>
1273
- <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
1274
- <p hint>
1275
- ${fits && length !== undefined && longest !== undefined && limit !== undefined
1276
- ? i18next.t('figure.text.repeat-what-pitch-gives', {
1277
- length: length.toLocaleString(),
1278
- count: repeat.count,
1279
- longest: longest.toLocaleString(),
1280
- limit
1281
- })
1282
- : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1283
- </p>
1284
- ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1290
+ <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).slice(1).map(line => html`<li>${line}</li>`)}</ul>
1291
+ <button quiet ?disabled=${unavailable || !removable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>${i18next.t('figure.button.remove-repeat')}</button>
1292
+ <p hint>${i18next.t('figure.text.repeat-remove-explained')}</p>
1293
+ ${fits ? this.renderPublicPitch(asset, pitch ?? 0, unavailable) : nothing}
1285
1294
  </section>`
1286
1295
  }
1287
1296
  /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
@@ -1589,8 +1598,8 @@ export class FigureEditor extends LitElement {
1589
1598
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1590
1599
  private renderTab(tab: SideTab, icon: string, label: string) {
1591
1600
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1592
- const name = i18next.t('figure.label.tab-short-' + tab)
1593
- return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1601
+ const name = i18next.t(tab === 'response' && this.selected ? 'figure.label.tab-arrangement' : 'figure.label.tab-short-' + tab)
1602
+ return html`<button tab ?on=${this.tab === tab} title=${tab === 'response' && this.selected ? name : label} aria-label=${name} @click=${() => (this.tab = tab)}>
1594
1603
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1595
1604
  </button>`
1596
1605
  }
@@ -1717,8 +1726,8 @@ export class FigureEditor extends LitElement {
1717
1726
  if (!summary) return nothing
1718
1727
  return html`<div subject>
1719
1728
  <div subject-summary>
1720
- ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1721
- ${summary.size ? html`<small>${summary.size}</small>` : nothing}
1729
+ <b>${ownerOf(this.selected)}</b>
1730
+ ${summary.kind ? html`<small>${summary.kind}</small>` : nothing}
1722
1731
  </div>
1723
1732
  </div>`
1724
1733
  }
@@ -126,7 +126,8 @@ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching
126
126
  }
127
127
 
128
128
  /** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
129
- export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.origin?.facing === 'centre'))
129
+ export const turnsInPlace = (asset: FigureAsset, part: string) =>
130
+ (asset.joints ?? []).some(j => j.body1.part === part && ((j.motion ?? []).some(a => a.type === 'revolute') || j.origin?.facing === 'centre'))
130
131
 
131
132
  /**
132
133
  * How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up