@things-factory/figure-ui 10.1.48 → 10.1.50

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 (104) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +89 -26
  5. package/client/modeller/figure-v3-parts.ts +86 -10
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/side-panel-styles.ts +8 -0
  8. package/client/modeller/v3-canvas.ts +86 -12
  9. package/client/modeller/v3-editor.ts +244 -37
  10. package/client/modeller/v3-fields.ts +15 -1
  11. package/client/modeller/v3-first-save.ts +34 -0
  12. package/client/modeller/v3-gizmo.ts +52 -3
  13. package/client/modeller/v3-joint-drag.ts +116 -0
  14. package/client/modeller/v3-joint-fan.ts +24 -11
  15. package/client/modeller/v3-keys.ts +12 -1
  16. package/client/modeller/v3-links.ts +72 -5
  17. package/client/modeller/v3-live.ts +11 -0
  18. package/client/modeller/v3-product-session.ts +4 -2
  19. package/client/modeller/v3-refusals.ts +21 -0
  20. package/client/modeller/v3-stage-model.ts +24 -0
  21. package/client/pages/figure-v3-modeller-page.ts +46 -7
  22. package/dist-client/graphql/v3-store.js +2 -1
  23. package/dist-client/graphql/v3-store.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.js +9 -1
  25. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +5 -0
  30. package/dist-client/modeller/figure-v3-links.js +81 -23
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  33. package/dist-client/modeller/figure-v3-parts.js +81 -10
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.js +2 -1
  36. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  37. package/dist-client/modeller/side-panel-styles.js +8 -0
  38. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  39. package/dist-client/modeller/v3-canvas.d.ts +13 -0
  40. package/dist-client/modeller/v3-canvas.js +88 -12
  41. package/dist-client/modeller/v3-canvas.js.map +1 -1
  42. package/dist-client/modeller/v3-clips.d.ts +1 -1
  43. package/dist-client/modeller/v3-editor.d.ts +32 -1
  44. package/dist-client/modeller/v3-editor.js +245 -35
  45. package/dist-client/modeller/v3-editor.js.map +1 -1
  46. package/dist-client/modeller/v3-fields.d.ts +6 -0
  47. package/dist-client/modeller/v3-fields.js +14 -2
  48. package/dist-client/modeller/v3-fields.js.map +1 -1
  49. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  50. package/dist-client/modeller/v3-first-save.js +27 -0
  51. package/dist-client/modeller/v3-first-save.js.map +1 -0
  52. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  53. package/dist-client/modeller/v3-gizmo.js +45 -2
  54. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  55. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  56. package/dist-client/modeller/v3-joint-drag.js +97 -0
  57. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  58. package/dist-client/modeller/v3-joint-fan.js +24 -11
  59. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  60. package/dist-client/modeller/v3-keys.d.ts +9 -1
  61. package/dist-client/modeller/v3-keys.js +3 -0
  62. package/dist-client/modeller/v3-keys.js.map +1 -1
  63. package/dist-client/modeller/v3-links.d.ts +45 -2
  64. package/dist-client/modeller/v3-links.js +52 -3
  65. package/dist-client/modeller/v3-links.js.map +1 -1
  66. package/dist-client/modeller/v3-live.d.ts +6 -0
  67. package/dist-client/modeller/v3-live.js +10 -0
  68. package/dist-client/modeller/v3-live.js.map +1 -1
  69. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  70. package/dist-client/modeller/v3-product-session.js +2 -2
  71. package/dist-client/modeller/v3-product-session.js.map +1 -1
  72. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  73. package/dist-client/modeller/v3-refusals.js +21 -0
  74. package/dist-client/modeller/v3-refusals.js.map +1 -0
  75. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  76. package/dist-client/modeller/v3-stage-model.js +26 -0
  77. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  78. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  79. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  80. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +7 -5
  83. package/test/browser/build.mjs +62 -0
  84. package/test/browser/modeller-page.js +198 -0
  85. package/test/browser/modeller.check.mjs +334 -0
  86. package/test/browser/serve.mjs +43 -0
  87. package/test/joints.ts +33 -0
  88. package/test/v3-clips.test.ts +3 -2
  89. package/test/v3-controls.test.ts +3 -2
  90. package/test/v3-fields.test.ts +38 -1
  91. package/test/v3-first-save.test.ts +75 -0
  92. package/test/v3-gizmo.test.ts +40 -5
  93. package/test/v3-joint-drag.test.ts +93 -0
  94. package/test/v3-joint-fan.test.ts +16 -6
  95. package/test/v3-keys.test.ts +15 -2
  96. package/test/v3-links.test.ts +62 -14
  97. package/test/v3-live.test.ts +23 -4
  98. package/test/v3-maturity.test.ts +3 -7
  99. package/test/v3-refusals.test.ts +28 -0
  100. package/translations/en.json +43 -5
  101. package/translations/ja.json +42 -4
  102. package/translations/ko.json +42 -4
  103. package/translations/ms.json +42 -4
  104. package/translations/zh.json +42 -4
