@things-factory/figure-ui 10.1.205 → 10.1.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/client/modeller/editor-styles.ts +16 -0
  2. package/client/modeller/editor.ts +43 -41
  3. package/client/modeller/fasten-form.ts +50 -11
  4. package/client/modeller/figure-fasten-dialog.ts +3 -2
  5. package/client/modeller/figure-links.ts +2 -2
  6. package/client/modeller/figure-preview.ts +6 -6
  7. package/client/modeller/gizmo.ts +10 -0
  8. package/client/modeller/keys.ts +10 -1
  9. package/client/modeller/links.ts +32 -41
  10. package/client/modeller/palette.ts +1 -1
  11. package/client/modeller/product-session.ts +16 -4
  12. package/client/modeller/refusal-teller.ts +3 -25
  13. package/client/modeller/refusals.ts +19 -26
  14. package/dist-client/modeller/editor-styles.js +16 -0
  15. package/dist-client/modeller/editor-styles.js.map +1 -1
  16. package/dist-client/modeller/editor.d.ts +7 -3
  17. package/dist-client/modeller/editor.js +47 -42
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/fasten-form.d.ts +16 -7
  20. package/dist-client/modeller/fasten-form.js +45 -11
  21. package/dist-client/modeller/fasten-form.js.map +1 -1
  22. package/dist-client/modeller/figure-fasten-dialog.d.ts +1 -1
  23. package/dist-client/modeller/figure-fasten-dialog.js +3 -3
  24. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  25. package/dist-client/modeller/figure-links.d.ts +1 -1
  26. package/dist-client/modeller/figure-links.js +2 -2
  27. package/dist-client/modeller/figure-links.js.map +1 -1
  28. package/dist-client/modeller/figure-preview.js +4 -4
  29. package/dist-client/modeller/figure-preview.js.map +1 -1
  30. package/dist-client/modeller/gizmo.d.ts +6 -0
  31. package/dist-client/modeller/gizmo.js +11 -0
  32. package/dist-client/modeller/gizmo.js.map +1 -1
  33. package/dist-client/modeller/keys.d.ts +1 -1
  34. package/dist-client/modeller/keys.js +6 -0
  35. package/dist-client/modeller/keys.js.map +1 -1
  36. package/dist-client/modeller/links.d.ts +16 -22
  37. package/dist-client/modeller/links.js +26 -39
  38. package/dist-client/modeller/links.js.map +1 -1
  39. package/dist-client/modeller/palette.js +1 -1
  40. package/dist-client/modeller/palette.js.map +1 -1
  41. package/dist-client/modeller/product-session.d.ts +15 -13
  42. package/dist-client/modeller/product-session.js +15 -5
  43. package/dist-client/modeller/product-session.js.map +1 -1
  44. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  45. package/dist-client/modeller/refusal-teller.js +3 -22
  46. package/dist-client/modeller/refusal-teller.js.map +1 -1
  47. package/dist-client/modeller/refusals.d.ts +6 -14
  48. package/dist-client/modeller/refusals.js +16 -27
  49. package/dist-client/modeller/refusals.js.map +1 -1
  50. package/dist-client/route.d.ts +1 -1
  51. package/dist-client/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +7 -7
  53. package/test/browser/acceptance-page.js +1 -1
  54. package/test/browser/links.check.mjs +30 -13
  55. package/test/browser/modeller.check.mjs +45 -16
  56. package/test/browser/parts.check.mjs +49 -0
  57. package/test/browser/section.check.mjs +27 -0
  58. package/test/fasten-form.test.ts +42 -2
  59. package/test/gizmo.test.ts +12 -1
  60. package/test/keys.test.ts +11 -0
  61. package/test/links.test.ts +38 -33
  62. package/test/refusals.test.ts +17 -21
  63. package/translations/en.json +8 -13
  64. package/translations/es.json +8 -13
  65. package/translations/ja.json +8 -13
  66. package/translations/ko.json +8 -13
  67. package/translations/ms.json +8 -13
  68. package/translations/zh.json +8 -13
