@things-factory/figure-ui 10.1.208 → 10.1.210

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 (89) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +74 -57
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +26 -120
  5. package/client/modeller/figure-size-editor.ts +196 -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 +36 -24
  9. package/client/modeller/motion-ends.ts +10 -6
  10. package/client/modeller/size-response.ts +95 -0
  11. package/dist-client/modeller/data-interface.d.ts +1 -1
  12. package/dist-client/modeller/data-interface.js +5 -5
  13. package/dist-client/modeller/data-interface.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +9 -1
  15. package/dist-client/modeller/editor.js +80 -58
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-geometry.js +1 -1
  18. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -4
  20. package/dist-client/modeller/figure-links.js +26 -107
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/figure-size-editor.d.ts +27 -0
  23. package/dist-client/modeller/figure-size-editor.js +221 -0
  24. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  25. package/dist-client/modeller/joint-drag.d.ts +1 -1
  26. package/dist-client/modeller/joint-drag.js +27 -22
  27. package/dist-client/modeller/joint-drag.js.map +1 -1
  28. package/dist-client/modeller/joint-fan.js +4 -3
  29. package/dist-client/modeller/joint-fan.js.map +1 -1
  30. package/dist-client/modeller/links.d.ts +9 -6
  31. package/dist-client/modeller/links.js +33 -21
  32. package/dist-client/modeller/links.js.map +1 -1
  33. package/dist-client/modeller/motion-ends.d.ts +4 -1
  34. package/dist-client/modeller/motion-ends.js +10 -6
  35. package/dist-client/modeller/motion-ends.js.map +1 -1
  36. package/dist-client/modeller/size-response.d.ts +29 -0
  37. package/dist-client/modeller/size-response.js +99 -0
  38. package/dist-client/modeller/size-response.js.map +1 -0
  39. package/dist-client/route.d.ts +1 -1
  40. package/dist-client/tsconfig.tsbuildinfo +1 -1
  41. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  42. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  43. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  44. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  45. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  46. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  47. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  48. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  49. package/docs/multipart-response-review-2026-10-24.md +77 -0
  50. package/docs/response-authoring-2026-10-24.md +46 -0
  51. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  52. package/package.json +7 -7
  53. package/test/attachment-size-contract.test.ts +47 -0
  54. package/test/browser/acceptance-page.js +1 -1
  55. package/test/browser/detail-page.js +1 -1
  56. package/test/browser/detail-tabs-page.js +1 -1
  57. package/test/browser/editing-page.js +1 -1
  58. package/test/browser/editing.check.mjs +28 -14
  59. package/test/browser/links.check.mjs +41 -22
  60. package/test/browser/modeller-page.js +1 -1
  61. package/test/browser/modeller-revision.check.mjs +21 -7
  62. package/test/browser/modeller.check.mjs +51 -39
  63. package/test/browser/multipart-response-page.js +8 -0
  64. package/test/browser/parts.check.mjs +2 -1
  65. package/test/browser/product-page.js +1 -1
  66. package/test/browser/props.check.mjs +17 -14
  67. package/test/browser/response-page.js +12 -0
  68. package/test/browser/row-preview.check.mjs +3 -3
  69. package/test/browser/screen.check.mjs +57 -13
  70. package/test/browser/serve-response.mjs +24 -0
  71. package/test/clip-ai-proposal.test.ts +1 -1
  72. package/test/clip-consent.test.ts +2 -2
  73. package/test/command-preview.test.ts +7 -7
  74. package/test/joint-drag.test.ts +6 -6
  75. package/test/joint-frames.test.ts +33 -32
  76. package/test/joints.ts +1 -6
  77. package/test/links.test.ts +21 -20
  78. package/test/multipart-response-fixture.js +17 -0
  79. package/test/multipart-response.test.ts +37 -0
  80. package/test/refusals.test.ts +2 -2
  81. package/test/resize-with-placement.test.ts +23 -1
  82. package/test/size-response.test.ts +49 -0
  83. package/test/size-source-pairing.test.ts +66 -0
  84. package/translations/en.json +44 -10
  85. package/translations/es.json +44 -10
  86. package/translations/ja.json +44 -10
  87. package/translations/ko.json +49 -15
  88. package/translations/ms.json +44 -10
  89. package/translations/zh.json +44 -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,7 +63,8 @@ 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'