@@ -8,6 +8,7 @@ import type { V3RenderPart } from './v3-renderer.js'
8
8
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
9
9
  import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
10
10
  import { JOINT_FAN } from './v3-joint-fan.js'
11
+ import { ownerOf } from './v3-fields.js'
11
12
 
12
13
  /*
13
14
  * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
@@ -36,6 +37,7 @@ interface SceneLayer extends SceneComponent {
36
37
  removeComponent?: (component: SceneComponent) => void
37
38
  invalidate?: () => void
38
39
  fitCameraToModel?: () => void
40
+ setGizmoMode?: (mode: 'translate' | 'rotate' | 'scale') => void
39
41
  app?: unknown
40
42
  }
41
43
  interface SceneHandle {
@@ -152,6 +154,11 @@ export class FigureV3Canvas extends LitElement {
152
154
  this.scene?.root?.fitCameraToModel?.()
153
155
  }
154
156
 
157
+ /** The gizmo in a mode on the selected part, as W, E and R do with the canvas focused -- the scene's toggle. */
158
+ gizmo(mode: 'translate' | 'rotate' | 'scale') {
159
+ this.scene?.root?.setGizmoMode?.(mode)
160
+ }
161
+
155
162
  /**
156
163
  * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
157
164
  * one that has outgrown it -- standing up again loses the selection and the camera.
@@ -160,7 +167,12 @@ export class FigureV3Canvas extends LitElement {
160
167
  if (!this.stage) return
161
168
  const needed = boardFor(this.parts, this.board)
162
169
  const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
163
- if (this.scene && this.standingFor === this.context && !outgrown) {
170
+ if (this.scene && this.standingFor === this.context) {
171
+ /*
172
+ A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,
173
+ the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).
174
+ */
175
+ if (outgrown) this.grow(needed)
164
176
  this.sync()
165
177
  return
166
178
  }
@@ -214,6 +226,19 @@ export class FigureV3Canvas extends LitElement {
214
226
  return found
215
227
  }
216
228
 
229
+ /** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */
230
+ private grow(needed: Board & { depth: number }) {
231
+ const layer = this.scene?.root
232
+ this.board = needed
233
+ if (!layer) return
234
+ this.writing = true
235
+ try {
236
+ layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) })
237
+ } finally {
238
+ this.writing = false
239
+ }
240
+ }
241
+
217
242
  /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