@@ -131,6 +131,22 @@ export const EditorStyles = css`
131
131
  min-width: 0;
132
132
  min-height: 0;
133
133
  }
134
+ /* Which size handles change one diameter (ADR-0140 4.1): a quiet line over the canvas, out of the gizmo's way. */
135
+ p[same-measure] {
136
+ position: absolute;
137
+ bottom: 12px;
138
+ left: 50%;
139
+ z-index: 5;
140
+ transform: translateX(-50%);
141
+ margin: 0;
142
+ padding: 4px 10px;
143
+ border-radius: 999em;
144
+ background: var(--md-sys-color-surface-container-high);
145
+ color: var(--md-sys-color-on-surface-variant);
146
+ font-size: var(--ui-t-small, 11.5px);
147
+ pointer-events: none;
148
+ white-space: nowrap;
149
+ }
134
150
  /* A new figure's first question, over the empty canvas. */
135
151
  div[start] {
136
152
  position: absolute;
@@ -30,13 +30,13 @@ import './figure-preview.js'
30
30
  import './figure-settings.js'
31
31
  import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
32
32
  import './figure-capability-panel.js'
33
- import { gizmoChanges, gizmoReachOf, sizeKeepOf, type GizmoReach, type GizmoResult } from './gizmo.js'
33
+ import { gizmoChanges, gizmoReachOf, sameMeasureAxesOf, sizeKeepOf, type GizmoReach, type GizmoResult } from './gizmo.js'
34
34
  import { keptBySizeOf, partsAtFrame, SIZE_KEEPS_WORDS, startSizeDrag, throughPlaneOf, type SizeDrag, type SizeDragFrame } from './size-drag.js'
35
35
  import type { KeyAction } from './keys.js'
36
36
  import { ModellerKeys } from './modeller-keys.js'
37
37
  import { PaneFold } from './pane-fold.js'
38
38
  import { PanelMemory } from './panel-memory.js'
39
- import { RefusalTeller, type Retry } from './refusal-teller.js'
39
+ import { RefusalTeller } from './refusal-teller.js'
40
40
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
41
41
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
42
42
  import { EditorStyles } from './editor-styles.js'
@@ -60,11 +60,11 @@ type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | u
60
60
  import { occupancyFromParts, roomRefitRowsOf } from './occupancy.js'
61
61
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
62
62
  import type { FigureGateFinding } from '../types.js'
63
- import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
63
+ import { editRefusalOf, inDeclaredRoom, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
64
64
  import type { FigureRenderPart } from './renderer.js'
65
65
  import type { FigureBudgetLine } from './maturity.js'
66
66
  import { HelpToggles } from './help.js'
67
- import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
67
+ import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
68
68
  import { jointFanOf } from './joint-fan.js'
69
69
  import { derivedOf, type FigureRevision } from './revision.js'
70
70
  import { jointDragOf } from './joint-drag.js'
@@ -135,8 +135,6 @@ export class FigureEditor extends LitElement {
135
135
  * edit was made (`from`).
136
136
  */
137
137
  @state() private faceMapping?: { action: FigureAuthoringAction; need: FigureFaceMappingNeed; from: 'section-editor' | 'panel' }
138
- /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
139
- @state() private retry?: Retry
140
138
  @state() private tab: SideTab = 'properties'
141
139
  /** The view settings or the release history, open over the work from the bar. */
142
140
  @state() private overlay?: BarOverlay
@@ -268,6 +266,14 @@ export class FigureEditor extends LitElement {
268
266
  selected: () => this.selected,
269
267
  act: action => this.keyAction(action)
270
268
  })
269
+ /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
270
+ private copied = ''
271
+ /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
272
+ private pasteCopied() {
273
+ const asset = this.session?.revisionNow()?.asset
274
+ if (!this.copied || !asset?.document.model.nodes.some(n => n.op === 'place@1' && n.id === this.copied)) return
275
+ this.duplicatePart(this.copied)
276
+ }
271
277
  private keyAction(action: KeyAction) {
272
278
  const view = this.session!.read()
273
279
  const canvas = this.renderRoot.querySelector<FigureCanvas>('figure-canvas')
@@ -275,6 +281,9 @@ export class FigureEditor extends LitElement {
275
281
  else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
276
282
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
277
283
  else if (action === 'duplicate') this.duplicatePart(this.selected)
284
+ else if (action === 'copy') this.copied = this.selected
285
+ else if (action === 'paste') this.pasteCopied()
286
+ else if (action === 'hold') return
278
287
  else if (action === 'remove') this.removePart(this.selected)
279
288
  else this.selected = ''
280
289
  }
@@ -283,9 +292,7 @@ export class FigureEditor extends LitElement {
283
292
  session: () => this.session,
284
293
  error: () => this.error,
285
294
  notice: () => this.notice,
286
- retry: () => this.retry,
287
- refuse: message => (this.error = message),
288
- command: action => this.linkCommand(action)
295
+ refuse: message => (this.error = message)
289
296
  })
290
297
  private panelMemory = new PanelMemory(() => this.renderRoot)
291
298
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
@@ -331,6 +338,14 @@ export class FigureEditor extends LitElement {
331
338
  if (closed && this.selected === editing) this.selected = next
332
339
  })
333
340
  }
341
+ /** While the size tool is on a round part: which of its handles change the one diameter (ADR-0140 4.1). */
342
+ private renderSameMeasure(unavailable: boolean) {
343
+ if (unavailable || this.gizmoMode !== 'scale' || !this.selected) return nothing
344
+ const axes = sameMeasureAxesOf(this.reachOf(this.selected))
345
+ return axes
346
+ ? html`<p same-measure role="status">${i18next.t('figure.text.gizmo-same-measure', { axes: axes.map(a => a.toUpperCase()).join(' · ') })}</p>`
347
+ : nothing
348
+ }
334
349
  /** The scene toggles: the mode already shown takes the gizmo away. */
335
350
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
336
351
  /*
@@ -412,6 +427,9 @@ export class FigureEditor extends LitElement {
412
427
  }
413
428
 
414
429
  private changed() {
430
+ /* What the step did to the size ends the author typed, said with whatever else it said. */
431
+ const fits = this.session?.takeSizeRangeFits() ?? []
432
+ if (fits.length) this.notice = [this.notice, sizeRangeFitsOf(fits, (key, params) => i18next.t(key, params))].filter(Boolean).join(' ')
415
433
  this.requestUpdate()
416
434
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
417
435
  }
@@ -507,7 +525,6 @@ export class FigureEditor extends LitElement {
507
525
  this.run(() => this.session!.edit({ kind: 'move-part', target: id, to }))
508
526
  }
509
527
  private removePart(id: string, refitRoom = false) {
510
- this.retry = undefined
511
528
  try {
512
529
  const view = this.session!.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) }) as { removal?: Parameters<typeof removalNoticeOf>[0] }
513
530
  this.error = ''
@@ -965,7 +982,6 @@ export class FigureEditor extends LitElement {
965
982
  private linkCommand(action: FigureAuthoringAction, from: 'section-editor' | 'panel' = 'panel'): boolean {
966
983
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
967
984
  this.holdAction(null)
968
- this.retry = undefined
969
985
  this.notice = ''
970
986
  const hadSize = this.hasSize()
971
987
  const hadSpace = !!this.shown()?.asset.occupancy
@@ -996,12 +1012,6 @@ export class FigureEditor extends LitElement {
996
1012
  this.error = ''
997
1013
  return false
998
1014
  }
999
- const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
1000
- if (narrowing) {
1001
- this.error = narrowing.message
1002
- this.retry = { label: narrowing.label, action: narrowing.retry as FigureAuthoringAction }
1003
- return false
1004
- }
1005
1015
  this.error =
1006
1016
  code === 'DIMENSION_CYCLE'
1007
1017
  ? i18next.t('figure.text.follow-would-loop')
@@ -1086,21 +1096,21 @@ export class FigureEditor extends LitElement {
1086
1096
  label.addEventListener('pointerup', up)
1087
1097
  label.addEventListener('pointercancel', up)
1088
1098
  }
1089
- /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
1099
+ /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1090
1100
  private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
1091
1101
  if (!shown) return nothing
1092
1102
  const asset = shown.asset
1093
- /* Measured once per version: measuring evaluates the figure at its edges. */
1103
+ /* Computed once per version: figure-model evaluates the figure at its edges. */
1094
1104
  const measured = derivedOf(shown, 'instance-range', () => {
1095
1105
  let of: ReturnType<typeof instanceRangeOf>
1096
- let toDeclare: ReturnType<typeof instanceRangeToDeclare>
1106
+ let toDeclare: ReturnType<typeof instanceSizeToDeclare>
1097
1107
  try {
1098
1108
  of = instanceRangeOf(asset)
1099
1109
  } catch {
1100
1110
  of = undefined
1101
1111
  }
1102
1112
  try {
1103
- toDeclare = of ? undefined : instanceRangeToDeclare(asset)
1113
+ toDeclare = of ? undefined : instanceSizeToDeclare(asset)
1104
1114
  } catch {
1105
1115
  toDeclare = undefined
1106
1116
  }
@@ -1121,13 +1131,17 @@ export class FigureEditor extends LitElement {
1121
1131
  return html`<section board-size>