@@ -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,8 @@ 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.run(() => this.session!.apply(figureSizeEditOf(this.shown()!.asset, e.detail.part, [{ kind: 'set-design', input: e.detail.input, unit: 'mm', value: e.detail.value }], { keep: 'centre' }).changes))}
821
828
  @fasten-open=${() => this.openFastening()}
822
829
  .previewWords=${this.commandPreview.words()}
823
830
  .detachWords=${this.commandPreview.detachWords(shown)}
@@ -837,21 +844,25 @@ export class FigureEditor extends LitElement {
837
844
  */
838
845
  private renderPartGroups(shown: FigureRevision | undefined, geometry: readonly { id: string }[], fields: FieldLike[], unavailable: boolean, held?: HeldValues) {
839
846
  const groups = this.withSizeRow(groupFields(fields, this.selected), held)
840
- const size = groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size')
847
+ const integrated = !!shown && canFollow(shown.asset, this.selected)
848
+ const dimensionInputs = new Set(integrated ? followableAxes(shown!.asset, this.selected).map(axis => sizeInputOf(shown!.asset, this.selected, axis)) : [])
849
+ const singles = groups.singles.filter(g => g.kind !== 'single' || !dimensionInputs.has(g.field.id))
850
+ const size = integrated ? [] : groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size')
841
851
  const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size')
842
852
  const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected))
843
853
  const placeLocked = fastened && place.every(g => g.kind === 'vector' && (['x', 'y', 'z'] as const).every(axis => !g.cells[axis]))
844
854
  return this.renderLinks(
845
855
  unavailable,
846
856
  'shape',
847
- html`${size.length || groups.singles.length
848
- ? html`<div slot="size" group-fields>
857
+ html`${size.length || singles.length
858
+ ? html`<section slot="size" group-fields>
859
+ <h3>${i18next.t(integrated ? 'figure.label.group-shape' : 'figure.label.group-size')}</h3>
849
860
  ${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>`
861
+ ${singles.length
862
+ ? html`<div form>${singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
852
863
  : nothing}
853
- ${this.renderSizeKeeps()}
854
- </div>`
864
+ ${!integrated ? this.renderSizeKeeps() : nothing}
865
+ </section>`
855
866
  : nothing}
856
867
  ${place.length && !placeLocked
857
868
  ? html`<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
@@ -860,7 +871,7 @@ export class FigureEditor extends LitElement {
860
871
  }
861
872
  /** The 「반응형」 tab of a part: the links' response groups, its row's own section put in by slot. */
862
873
  private renderResponse(shown: FigureRevision | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
863
- return this.renderLinks(unavailable, 'response', this.renderRepeat(shown, geometry, unavailable))
874
+ return this.renderLinks(unavailable, 'response', html`<div slot="size-note">${this.renderSizeKeeps()}</div>${this.renderRepeat(shown, geometry, unavailable)}`)
864
875
  }
865
876
  /**
866
877
  * 「더 보기」: what few parts need, folded by default and left as this person last left it (chief architect
@@ -967,6 +978,24 @@ export class FigureEditor extends LitElement {
967
978
  const done = this.linkCommand({ ...held.action, remap } as FigureAuthoringAction, held.from)
968
979
  if (done && held.from === 'section-editor') this.closeSectionEditor()
969
980
  }
981
+ /** The figure's size, declared for a part to follow, in one line (ADR ruling Q1). */
982
+ private sizeDeclaredWords() {
983
+ const size = this.designValues()
984
+ return i18next.t('figure.text.size-declared-to-follow', {
985
+ x: Math.round(size['size.x'] ?? 0).toLocaleString(),
986
+ y: Math.round(size['size.y'] ?? 0).toLocaleString(),
987
+ z: Math.round(size['size.z'] ?? 0).toLocaleString()
988
+ })
989
+ }
990
+ /**
991
+ * The size editor's steps, one step of the history. A measure made to follow the figure on a figure with no size
992
+ * declares it on the way; that is said in one line, as follow-size said it (ADR ruling Q1, 2026-10-24).
993
+ */
994
+ private applySizeSteps(steps: readonly FigureProposalStep[]) {
995
+ const hadSize = this.hasSize()
996
+ this.run(() => this.session!.applyRecipe(steps))
997
+ if (!hadSize && !this.error && this.hasSize()) this.notice = this.sizeDeclaredWords()
998
+ }
970
999
  /** Runs a command; true when it was taken. */
971
1000
  private linkCommand(action: FigureAuthoringAction, from: 'section-editor' | 'panel' = 'panel'): boolean {
972
1001
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
@@ -978,14 +1007,7 @@ export class FigureEditor extends LitElement {
978
1007
  this.error = ''
979
1008
  this.faceMapping = undefined
980
1009
  /* 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
- }
1010
+ if (action.kind === 'follow-size' && !hadSize) this.notice = this.sizeDeclaredWords()
989
1011
  this.changed()
990
1012
  return true
991
1013
  } catch (error) {
@@ -998,12 +1020,7 @@ export class FigureEditor extends LitElement {
998
1020
  this.error = ''
999
1021
  return false
1000
1022
  }
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)
1023
+ this.error = this.said(error)
1007
1024
  return false
1008
1025
  }
1009
1026
  }
@@ -1243,45 +1260,45 @@ export class FigureEditor extends LitElement {
1243
1260
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
1244
1261
  const own = `${this.selected}.repeat.pitch`
1245
1262
  /* 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
1263
  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
1264
+ 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
1265
  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)
1266
+ const record = asset.joints?.find(j => j.body1.part === this.selected && j.repeat)
1267
+ const parent = record && 'part' in record.body0 ? record.body0.part : undefined
1268
+ const removable = !!record
1269
+ const axes = record && 'part' in record.body0 && 'face' in record.body0 ? figureFastenAxesOf(asset, record.body0) : []
1270
+ const update = (change: Partial<NonNullable<typeof record>['repeat']>) => {
1271
+ if (!record?.repeat || unavailable) return
1272
+ this.run(() => this.session!.command({ kind: 'set-joint', joint: { ...record, repeat: { ...record.repeat!, ...change } } }))
1273
+ }
1255
1274
  return html`<section slot="row" row>
1256
1275
  <h3>
1257
1276
  ${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 })) }])}