218
243
  private sync() {
219
244
  const layer = this.scene?.root
@@ -251,34 +276,78 @@ export class FigureV3Canvas extends LitElement {
251
276
  root.on('selected', (after: unknown) => {
252
277
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
253
278
  const id = typeof name === 'string' ? name : ''
254
- if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
279
+ if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
255
280
  })
256
281
  /*
257
282
  A change comes up to the board with the component in the last argument's `origin` (the scene appends
258
283
  { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
259
284
  */
285
+ /*
286
+ A drag is written once, when it ends. The scene's gizmo writes the component on every move -- a size anchored on
287
+ the opposite face, from the scale it captured when the drag began -- and writing each move back through the
288
+ editor (and the evaluation back onto the component) fought it: the scale gizmo did not work (user, 2026-09-24).
289
+ While a pointer is down the changes are only kept; on release the last one is told, and the board is brought to
290
+ the evaluation. A change with no pointer down (the arrow keys) is told after a short pause.
291
+ */
260
292
  layer.on?.('change', (...args: unknown[]) => {
261
293
  if (this.writing) return
262
294
  const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
263
295
  const name = origin?.state?.name
264
- const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined
265
- if (!part || !this.board) return
266
- const result = gizmoResultOf(part, origin!.state, this.board)
267
- if (!result.position && !result.rotation && !result.scale) return
268
- this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
269
- /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
270
- setTimeout(() => this.sync())
296
+ if (typeof name !== 'string' || !this.parts.some(p => p.id === name)) return
297
+ this.pending.set(name, origin!)
298
+ if (!this.pointerDown) this.commitSoon(80)
271
299
  })
300
+ this.stage.addEventListener('pointerdown', this.onPointerDown, true)
301
+ window.addEventListener('pointerup', this.onPointerUp, true)
302
+ window.addEventListener('pointercancel', this.onPointerUp, true)
303
+ }
304
+
305
+ /** Components the gizmo changed since the last commit, by part name. */
306
+ private pending = new Map<string, SceneComponent>()
307
+ private pointerDown = false
308
+ private commitTimer?: ReturnType<typeof setTimeout>
309
+ private onPointerDown = () => {
310
+ this.pointerDown = true
311
+ }
312
+ private onPointerUp = () => {
313
+ if (!this.pointerDown) return
314
+ this.pointerDown = false
315
+ /* After the scene's own drag end (it settles the depth and rebuilds the geometry on release). */
316
+ this.commitSoon(0)
317
+ }
318
+ private commitSoon(delay: number) {
319
+ clearTimeout(this.commitTimer)
320
+ this.commitTimer = setTimeout(() => this.commit(), delay)
321
+ }
322
+ /** Tell each changed part once, in the inputs' terms; then put the board where the inputs say (a refusal springs back). */
323
+ private commit() {
324
+ /*
325
+ Not while a pointer is down: a pause set by a change just before the press (the arrow keys, the scene settling
326
+ the pick) fired in the middle of the drag and wrote where the part was then -- a drag on a converted part became
327
+ two steps, the first one a few millimetres in (browser check, 2026-09-25). What is pending waits for the release.
328
+ */
329
+ if (this.pointerDown || !this.board || !this.pending.size) return
330
+ const changed = [...this.pending]
331
+ this.pending.clear()
332
+ for (const [name, component] of changed) {
333
+ const part = this.parts.find(p => p.id === name)
334
+ if (!part) continue
335
+ const result = gizmoResultOf(part, component.state, this.board)
336
+ if (!result.position && !result.rotation && !result.scale) continue
337
+ this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
338
+ }
339
+ setTimeout(() => this.sync())
272
340
  }
273
341
 
274
342
  private showSelected() {
275
343
  const layer = this.scene?.root
276
344
  const root = layer?.root
277
345
  if (!layer || !root) return
278
- const component = this.selected ? this.standing().get(this.selected) : undefined
346
+ /* A repeat is selected whole: every copy of it (the editor selects the part the author made). */
347
+ const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : []
279
348
  const now = root.selected ?? []
280
- if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return
281
- root.selected = component ? [component] : []
349
+ if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return
350
+ root.selected = wanted
282
351
  layer.invalidate?.()
283
352
  }
284
353
 
@@ -291,6 +360,11 @@ export class FigureV3Canvas extends LitElement {
291
360
  }
292
361
 
293
362
  private teardown() {
363
+ clearTimeout(this.commitTimer)
364
+ this.pending.clear()
365
+ this.stage?.removeEventListener('pointerdown', this.onPointerDown, true)
366
+ window.removeEventListener('pointerup', this.onPointerUp, true)
367
+ window.removeEventListener('pointercancel', this.onPointerUp, true)
294
368
  this.scene?.dispose?.()
295
369
  this.scene = undefined
296
370
  this.standingFor = undefined
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
3
3
  import type { OxThreePane } from '@operato/layout'
4
4
  import '@operato/layout'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
+ import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
6
7
  import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
7
8
  import type { V3ProductSession } from './v3-product-session.js'
8
9
  import { captureV3Thumbnail } from './v3-thumbnail.js'
@@ -19,16 +20,22 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
19
20
  import './figure-v3-capability.js'
20
21
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
21
22
  import { keyActionOf } from './v3-keys.js'
22
- import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
23
+ import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
23
24
  import { SidePanelStyles } from './side-panel-styles.js'
25
+ import { ScrollbarStyles } from '@operato/styles'
24
26
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
25
27
  import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
26
28
  import { paletteRecipe } from './v3-palette.js'
27
29
  import type { FigureV3Canvas } from './v3-canvas.js'
30
+ import { nudgeOf } from './v3-stage-model.js'
31
+ import { sizeRangeRefusalOf } from './v3-refusals.js'
32
+ import type { V3RenderPart } from './v3-renderer.js'
28
33
  import type { V3BudgetLine } from './v3-maturity.js'
29
34
  import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
30
35
  import { jointFanOf } from './v3-joint-fan.js'
31
- import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
36
+ import { jointDragOf } from './v3-joint-drag.js'
37
+ import type { FirstSave } from './v3-first-save.js'
38
+ import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
32
39
 
33
40
 
34
41
  /**
@@ -41,7 +48,8 @@ type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'hi
41
48
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
42
49
  @customElement('figure-v3-editor')
43
50
  export class FigureV3Editor extends LitElement {
44
- static styles = [SidePanelStyles, css`
51
+ /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
52
+ static styles = [ScrollbarStyles, SidePanelStyles, css`
45
53
  :host {
46
54
  display: flex;
47
55
  flex-direction: column;
@@ -223,6 +231,11 @@ export class FigureV3Editor extends LitElement {
223
231
  @property({ type: Number }) instances?: number
224
232
  /** The cost line the parts list shows, from the maturity report. */
225
233
  @property({ attribute: false }) budget?: V3BudgetLine
234
+ /**
235
+ * Asks the type and name of a figure saved for the first time (the host has the catalogue's names); undefined
236
+ * when the author cancels, and then nothing is saved. Without it a new figure is saved under a temporary type.
237
+ */
238
+ @property({ attribute: false }) firstSave?: (name: string) => Promise<FirstSave | undefined>
226
239
  @state() private selected = ''
227
240
  @state() private error = ''
228
241
  @state() private tab: SideTab = 'properties'
@@ -259,14 +272,26 @@ export class FigureV3Editor extends LitElement {
259
272
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
260
273
  @state() private live: LiveValues = NOTHING_LIVE
261
274
  private lastContext: string | null = null
262
- /** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure. */
275
+ /** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
263
276
  private onKey = (e: KeyboardEvent) => {
264
277
  const view = this.session?.read()
265
278
  if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
266
- const action = keyActionOf({ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: e.composedPath()[0] as HTMLElement }, this.selected)
279
+ const canvas = this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')
280
+ const path = e.composedPath()
281
+ const action = keyActionOf(
282
+ { key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0] as HTMLElement, inScene: !!canvas && path.includes(canvas) },
283
+ this.selected
284
+ )
267
285
  if (!action) return
268
286
  e.preventDefault()
269
- if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
287
+ /*
288
+ The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
289
+ board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
290
+ converted part wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
291
+ */
292
+ e.stopPropagation()
293
+ if (action === 'translate' || action === 'rotate' || action === 'scale') canvas?.gizmo(action)
294
+ else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
270
295
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
271
296
  else if (action === 'duplicate') this.duplicatePart(this.selected)
272
297
  else if (action === 'remove') this.removePart(this.selected)
@@ -274,10 +299,10 @@ export class FigureV3Editor extends LitElement {
274
299
  }
275
300
  connectedCallback() {
276
301
  super.connectedCallback()
277
- window.addEventListener('keydown', this.onKey)
302
+ window.addEventListener('keydown', this.onKey, true)
278
303
  }
279
304
  disconnectedCallback() {
280
- window.removeEventListener('keydown', this.onKey)
305
+ window.removeEventListener('keydown', this.onKey, true)
281
306
  super.disconnectedCallback()
282
307
  }
283
308
  protected willUpdate(_changed: PropertyValues) {
@@ -288,7 +313,7 @@ export class FigureV3Editor extends LitElement {
288
313
  this.error = ''
289
314
  this.hiddenParts = new Set()
290
315
  }
291
- if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
316
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
292
317
  }
293
318
  private run(action: () => unknown) {
294
319
  try {
@@ -296,7 +321,7 @@ export class FigureV3Editor extends LitElement {
296
321
  this.error = ''
297
322
  this.changed()
298
323
  } catch (error) {
299
- this.error = error instanceof Error ? error.message : String(error)
324
+ this.error = sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
300
325
  } finally {
301
326
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
302
327
  this.live = NOTHING_LIVE
@@ -304,8 +329,8 @@ export class FigureV3Editor extends LitElement {
304
329
  }
305
330
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
306
331
  private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
307
- const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
308
- return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
332
+ const parts = (asset ? liveOverSession(asset as V3Asset, this.currentInputs(), this.live) : null) ?? drawableV3Parts(geometry ?? [])
333
+ return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
309
334
  }
310
335
  /**
311
336
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -314,11 +339,20 @@ export class FigureV3Editor extends LitElement {
314
339
  private fanOf(asset: V3Asset | undefined) {
315
340
  if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
316
341
  try {
317
- return jointFanOf(asset, this.selected, this.live.values)
342
+ return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })
318
343
  } catch {
319
344
  return null
320
345
  }
321
346
  }
347
+ /**
348
+ * Every input as the session holds it now -- the pose the author has set with the controls, and design values. The
349
+ * asset's source carries only the saved defaults, so drawing a value in hand over it put every other joint back to its
350
+ * saved angle while one slider moved, and the arm jumped when released (user, 2026-09-24).
351
+ */
352
+ private currentInputs(): Record<string, number> {
353
+ const view = this.session?.read()
354
+ return view ? Object.fromEntries(view.fields.map(field => [field.id, field.value as number])) : {}
355
+ }
322
356
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
323
357
  private hold(values: Record<string, number>, edit?: V3PartEdit) {
324
358
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
@@ -335,9 +369,20 @@ export class FigureV3Editor extends LitElement {
335
369
  not from the one on screen, which stands wherever the author left it turned. A picture that cannot
336
370
  be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
337
371
  */
372
+ let type: string | undefined
373
+ if (!this.session.read().id && this.firstSave) {
374
+ const answer = await this.firstSave(this.session.read().name ?? '')
375
+ if (!answer) return
376
+ /* The name is the figure's own, in the asset: one step of the history, like typing it in the properties. */
377
+ if (answer.name !== this.session.read().name) {
378
+ this.session.command({ kind: 'rename', name: answer.name })
379
+ this.changed()
380
+ }
381
+ type = answer.type
382
+ }
338
383
  const view = this.session.read()
339
384
  const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
340
- const promise = this.session.save({ thumbnail })
385
+ const promise = this.session.save({ thumbnail, type })
341
386
  this.changed()
342
387
  await promise
343
388
  this.error = ''
@@ -397,7 +442,8 @@ export class FigureV3Editor extends LitElement {
397
442
  /** What the gizmo may write for the selected part — read off the asset being edited. */
398
443
  private reachOf(id: string): GizmoReach {
399
444
  const none = { translate: false, rotate: false, scale: false }
400
- if (!id || !this.session) return none
445
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
446
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
401
447
  try {
402
448
  return gizmoReachOf(JSON.parse(this.session.source()), id)
403
449
  } catch {
@@ -407,10 +453,68 @@ export class FigureV3Editor extends LitElement {
407
453
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
408
454
  private transformed(id: string, result: GizmoResult) {
409
455
  const view = this.session!.read()
456
+ /* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
457
+ const asset = JSON.parse(this.session!.source()) as V3Asset
458
+ const drawn = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
459
+ const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
460
+ if (drag) {
461
+ if (drag.kind === 'value') this.run(() => this.session!.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]))
462
+ else if (drag.kind === 'joint') this.run(() => this.session!.command({ kind: 'set-joint', joint: drag.joint }))
463
+ else {
464
+ this.error = i18next.t(`figure.text.gizmo-joint-${drag.reason}`, { joint: drag.joint, leader: drag.leader ?? '', axis: drag.axis ?? '' })
465
+ this.requestUpdate()
466
+ }
467
+ return
468
+ }
410
469
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
411
- const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
412
- if (changes.length) this.run(() => this.session!.apply(changes))
413
- else this.requestUpdate()
470
+ /*
471
+ A part a joint places, scaled: only its measures change. The shift of its centre that an anchored scale makes is
472
+ the joint's to give -- the face on the joint stays there by itself (user, 2026-09-25: 「'R'모드에서 크기가 변하지 않는다」;
473
+ the shift had been taken for a move of the joint, and the size was dropped).
474
+ */
475
+ const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
476
+ const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes)
477
+ if (changes.length) {
478
+ this.run(() => this.session!.apply(changes))
479
+ return
480
+ }
481
+ if (jointed && result.scale) {
482
+ /* Only measures that follow another were pulled: the relation says their size, and it is not broken here. */
483
+ const axes = (['x', 'y', 'z'] as const).filter((_, i) => Math.abs(result.scale![i]! - 1) > 1e-4)
484
+ this.error = i18next.t('figure.text.gizmo-size-follows', { part: partLabelOf(id), axis: axes.join(', ').toUpperCase() })
485
+ return this.requestUpdate()
486
+ }
487
+ /*
488
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
489
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
490
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
491
+ */
492
+ const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
493
+ if (!body) return this.requestUpdate()
494
+ const { by, turn } = nudgeOf(body, result)
495
+ /* A size worked out from the figure's: the drag's factor beside its rule, so it still follows (V2 scaled any part). */
496
+ const grow: Partial<Record<'x' | 'y' | 'z', number>> = {}
497
+ if (result.scale) (['x', 'y', 'z'] as const).forEach((axis, i) => Math.abs(result.scale![i]! - 1) > 1e-6 && (grow[axis] = Math.round(result.scale![i]! * 1e6) / 1e6))
498
+ if (!Object.keys(by).length && !Object.keys(turn).length && !Object.keys(grow).length) return this.requestUpdate()
499
+ try {
500
+ this.session!.command({
501
+ kind: 'nudge-part',
502
+ part: id,
503
+ ...(Object.keys(by).length ? { by } : {}),
504
+ ...(Object.keys(turn).length ? { turn } : {}),
505
+ ...(Object.keys(grow).length ? { grow } : {})
506
+ })
507
+ this.error = ''
508
+ this.changed()
509
+ } catch (error) {
510
+ const message = error instanceof Error ? error.message : String(error)
511
+ this.error = /carried by a joint/.test(message)
512
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
513
+ : /is fastened to/.test(message)
514
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
515
+ : message
516
+ this.requestUpdate()
517
+ }
414
518
  }
415
519
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
416
520
  private align(tool: AlignTool) {
@@ -473,7 +577,7 @@ export class FigureV3Editor extends LitElement {
473
577
  */
474
578
  private renderRepeatNote(geometry: readonly unknown[] | undefined) {
475
579
  const repeat = repeatOf(this.selected, geometry)
476
- return repeat ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
580
+ return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
477
581
  }
478
582
  private renamePart(input: HTMLInputElement) {
479
583
  const name = input.value.trim()
@@ -561,9 +665,7 @@ export class FigureV3Editor extends LitElement {
561
665
  ? i18next.t('figure.text.follow-would-loop')
562
666
  : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
563
667
  ? i18next.t('figure.text.fasten-would-loop')
564
- : error instanceof Error
565
- ? error.message
566
- : String(error)
668
+ : sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
567
669
  }
568
670
  }
569
671
  private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
@@ -672,9 +774,117 @@ export class FigureV3Editor extends LitElement {
672
774
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
673
775
  </button>`
674
776
  }
777
+ /**
778
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
779
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
780
+ */
781
+ private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
782
+ const rows = new Map<string, V3PartRow>()
783
+ for (const g of geometry) {
784
+ const id = ownerOf(g.id)
785
+ const row = rows.get(id)
786
+ if (row) {
787
+ row.copies = (row.copies ?? 1) + 1
788
+ continue
789
+ }
790
+ rows.set(id, {
791
+ id,
792
+ provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
793
+ hidden: this.hiddenParts.has(id),
794
+ invisible: (g as { visible?: boolean }).visible === false,
795
+ ...(id !== g.id ? { copies: 1 } : {})
796
+ })
797
+ }
798
+ return [...rows.values()]
799
+ }
800
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
801
+ private partBudgetOf(asset: V3Asset | undefined) {
802
+ if (!asset) return undefined
803
+ try {
804
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
805
+ } catch {
806
+ return undefined
807
+ }
808
+ }
809
+ /**
810
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
811
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
812
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
813
+ */
814
+ private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
815
+ const repeat = repeatOf(this.selected, geometry)
816
+ if (!asset || !repeat || repeat.index) return nothing
817
+ const model = asset.document.model as unknown as {
818
+ nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
819
+ constants: { id: string; value: number }[]
820
+ inputs: { id: string; max?: number }[]
821
+ }
822
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
823
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
824
+ const own = `${this.selected}.repeat.pitch`
825
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
826
+ const fits = layout?.op === 'fit-pitch@1'
827
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
828
+ const lengthRef = layout?.args[0] ?? ''
829
+ const length = asset.designInputs[lengthRef]
830
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max
831
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value
832
+ const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
833
+ return html`<section>
834
+ <h3>${i18next.t('figure.label.repeat')}</h3>
835
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
836
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
837
+ <div inline-field>
838
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
839
+ <input
840
+ type="number"
841
+ step="any"
842
+ aria-label=${own}
843
+ .value=${String(pitch ?? '')}
844
+ ?disabled=${unavailable || !fits}
845
+ @change=${(e: Event) => this.setPitch(field, e)}
846
+ />
847
+ </div>
848
+ <p hint>
849
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
850
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
851
+ length: length.toLocaleString(),
852
+ count: repeat.count,
853
+ longest: longest.toLocaleString(),
854
+ limit
855
+ })
856
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
857
+ </p>
858
+ </section>`
859
+ }
860
+ /**
861
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
862
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
863
+ * that -- said with the numbers.
864
+ */
865
+ private setPitch(field: { id: string; value: number }, event: Event) {
866
+ const input = event.target as HTMLInputElement
867
+ const pitch = input.valueAsNumber
868
+ if (!Number.isFinite(pitch) || pitch === field.value) return
869
+ try {
870
+ this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
871
+ this.error = ''
872
+ this.changed()
873
+ } catch (error) {
874
+ input.value = String(field.value)
875
+ const code = (error as { code?: string })?.code
876
+ const message = error instanceof Error ? error.message : String(error)
877
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
878
+ this.error =
879
+ code === 'INPUT_RANGE' && found
880
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
881
+ : message
882
+ }
883
+ }
675
884
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
676
885
  private choose(id: string) {
677
- this.selected = id
886
+ /* A copy on the canvas selects its repeat: the part the author made. */
887
+ this.selected = id ? ownerOf(id) : id
678
888
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
679
889
  }
680
890
  private asset(): unknown {
@@ -707,11 +917,12 @@ export class FigureV3Editor extends LitElement {
707
917
  const view = this.session?.read()
708
918
  if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
709
919
  const unavailable = view.loading || !!view.error || view.saving || !view.context
710
- const part = view.geometry.find(g => g.id === this.selected)
920
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
711
921
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
712
922
  const design = view.fields.filter(i => i.role !== 'state')
713
- const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
714
- const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
923
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
924
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
925
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
715
926
  const status = view.loading
716
927
  ? 'figure.text.loading-figure'
717
928
  : view.saving
@@ -747,21 +958,16 @@ export class FigureV3Editor extends LitElement {
747
958
  slot="start"
748
959
  .budget=${this.budget}
749
960
  @show-maturity=${() => (this.tab = 'verdict')}
750
- .parts=${view.geometry.map(
751
- (g): V3PartRow => ({
752
- id: g.id,
753
- provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
754
- hidden: this.hiddenParts.has(g.id),
755
- invisible: (g as { visible?: boolean }).visible === false
756
- })
757
- )}
961
+ .parts=${this.partRows(view.geometry)}
962
+ .partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
963
+ @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
758
964
  .selected=${this.selected}
759
965
  ?locked=${unavailable}
760
966
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
761
967
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
762
968
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
763
969
  @set-all-parts-hidden=${(e: CustomEvent) =>
764
- (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
970
+ (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
765
971
  @move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
766
972
  @move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
767
973
  @duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
@@ -815,10 +1021,11 @@ export class FigureV3Editor extends LitElement {
815
1021
  </div>
816
1022
  ${this.renderRepeatNote(view.geometry)}
817
1023
  </section>
818
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
1024
+ ${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
1025
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
819
1026
  ${this.renderLinks(unavailable, 'shape')}
820
1027
  <figure-v3-material
821
- .body=${part}
1028
+ .body=${{ ...part, id: this.selected }}
822
1029
  .palette=${view.palette ?? {}}
823
1030
  ?locked=${unavailable}
824
1031
  @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
@@ -66,7 +66,11 @@ export function partLabelOf(id: string): string {
66
66
  */
67
67
  export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
68
68
  const path = pathOf(id)
69
- if (!path) return null
69
+ if (!path) {
70
+ /* The repeat itself, by its name: how many copies it makes now. */
71
+ const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
72
+ return copies ? { name: id, count: copies, index: 0 } : null
73
+ }
70
74
  const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
71
75
  const index = path.find((step): step is number => typeof step === 'number')
72
76
  return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
@@ -82,3 +86,13 @@ function pathOf(id: string): unknown[] | null {
82
86
  }
83
87
  }
84
88
 
89
+ /**
90
+ * The authored part a body belongs to: a repeat's copy belongs to its repeat (`roller`), any other body is its own. The
91
+ * editor selects, lists, hides and names parts by this -- what the author made, not every body the kernel draws
92
+ * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
93
+ */
94
+ export function ownerOf(id: string): string {
95
+ const path = pathOf(id)
96
+ return path ? String(path[0]).replace(/\.repeat$/, '') : id
97
+ }
98
+