1122
1132
  ${title('figure.text.board-size-fixed-explained')}
1123
1133
  <p fixed-size>${i18next.t('figure.text.board-size-fixed', { x: mm(toDeclare.at.x), y: mm(toDeclare.at.y), z: mm(toDeclare.at.z) })}</p>
1124
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })}>
1134
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', at: toDeclare.at })}>
1125
1135
  <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1126
1136
  </button>
1127
1137
  </section>`
1128
1138
  if (!range) return nothing
1129
- const differs = rangeDiffers(range.declared, range.measured)
1130
1139
  const followers = sizeFollowersOf(asset)
1140
+ /*
1141
+ An end the author typed shows its number; an open end is empty, with what the parts allow written faintly in it
1142
+ (ADR-0087 decision 6). Typing narrows that end, emptying the field opens it again.
1143
+ */
1144
+ const shownEnd = (r: (typeof range)['x'], bound: 'min' | 'max') => (bound === 'max' && !Number.isFinite(r.max) ? '∞' : mm(r[bound]))
1131
1145
  return html`<section board-size>
1132
1146
  ${title('figure.text.board-size-range-explained')}
1133
1147
  ${(['x', 'y', 'z'] as const).map(
@@ -1139,11 +1153,12 @@ export class FigureEditor extends LitElement {
1139
1153
  aria-label=${`size.${axis}.${bound}`}
1140
1154
  type="number"
1141
1155
  step="any"
1142
- .value=${live(String(Math.round(range.declared[axis][bound])))}
1156
+ .value=${live(range[axis].narrowed[bound] ? String(Math.round(range[axis][bound])) : '')}
1157
+ placeholder=${shownEnd(range[axis], bound)}
1143
1158
  ?disabled=${unavailable}
1144
1159
  @change=${(e: Event) => {
1145
- const next = rangeWith(range.declared, axis, bound, (e.target as HTMLInputElement).valueAsNumber)
1146
- if (next) this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true })
1160
+ const typed = (e.target as HTMLInputElement).valueAsNumber
1161
+ this.linkCommand({ kind: 'declare-instance-size', range: rangeWith(range, axis, bound, typed), replace: true })
1147
1162
  }}
1148
1163
  />`
1149
1164
  )}
@@ -1157,19 +1172,6 @@ export class FigureEditor extends LitElement {
1157
1172
  })
1158
1173
  : i18next.t('figure.text.board-size-no-followers')}
1159
1174
  </p>
1160
- ${differs
1161
- ? html`<p hint>
1162
- ${i18next.t('figure.text.instance-range-can-widen', {
1163
- ranges: (['x', 'y', 'z'] as const)
1164
- .filter(axis => rangeDiffers(range.declared, { ...range.declared, [axis]: range.measured[axis] }))
1165
- .map(axis => `${axis.toUpperCase()} ${mm(range.measured[axis].min)} – ${mm(range.measured[axis].max)}`)
1166
- .join(' · ')
1167
- })}
1168
- </p>
1169
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })}>
1170
- <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
1171
- </button>`
1172
- : nothing}
1173
1175
  ${this.renderSizingRule(asset, unavailable)}
1174
1176
  </section>`