1277
+ <span section-state active>${i18next.t('figure.label.repeat-active')}</span>
1259
1278
  </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>
1279
+ <div choice-row role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
1280
+ <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>
1281
+ <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>
1282
+ </div>
1283
+ <p hint>${i18next.t(fits ? 'figure.text.repeat-mode-pitch' : 'figure.text.repeat-mode-count')}</p>
1284
+ <div inline-field><label>${i18next.t('figure.label.size-source')}</label><span value>${parent ?? '—'}</span></div>
1285
+ <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>
1286
+ <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
1287
  <div inline-field>
1263
1288
  <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
1264
- <input
1289
+ ${fits ? html`<input
1265
1290
  type="number"
1266
1291
  step="any"
1267
- aria-label=${own}
1268
- .value=${String(pitch ?? '')}
1269
- ?disabled=${unavailable || !fits}
1292
+ aria-label=${i18next.t('figure.label.repeat-gap')}
1293
+ .value=${live(String(pitch ?? ''))}
1294
+ ?disabled=${unavailable}
1270
1295
  @change=${(e: Event) => this.setPitch(field, e)}
1271
- />
1296
+ />` : html`<span value>${pitch === undefined ? '—' : Number(pitch.toFixed(3))}</span>`}
1272
1297
  </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)}
1298
+ <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).slice(1).map(line => html`<li>${line}</li>`)}</ul>
1299
+ <button quiet ?disabled=${unavailable || !removable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>${i18next.t('figure.button.remove-repeat')}</button>
1300
+ <p hint>${i18next.t('figure.text.repeat-remove-explained')}</p>
1301
+ ${fits ? this.renderPublicPitch(asset, pitch ?? 0, unavailable) : nothing}
1285
1302
  </section>`
1286
1303
  }
1287
1304
  /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
@@ -1589,8 +1606,8 @@ export class FigureEditor extends LitElement {
1589
1606
  /** 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
1607
  private renderTab(tab: SideTab, icon: string, label: string) {
1591
1608
  /* 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)}>
1609
+ const name = i18next.t(tab === 'response' && this.selected ? 'figure.label.tab-arrangement' : 'figure.label.tab-short-' + tab)
1610
+ return html`<button tab ?on=${this.tab === tab} title=${tab === 'response' && this.selected ? name : label} aria-label=${name} @click=${() => (this.tab = tab)}>
1594
1611
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1595
1612
  </button>`
1596
1613
  }
@@ -1717,8 +1734,8 @@ export class FigureEditor extends LitElement {
1717
1734
  if (!summary) return nothing
1718
1735
  return html`<div subject>
1719
1736
  <div subject-summary>
1720
- ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1721
- ${summary.size ? html`<small>${summary.size}</small>` : nothing}
1737
+ <b>${ownerOf(this.selected)}</b>
1738
+ ${summary.kind ? html`<small>${summary.kind}</small>` : nothing}
1722
1739
  </div>
1723
1740
  </div>`
1724
1741
  }
@@ -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
@@ -3,24 +3,20 @@ import '@material/web/icon/icon.js'
3
3
  import { css, html, LitElement, nothing } from 'lit'
4
4
  import '@operato/input/ox-input-select-buttons.js'
5
5
  import './figure-slot-layout-panel.js'
6
+ import './figure-size-editor.js'
6
7
  import { customElement, property, state } from 'lit/decorators.js'
7
8
 
8
9
  import { i18next, localize } from '@operato/i18n'
9
10
  import { figureFaceOverhangOf } from '@hatiolab/figure-model'
10
- import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
11
+ import type { FigureAsset, FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
11
12
 
12
13
  import {
13
14
  acceptsOf,
14
15
  AXES,
15
16
  canFasten,
16
17
  canFollow,
17
- followCommandOf,
18
- followableAxes,
19
18
  positionIsShare,
20
- choiceOf,
21
- followWordsOf,
22
19
  heldBy,
23
- followOf,
24
20
  shownWhenOf,
25
21
  visibilityControlsOf,
26
22
  fasteningOf,
@@ -40,9 +36,7 @@ import {
40
36
  fixedJointOf,
41
37
  rowOf,
42
38
  positionsWorkedOut,
43
- measureNow,
44
39
  type Axis,
45
- type Follow,
46
40
  type MotionForm
47
41
  } from './links.js'
48
42
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
@@ -113,8 +107,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
113
107
  font-weight: 600;
114
108
  }
115
109
  /* What each direction does now: read-only rows. */
116
- div[resize-now] { display: grid; gap: 4px; margin: 12px 0; }
117
- div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
110
+ div[resize-now] { display: grid; grid-template-columns: 1fr auto auto auto; gap: 4px 8px; margin: 12px 0; }
111
+ div[resize-now] div[row] { grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; }
118
112
  div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
119
113
  /* Size behaviour is a choice about dimensions only. It never implies a placement or a fastening. */
120
114
  /* One of the size group's rows: the group's gap spaces it, as it does the rest (a margin on top of it left a hole). */
@@ -124,13 +118,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
124
118
  details { margin-top: 8px; }
125
119
  details[row-fold] { margin: 0; }
126
120
  details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
127
- details[row-fold] > summary h3 { margin: 0; }
121
+ details[row-fold] > summary h3 { margin: 0; flex: 1; }
128
122
  details[row-fold][open] > summary { margin-bottom: 8px; }
129
123
  /* What the command would do, said beside the result drawn on the canvas. */
130
124
  ul[preview-words] { margin: 8px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
131
125
  p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
132
- div[row-basis] { display: grid; gap: 4px; margin: 8px 0; }
133
- div[row-basis] label { display: flex; align-items: center; gap: 6px; }
134
126
  div[overhang] { display: grid; gap: 6px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--md-sys-color-outline-variant, #8884); }
135
127
  div[overhang] p { margin: 0; }
136
128
  ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; }
@@ -209,6 +201,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
209
201
  this.motion = freshMotion()
210
202
  this.place = freshPlace()
211
203
  this.row = freshRow()
204
+ this.rowOpen = false
205
+ this.rowCandidate = undefined
212
206
  this.editingMotion = false
213
207
  }
214
208
  }
@@ -273,13 +267,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
273
267
  How the part answers a size it is placed at (user 2026-10-17, the 「반응형」 tab): its own measures, where it
274
268
  stands, and the row it may make -- what Build Once, Fit Anywhere is authored with, in one place.
275
269
  */
276
- const positions = positionsWorkedOut(asset, this.partId)
270
+ const positions = held ? [] : positionsWorkedOut(asset, this.partId)
271
+ this.rowCandidate = undefined
277
272
  const response = html`
278
- <section size-response>
273
+ ${canFollow(asset, this.partId) ? html`<section size>
279
274
  <h3>${i18next.t('figure.label.group-size')}</h3>
280
- <p group-question>${i18next.t('figure.label.resize-intent')}</p>
281
- ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-follow')}</p>`}
282
- </section>
275
+ <figure-size-editor .revision=${this.revision} .partId=${this.partId} ?locked=${this.locked}></figure-size-editor>
276
+ <slot name="size-note"></slot>
277
+ </section>` : nothing}
283
278
  ${positions.length
284
279
  ? html`<section position-response>
285
280
  <h3>${i18next.t('figure.label.group-position-response')}</h3>
@@ -292,10 +287,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
292
287
  return response
293
288
  }