1175
1177
  }
@@ -1325,7 +1327,7 @@ export class FigureEditor extends LitElement {
1325
1327
  this.publishing?.part === part
1326
1328
  ? this.publishing
1327
1329
  : listed && input
1328
- ? { part, label: listed.label, min: input.min, max: input.max }
1330
+ ? /* A published input has both ends (figure-model checkPublicInput). */ { part, label: listed.label, min: input.min!, max: input.max! }
1329
1331
  : undefined
1330
1332
  const open = !!form
1331
1333
  const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
@@ -1921,7 +1923,7 @@ export class FigureEditor extends LitElement {
1921
1923
  @render-error=${(e: CustomEvent<Error>) => {
1922
1924
  this.error = e.detail.message
1923
1925
  }}
1924
- ></figure-canvas></div>
1926
+ ></figure-canvas>${this.renderSameMeasure(unavailable)}</div>
1925
1927
  <aside slot="end">
1926
1928
  <div tabs>
1927
1929
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1,4 +1,4 @@
1
- import { FIGURE_FACES, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureFastenKeptGapOf, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
3
3
 
4
4
  import { faceViewOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
@@ -38,6 +38,12 @@ export interface FastenForm {
38
38
  gap: number
39
39
  /** The record's gap when it is worked out (a length that follows something): kept until a number is typed. */
40
40
  gapKept: unknown
41
+ /**
42
+ * Where the gap came from (ADR-0140 stage 4, 「현재 상태로 붙이기」): `kept` -- the distance the part stands at, worked
43
+ * out again whenever where or how it is fastened changes, so fastening does not move it; `typed` -- the author's
44
+ * number, left as it is; `pressed` -- 0, the part put where the canvas was pressed.
45
+ */
46
+ gapFrom: 'kept' | 'typed' | 'pressed'
41
47
  align: Partial<Record<Axis, Alignment>>
42
48
  alignMm: Partial<Record<Axis, number>>
43
49
  alignInput: Partial<Record<Axis, string>>
@@ -52,6 +58,7 @@ export const freshFasten = (): FastenForm => ({
52
58
  facing: 'meet',
53
59
  gap: 0,
54
60
  gapKept: undefined,
61
+ gapFrom: 'kept',
55
62
  align: {},
56
63
  alignMm: {},
57
64
  alignInput: {}
@@ -60,7 +67,9 @@ export const freshFasten = (): FastenForm => ({
60
67
  /** The part's own fastening as a form, or a fresh one: a window opens on what the part has (walk 1, 2026-09-26). */
61
68
  export function fastenFormOf(revision: FigureRevision, part: string): FastenForm {
62
69
  const own = fasteningOf(revision, part)
63
- return own && !own.atOrigin ? ({ ...freshFasten(), ...own } as FastenForm) : freshFasten()
70
+ if (!own || own.atOrigin) return freshFasten()
71
+ /* A gap the record works out is the author's; a number is where the part stands, so aiming again keeps it there. */
72
+ return { ...freshFasten(), ...own, gapFrom: own.gapKept !== undefined ? 'typed' : 'kept' } as FastenForm
64
73
  }
65
74
 
66
75
  /** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
@@ -133,11 +142,11 @@ export function fastenChanged(
133
142
  change: FastenChange
134
143
  ): FastenForm {
135
144
  const f = form
136
- if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
137
- if ('onSurface' in change) return aimFasten(revision, part, f, { surface: true })
138
- if ('surface' in change) return { ...f, surface: change.surface }
139
- if ('mode' in change) return { ...f, facing: change.mode }
140
- if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
145
+ if ('targetFace' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace }))
146
+ if ('onSurface' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { surface: true }))
147
+ if ('surface' in change) return keptGapOf(revision, part, { ...f, surface: change.surface })
148
+ if ('mode' in change) return keptGapOf(revision, part, { ...f, facing: change.mode })
149
+ if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined, gapFrom: 'typed' }
141
150
  return {
142
151
  ...f,
143
152
  align: { ...f.align, [change.axis]: change.how },
@@ -145,6 +154,29 @@ export function fastenChanged(
145
154
  }
146
155
  }
147
156
 
157
+ /**
158
+ * The form with the gap that keeps the part where it stands (ADR-0140 stage 4, user 2026-10-20 「현재 상태로 붙이기」):
159
+ * figure-model's `figureFastenKeptGapOf` for what it is aimed at and how it meets it. A gap the author typed, or the 0 a
160
+ * canvas press puts, is left as it is. Nothing aimed at yet, the form is as it was.
161
+ */
162
+ export function keptGapOf(revision: FigureRevision, part: string, form: FastenForm): FastenForm {
163
+ if (form.gapFrom !== 'kept') return form
164
+ const asset = revision.asset
165
+ const theirFaces = form.target ? facesOf(asset, form.target) : FACES
166
+ const targetFace = (theirFaces.includes(form.targetFace) ? form.targetFace : theirFaces[0]) as FigureFace
167
+ const planeWay = figurePlaneFacingOf(asset)
168
+ const mode: FastenMode = form.toPlane ? (mountingPlaneOf(asset).facings.includes(form.facing) ? form.facing : (planeWay ?? 'meet')) : form.facing
169
+ const to = form.toPlane
170
+ ? ('plane' as const)
171
+ : form.target && form.surface && surfaceOf(asset, form.target)
172
+ ? { part: form.target, surface: form.surface }
173
+ : form.target
174
+ ? { part: form.target, face: targetFace }
175
+ : undefined
176
+ if (!to) return form
177
+ return { ...form, gap: figureFastenKeptGapOf(asset, part, to, mode, { ...revision.inputs }), gapKept: undefined }
178
+ }
179
+
148
180
  /** What a form comes to: the face aimed at, the way, the drawing of the face and the command. */
149
181
  export interface FastenPlan {
150
182
  /** Nothing is aimed at until a part or the plane is chosen. */
@@ -226,12 +258,16 @@ export function fastenSentenceOf(
226
258
  return say(`figure.text.fasten-sentence-${plan.mode}`, { to })
227
259
  }
228
260
 
229
- /** What the folded details hold that is not their default, said while folded; nothing for the default. */
261
+ /**
262
+ * What the folded details hold that is not their default, said while folded; nothing for the default. The gap's default
263
+ * is the one it starts at -- the distance the part stands at, or 0 where the canvas was pressed -- so only a number typed
264
+ * is said.
265
+ */
230
266
  export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
231
267
  return [
232
268
  ...(form.gapKept !== undefined
233
269
  ? [say('figure.text.fasten-summary-gap-worked-out')]
234
- : form.gap
270
+ : form.gapFrom === 'typed' && form.gap
235
271
  ? [say('figure.text.fasten-summary-gap', { mm: String(form.gap) })]
236
272
  : [])
237
273
  ]
@@ -244,6 +280,9 @@ export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
244
280
  * nothing to fasten to is not aimed at; a press on a curve there is no fastening to aims at the part alone. Each says
245
281
  * why, by its words' key.
246
282
  */
283
+ /* Put where the canvas was pressed: on the face or the surface there, with no gap (ADR-0140 stage 4). */
284
+ const PRESSED = { gap: 0, gapKept: undefined, gapFrom: 'pressed' as const }
285
+
247
286
  export function aimFastenAt(
248
287
  revision: FigureRevision,
249
288
  part: string,
@@ -265,8 +304,8 @@ export function aimFastenAt(
265
304
  /* On the face where it was pressed, as a point of a curved surface is: its middle there. */
266
305
  const onFace = aimFasten(revision, part, { ...aimed, surface: undefined }, { targetFace: lands.face as FigureFace })
267
306
  const at = lands.at as Partial<Record<Axis, number>>
268
- return { form: { ...onFace, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
307
+ return { form: { ...onFace, ...PRESSED, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
269
308
  }
270
- if ('surface' in lands) return { form: { ...aimed, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
309
+ if ('surface' in lands) return { form: { ...aimed, ...PRESSED, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
271
310
  return { form: aimed, note: 'aim-not-here', part: target }
272
311
  }
@@ -16,6 +16,7 @@ import { turnsInPlace, type FastenMode } from './fasten-geometry.js'
16
16
  import {
17
17
  aimFasten,
18
18
  aimFastenAt,
19
+ keptGapOf,
19
20
  fastenChanged,
20
21
  fastenDetailWordsOf,
21
22
  fastenFormOf,
@@ -260,7 +261,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
260
261
  step="any"
261
262
  .value=${f.gapKept !== undefined ? '' : String(f.gap)}
262
263
  placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
263
- @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined })}
264
+ @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined, gapFrom: 'typed' })}
264
265
  />
265
266
  </div>
266
267
  </details>
@@ -330,7 +331,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
330
331
  this.aimNote = ''
331
332
  const chosen = this.form.toPlane ? PLANE : this.form.target
332
333
  if (part === chosen) return
333
- this.form = aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part })
334
+ this.form = keptGapOf(this.revision, this.partId, aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part }))
334
335
  }
335
336
 
336
337
  /**
@@ -278,7 +278,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
278
278
  <section size-response>
279
279
  <h3>${i18next.t('figure.label.group-size')}</h3>
280
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.links-need-a-box')}</p>`}
281
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-follow')}</p>`}
282
282
  </section>
283
283
  ${positions.length
284
284
  ? html`<section position-response>
@@ -302,7 +302,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
302
302
  ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
303
303
  </h3>${this.help.hint('figure.text.fastening-explained')}
304
304
  <slot name="place"></slot>
305
- ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
305
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-fasten')}</p>`}
306
306
  </section>
307
307
  `
308
308
  this.candidate = this.fastenCandidate
@@ -3,14 +3,14 @@ import { customElement, property, query, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { compileFigureAsset } from '@hatiolab/figure-model'
6
- import type { FigureAsset, FigureDataRow, FigureDataValue } from '@hatiolab/figure-model'
6
+ import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
7
7
  import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
11
11
  import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
12
12
  import { derivedOf, type FigureRevision } from './revision.js'
13
- import { authoredSizeOf, occupancyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeRangeOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
13
+ import { authoredSizeOf, occupancyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
14
14
  import { previewSizeWordsOf } from './preview-size.js'
15
15
 
16
16
  /**
@@ -335,7 +335,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
335
335
  if (!asset.occupancy) return html`<div sizes><p said>${i18next.t('figure.text.preview-needs-space')}</p></div>`
336
336
  return html`<div sizes>
337
337
  ${(['x', 'y', 'z'] as const).map(axis => {
338
- const range = sizeRangeOf(asset, axis)
338
+ const range = sizeSpanOf(asset, axis)
339
339
  if (!range) return nothing
340
340
  return html`<label>
341
341
  <span>${i18next.t(`figure.label.measure-${axis}`)}</span>
@@ -377,7 +377,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
377
377
  this.scene?.root?.fitCameraToModel?.()
378
378
  }
379
379
 
380
- private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
380
+ private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
381
381
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
382
382
  return html`<div clips>
383
383
  ${changed.length
@@ -609,8 +609,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
609
609
  type: 'model-layer',
610
610
  width: board.width,
611
611
  height: board.height,
612
- /* As tall as the figure may grow: twice for the eight, the declared height range's top for the one. */
613
- depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeRangeOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
612
+ /* As tall as the figure may grow: twice for the eight, the top of the height slider for the one. */
613
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeSpanOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
614
614
  threed: true,
615
615
  sky: 'color',
616
616
  ...sceneLook(view),
@@ -111,6 +111,16 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
111
111
  }
112
112
 
113
113
  /** Where a drag left the part, in the asset frame: millimetres, degrees and per-axis scale. */
114
+ /**
115
+ * The size handles that change one measure (ADR-0140 4.1): a round part's radius across is one value, so its X and Z
116
+ * handles are the same diameter, and a sphere's three are one. Said on the canvas while the size tool is on, to see how
117
+ * it handles before deciding to show fewer. Nothing for a part whose handles each change their own measure.
118
+ */
119
+ export function sameMeasureAxesOf(reach: GizmoReach): Array<'x' | 'y' | 'z'> | undefined {
120
+ if (!reach.scale || !reach.round) return undefined
121
+ return reach.round.along.length ? ['x', 'z'] : ['x', 'y', 'z']
122
+ }
123
+
114
124
  export interface GizmoResult {
115
125
  position?: [number, number, number]
116
126
  rotation?: [number, number, number]
@@ -5,7 +5,13 @@
5
5
  * Typing is never taken: a key pressed in a field belongs to the field -- Backspace in a name box deletes a letter,
6
6
  * not the part.
7
7
  */
8
- export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | GizmoKey
8
+ /*
9
+ * `copy` · `paste` are the modeller's own: a part copied and pasted is the kernel's duplicate, with a name of its own.
10
+ * Left to the scene, Ctrl+C / Ctrl+V copied the canvas object with the part's name -- two objects of one name, the copy
11
+ * moving the original (user 2026-10-21). `hold` is a key kept from the scene that the modeller has no use for: the
12
+ * scene's cut and bulk paste act on canvas objects alone and leave the figure out of step with them.
13
+ */
14
+ export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | 'copy' | 'paste' | 'hold' | GizmoKey
9
15
 
10
16
  /** The gizmo's modes on W, E and R, as the scene has them. */
11
17
  export type GizmoKey = 'translate' | 'rotate' | 'scale'
@@ -36,6 +42,9 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
36
42
  const key = e.key.toLowerCase()
37
43
  if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
38
44
  if (mod && key === 'y') return 'redo'
45
+ if (mod && key === 'c') return selected ? 'copy' : 'hold'
46
+ if (mod && key === 'v') return e.shiftKey ? 'hold' : 'paste'
47
+ if (mod && key === 'x') return 'hold'
39
48
  if (!selected) return null
40
49
  if (mod && key === 'd') return 'duplicate'
41
50
  if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
@@ -1,5 +1,5 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
- import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeRangeOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureFollow, FigureInstanceSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureSurfacePlace } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
5
  import { boxOf } from './align.js'
@@ -733,53 +733,48 @@ export const holdsThings = (asset: FigureAsset) => (asset.document.capabilities.
733
733
  export const acceptsOf = (typed: string) => [...new Set(typed.split(',').map(one => one.trim()).filter(Boolean))]
734
734
 
735
735
  /**
736
- * The sizes a board may give this figure: the range it declares now, and the range measured afresh -- the default
737
- * (1 mm to ten times the authored size) narrowed only where the figure cannot follow, by figure-model's own functions.
738
- * A range measured when a part filled what the others left (ROBOT_ARM: Y from 1,854 mm) stays narrow after the part is
739
- * made to grow with the figure; measuring again gives the figure its whole range back. Nothing when no size is declared.
740
- */
741
- export function instanceRangeOf(asset: FigureAsset): { declared: FigureSizeRange; measured: FigureSizeRange } | undefined {
742
- const inputs = modelOf(asset).inputs
743
- const declared = {} as FigureSizeRange
744
- const authored = {} as Record<Axis, number>
745
- for (const axis of AXES) {
746
- const input = inputs.find(i => i.id === `size.${axis}` && i.role === 'design')
747
- const at = asset.designInputs[`size.${axis}`]
748
- if (!input || typeof at !== 'number') return undefined
749
- declared[axis] = { min: input.min ?? 0, max: input.max ?? Infinity }
750
- authored[axis] = at
751
- }
752
- const start = defaultInstanceSizeRange(authored)
753
- const measured = foldInstanceSizeRange(start, figureInstanceSizeLimitsOf(asset, start)).range
754
- return { declared, measured }
736
+ * The sizes a board may give this figure, per axis: the ends its author narrowed, and the rest as far as its parts
737
+ * allow, computed by figure-model (`figureInstanceSizeRangeOf`, ADR-0087 decision 6) -- the screen does not measure
738
+ * on its own. Nothing when no size is declared.
739
+ */
740
+ export function instanceRangeOf(asset: FigureAsset): Record<Axis, FigureInstanceSizeRange> | undefined {
741
+ const range = figureInstanceSizeRangeOf(asset)
742
+ return AXES.every(axis => range[axis]) ? (range as Record<Axis, FigureInstanceSizeRange>) : undefined
755
743
  }
756
744
 
757
745
  /**
758
- * The size range to declare for a figure that has none yet: its parts' extent now as the size it is authored at, and
759
- * as wide as its parts allow around it (the same measure 「다시 재기」 uses). Undefined when the figure already has a
760
- * size or has nothing to measure. Without a declared size a board cannot resize the figure, and no part is offered
761
- * to follow its size -- every figure made in the modeller was left there (user, 2026-09-25).
746
+ * The size to declare for a figure that has none yet: its parts' extent now, the size it is authored at. Undefined when
747
+ * the figure already has a size or has nothing to measure. Without a declared size a board cannot resize the figure,
748
+ * and no part is offered to follow its size -- every figure made in the modeller was left there (user, 2026-09-25).
762
749
  */
763
- export function instanceRangeToDeclare(asset: FigureAsset): { range: FigureSizeRange; at: Record<Axis, number> } | undefined {
750
+ export function instanceSizeToDeclare(asset: FigureAsset): { at: Record<Axis, number> } | undefined {
764
751
  if (instanceRangeOf(asset) || !asset.appearance.length) return undefined
765
752
  /* The parts as they rest -- not the room their joints swing them through, which declared a door 8208 mm every way. */
766
753
  const at = figurePartsExtentOf(asset) as Record<Axis, number>
767
754
  if (AXES.some(axis => !(at[axis] > 0))) return undefined
768
- const start = defaultInstanceSizeRange(at)
769
- return { range: foldInstanceSizeRange(start, figureInstanceSizeLimitsOf(asset, start)).range, at }
755
+ return { at }
770
756
  }
771
757
 
772
- /** Whether measuring again would change the declared range (to a micrometre). */
773
758
  /**
774
- * The declared range with one end typed by the author: the sizes a figure may be placed at are a claim about the
775
- * product (a gantry spans metres, not 30 cm to 29 m), and measuring only ever widens them. Nothing when the typed
776
- * value is not a number; whether the figure holds there is the command's to say.
759
+ * The ends the author narrowed, with one end typed or cleared: a number narrows that end, an empty field opens it
760
+ * again to what the parts allow. Whether the figure holds there is the command's to say.
777
761
  */
778
- export function rangeWith(declared: FigureSizeRange, axis: Axis, bound: 'min' | 'max', value: number): FigureSizeRange | undefined {
779
- if (!Number.isFinite(value)) return undefined
780
- const next = structuredClone(declared)
781
- next[axis][bound] = value
782
- return next
762
+ export function rangeWith(
763
+ range: Record<Axis, FigureInstanceSizeRange>,
764
+ axis: Axis,
765
+ bound: 'min' | 'max',
766
+ value: number
767
+ ): Partial<Record<Axis, { min?: number; max?: number }>> {
768
+ const out: Partial<Record<Axis, { min?: number; max?: number }>> = {}
769
+ for (const a of AXES) {
770
+ const ends: { min?: number; max?: number } = {}
771
+ for (const b of ['min', 'max'] as const) {
772
+ const typed = a === axis && b === bound
773
+ if (typed ? Number.isFinite(value) : range[a].narrowed[b]) ends[b] = typed ? value : range[a][b]
774
+ }
775
+ if (Object.keys(ends).length) out[a] = ends
776
+ }
777
+ return out
783
778
  }
784
779
 
785
780
  /**
@@ -807,10 +802,6 @@ export function valueNow(revision: FigureRevision, ref: string | undefined): num
807
802
  }
808
803
  }
809
804
 
810
- export function rangeDiffers(a: FigureSizeRange, b: FigureSizeRange): boolean {
811
- return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
812
- }
813
-
814
805
  /**
815
806
  * Whether the figure is a mechanism -- a part turns about a joint. Such a figure should grow by one ratio (chief
816
807
  * architect's ruling 2026-09-24); the modeller proposes it, the author declares it.
@@ -16,7 +16,7 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
16
16
  case 'rounded-box':
17
17
  return { ...standing(100), kind, dimensions: { width: 100, height: 100, depth: 100, round: 10 } }
18
18
  case 'sphere':
19
- return { ...standing(100), kind, dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 } }
19
+ return { ...standing(100), kind, dimensions: { radius: 50, radiusY: 50 } }
20
20
  case 'frustum':
21
21
  return { ...standing(100), kind, dimensions: { radiusTop: 0, radiusBottom: 50, height: 100 } }
22
22
  case 'polygon':