294
289
  const shown = html`
295
- <section size>
296
- <h3>${i18next.t('figure.label.group-size')}</h3>
297
- <slot name="size"></slot>
298
- </section>
290
+ <slot name="size"></slot>
299
291
  <section fastening>
300
292
  <h3>
301
293
  ${i18next.t('figure.label.group-place')}${this.help.button('figure.text.fastening-explained')}
@@ -379,80 +371,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
379
371
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }))
380
372
  }
381
373
 
382
- private renderDimensions(asset: FigureAsset) {
383
- const editable = followableAxes(asset, this.partId)
384
- const round = editable.length === 2
385
- const word = (axis: Axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
386
- const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
387
- const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance')
388
- /* A size of its own on every axis is the usual: nothing to read, only the way to make it follow (user 2026-10-01). */
389
- const allOwn = editable.every(axis => followOf(asset, this.partId, axis).of === 'own')
390
- return html`${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
391
- ${allOwn ? nothing : html`<div resize-now>${editable.map(axis => {
392
- const now = followOf(asset, this.partId, axis)
393
- const following = now.of === 'instance' && now.axis === axis
394
- const custom = now.of !== 'own' && !following
395
- const mm = measureNow(this.revision!, this.partId, axis)
396
- return html`<div row>
397
- <b>${word(axis)}</b>
398
- <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
399
- <span>${custom ? followWordsOf(now, (key, params) => i18next.t(key, params)) : i18next.t(following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
400
- ${following
401
- ? html`<button quiet resize-unfollow=${axis} ?disabled=${this.locked}
402
- @click=${() => {
403
- this.follow(axis, 'own', now)
404
- }}>${i18next.t('figure.button.resize-unfollow')}</button>`
405
- : nothing}
406
- </div>`
407
- })}</div>`}
408
- ${followsAll
409
- ? nothing
410
- : html`<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
411
- <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
412
- </button>`}
413
- <details resize-choice><summary>${i18next.t('figure.label.resize-advanced')}</summary><div>${followableAxes(asset, this.partId).map(axis => {
414
- const now = followOf(asset, this.partId, axis)
415
- const value = choiceOf(now)
416
- /* A cylinder's two measures are its radius and its length; its X and Y name them. */
417
- const name = round ? i18next.t(axis === 'x' ? 'figure.label.field-radius' : 'figure.label.field-length') : axis.toUpperCase()
418
- return html`<div row>
419
- <b>${name}</b>
420
- <select ?disabled=${this.locked} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
421
- <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
422
- ${AXES.filter(one => hasInstanceSize(asset, one)).map(
423
- one => html`<option value=${`share:${one}`} ?selected=${value === `share:${one}`}>
424
- ${i18next.t('figure.label.follows-instance-share', { axis: one.toUpperCase() })}
425
- </option>
426
- <option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
427
- ${i18next.t('figure.label.follows-instance', { axis: one.toUpperCase() })}
428
- </option>`
429
- )}
430
- ${others.flatMap(other =>
431
- AXES.map(
432
- one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${i18next.t('figure.label.follows-part-axis', { part: other, axis: one.toUpperCase() })}</option>`
433
- )
434
- )}
435
- ${now.of === 'expression' || now.of === 'input'
436
- ? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
437
- : nothing}
438
- </select>
439
- ${now.of === 'instance' || now.of === 'part'
440
- ? html`<span></span>
441
- <label inline-plus>
442
- ${i18next.t('figure.label.follows-plus')}
443
- <input
444
- type="number"
445
- step="any"
446
- ?disabled=${this.locked}
447
- .value=${String(now.plus ?? 0)}
448
- @change=${(e: Event) => this.followPlus(axis, now, Number((e.target as HTMLInputElement).value) || 0)}
449
- />
450
- </label>`
451
- : nothing}
452
- </div>`
453
- })}</div></details>`
454
- }
455
-
456
374
  /**
457
375
  * 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
458
376
  * out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
@@ -503,20 +421,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
503
421
  return where
504
422
  }
505
423
 
506
- /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
507
- private followPlus(axis: Axis, now: Follow, plus: number) {
508
- if (now.of !== 'instance' && now.of !== 'part') return
509
- if ((now.plus ?? 0) === plus) return
510
- const source: FigureDimensionSource = now.of === 'instance' ? { of: 'instance', axis: now.axis } : { of: 'part', part: now.part, axis: now.axis }
511
- const times = now.of === 'instance' ? now.times : (now.times ?? 1)
512
- this.command({ kind: 'link-dimension', part: this.partId, axis, source, ...(times !== 1 ? { times } : {}), ...(plus ? { plus } : {}), replace: true })
513
- }
514
-
515
- private follow(axis: Axis, choice: string, now: Follow) {
516
- const action = followCommandOf(this.revision!, this.partId, axis, choice, now)
517
- if (action) this.command(action)
518
- }
519
-
520
424
  /**
521
425
  * Positions worked out from other values, each with the way to
522
426
  * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
@@ -617,9 +521,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
617
521
  Folded, open while it is being worked (2026-10-02): a row is made now and then, and most parts stand on the plane,
618
522
  where the section said its reason open on every one. Opened, it says why or asks how.
619
523
  */
620
- const head = html`<summary><h3>${i18next.t('figure.label.repeat')}</h3></summary>`
524
+ const head = html`<summary><h3>${i18next.t('figure.label.repeat')} <span section-state>${i18next.t('figure.label.repeat-inactive')}</span></h3></summary>`
621
525
  if (blocked || !axes.length)
622
- return html`<section row><details row-fold>
526
+ return html`<section row><details row-fold open>
623
527
  ${head}
624
528
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
625
529
  ${blocked === 'unfastened'
@@ -640,27 +544,29 @@ export class FigureLinks extends localize(i18next)(LitElement) {
640
544
  const toggled = (e: Event) => (this.rowOpen = (e.target as HTMLDetailsElement).open)
641
545
  return html`<section row><details row-fold ?open=${this.rowOpen} @toggle=${toggled}>
642
546
  ${head}
547
+ <p hint>${i18next.t('figure.text.repeat-intro')}</p>
643
548
  <div form>
644
549
  <span>${i18next.t('figure.label.repeat-axis')}</span>
645
- <select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
550
+ <select aria-label=${i18next.t('figure.label.repeat-axis')} ?disabled=${this.locked} @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
646
551
  ${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
647
552
  </select>
648
553
  </div>
649
- <div row-basis role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
650
- <label><input type="radio" name="row-basis" .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
651
- <label><input type="radio" name="row-basis" .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
554
+ <p hint>${i18next.t('figure.text.repeat-direction')}</p>
555
+ <div choice-row role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
556
+ <label toggle><input type="radio" name="row-basis" ?disabled=${this.locked} .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
557
+ <label toggle><input type="radio" name="row-basis" ?disabled=${this.locked} .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
652
558
  </div>
653
559
  ${r.fills
654
560
  ? html`<div form>
655
561
  <span>${i18next.t('figure.label.repeat-pitch')} · mm</span>
656
- <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => set({ ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
562
+ <input aria-label=${i18next.t('figure.label.repeat-pitch')} ?disabled=${this.locked} type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => set({ ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
657
563
  </div>`
658
564
  : html`<div form>
659
565
  <span>${i18next.t('figure.label.repeat-count')}</span>
660
- <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
566
+ <input aria-label=${i18next.t('figure.label.repeat-count')} ?disabled=${this.locked} type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
661
567
  </div>
662
568
  <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
663
- ${action && this.rowOpen ? this.renderPreviewWords() : nothing}
569
+ ${action && this.rowOpen ? html`<p hint>${i18next.t('figure.text.repeat-draft')}</p>${this.renderPreviewWords()}` : nothing}
664
570
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
665
571
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
666
572
  </button>
@@ -799,7 +705,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
799
705
  <span>${i18next.t('figure.label.motion-follow')}</span>
800
706
  <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
801
707
  <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
802
- ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
708
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
803
709
  </select>
804
710
  </div>`
805
711
  : nothing}