@things-factory/figure-ui 10.1.49 → 10.1.51

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 (97) 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 +177 -31
  5. package/client/modeller/figure-v3-parts.ts +1 -0
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/v3-canvas.ts +61 -7
  8. package/client/modeller/v3-editor.ts +96 -21
  9. package/client/modeller/v3-first-save.ts +34 -0
  10. package/client/modeller/v3-gizmo.ts +52 -3
  11. package/client/modeller/v3-joint-drag.ts +116 -0
  12. package/client/modeller/v3-joint-fan.ts +24 -11
  13. package/client/modeller/v3-keys.ts +12 -1
  14. package/client/modeller/v3-links.ts +119 -8
  15. package/client/modeller/v3-live.ts +11 -0
  16. package/client/modeller/v3-product-session.ts +4 -2
  17. package/client/modeller/v3-refusals.ts +21 -0
  18. package/client/pages/figure-v3-modeller-page.ts +57 -8
  19. package/dist-client/graphql/v3-store.js +2 -1
  20. package/dist-client/graphql/v3-store.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.js +9 -1
  22. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  24. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/figure-v3-controls.d.ts +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 +14 -1
  30. package/dist-client/modeller/figure-v3-links.js +169 -28
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  33. package/dist-client/modeller/figure-v3-parts.js +1 -0
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  36. package/dist-client/modeller/figure-v3-properties.js +2 -1
  37. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  38. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  39. package/dist-client/modeller/v3-canvas.js +61 -7
  40. package/dist-client/modeller/v3-canvas.js.map +1 -1
  41. package/dist-client/modeller/v3-clips.d.ts +1 -1
  42. package/dist-client/modeller/v3-editor.d.ts +13 -1
  43. package/dist-client/modeller/v3-editor.js +96 -21
  44. package/dist-client/modeller/v3-editor.js.map +1 -1
  45. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  46. package/dist-client/modeller/v3-first-save.js +27 -0
  47. package/dist-client/modeller/v3-first-save.js.map +1 -0
  48. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  49. package/dist-client/modeller/v3-gizmo.js +45 -2
  50. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  51. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  52. package/dist-client/modeller/v3-joint-drag.js +97 -0
  53. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  54. package/dist-client/modeller/v3-joint-fan.js +24 -11
  55. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  56. package/dist-client/modeller/v3-keys.d.ts +9 -1
  57. package/dist-client/modeller/v3-keys.js +3 -0
  58. package/dist-client/modeller/v3-keys.js.map +1 -1
  59. package/dist-client/modeller/v3-links.d.ts +72 -3
  60. package/dist-client/modeller/v3-links.js +89 -6
  61. package/dist-client/modeller/v3-links.js.map +1 -1
  62. package/dist-client/modeller/v3-live.d.ts +6 -0
  63. package/dist-client/modeller/v3-live.js +10 -0
  64. package/dist-client/modeller/v3-live.js.map +1 -1
  65. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  66. package/dist-client/modeller/v3-product-session.js +2 -2
  67. package/dist-client/modeller/v3-product-session.js.map +1 -1
  68. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  69. package/dist-client/modeller/v3-refusals.js +21 -0
  70. package/dist-client/modeller/v3-refusals.js.map +1 -0
  71. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  72. package/dist-client/pages/figure-v3-modeller-page.js +56 -8
  73. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +7 -5
  76. package/test/browser/build.mjs +62 -0
  77. package/test/browser/modeller-page.js +198 -0
  78. package/test/browser/modeller.check.mjs +358 -0
  79. package/test/browser/serve.mjs +43 -0
  80. package/test/joints.ts +33 -0
  81. package/test/v3-clips.test.ts +3 -2
  82. package/test/v3-controls.test.ts +3 -2
  83. package/test/v3-fields.test.ts +3 -2
  84. package/test/v3-first-save.test.ts +84 -0
  85. package/test/v3-gizmo.test.ts +38 -5
  86. package/test/v3-joint-drag.test.ts +93 -0
  87. package/test/v3-joint-fan.test.ts +16 -6
  88. package/test/v3-keys.test.ts +15 -2
  89. package/test/v3-links.test.ts +114 -14
  90. package/test/v3-live.test.ts +23 -4
  91. package/test/v3-maturity.test.ts +2 -6
  92. package/test/v3-refusals.test.ts +28 -0
  93. package/translations/en.json +40 -5
  94. package/translations/ja.json +39 -4
  95. package/translations/ko.json +39 -4
  96. package/translations/ms.json +39 -4
  97. package/translations/zh.json +39 -4
@@ -22,16 +22,20 @@ import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from '.
22
22
  import { keyActionOf } from './v3-keys.js'
23
23
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
24
24
  import { SidePanelStyles } from './side-panel-styles.js'
25
+ import { ScrollbarStyles } from '@operato/styles'
25
26
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
26
27
  import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
27
28
  import { paletteRecipe } from './v3-palette.js'
28
29
  import type { FigureV3Canvas } from './v3-canvas.js'
29
30
  import { nudgeOf } from './v3-stage-model.js'
31
+ import { sizeRangeRefusalOf } from './v3-refusals.js'
30
32
  import type { V3RenderPart } from './v3-renderer.js'
31
33
  import type { V3BudgetLine } from './v3-maturity.js'
32
34
  import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
33
35
  import { jointFanOf } from './v3-joint-fan.js'
34
- 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'
35
39
 
36
40
 
37
41
  /**
@@ -44,7 +48,8 @@ type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'hi
44
48
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
45
49
  @customElement('figure-v3-editor')
46
50
  export class FigureV3Editor extends LitElement {
47
- 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`
48
53
  :host {
49
54
  display: flex;
50
55
  flex-direction: column;
@@ -226,6 +231,11 @@ export class FigureV3Editor extends LitElement {
226
231
  @property({ type: Number }) instances?: number
227
232
  /** The cost line the parts list shows, from the maturity report. */
228
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>
229
239
  @state() private selected = ''
230
240
  @state() private error = ''
231
241
  @state() private tab: SideTab = 'properties'
@@ -262,14 +272,26 @@ export class FigureV3Editor extends LitElement {
262
272
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
263
273
  @state() private live: LiveValues = NOTHING_LIVE
264
274
  private lastContext: string | null = null
265
- /** 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. */
266
276
  private onKey = (e: KeyboardEvent) => {
267
277
  const view = this.session?.read()
268
278
  if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
269
- 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
+ )
270
285
  if (!action) return
271
286
  e.preventDefault()
272
- 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())
273
295
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
274
296
  else if (action === 'duplicate') this.duplicatePart(this.selected)
275
297
  else if (action === 'remove') this.removePart(this.selected)
@@ -277,10 +299,10 @@ export class FigureV3Editor extends LitElement {
277
299
  }
278
300
  connectedCallback() {
279
301
  super.connectedCallback()
280
- window.addEventListener('keydown', this.onKey)
302
+ window.addEventListener('keydown', this.onKey, true)
281
303
  }
282
304
  disconnectedCallback() {
283
- window.removeEventListener('keydown', this.onKey)
305
+ window.removeEventListener('keydown', this.onKey, true)
284
306
  super.disconnectedCallback()
285
307
  }
286
308
  protected willUpdate(_changed: PropertyValues) {
@@ -299,7 +321,7 @@ export class FigureV3Editor extends LitElement {
299
321
  this.error = ''
300
322
  this.changed()
301
323
  } catch (error) {
302
- 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))
303
325
  } finally {
304
326
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
305
327
  this.live = NOTHING_LIVE
@@ -307,7 +329,7 @@ export class FigureV3Editor extends LitElement {
307
329
  }
308
330
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
309
331
  private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
310
- const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
332
+ const parts = (asset ? liveOverSession(asset as V3Asset, this.currentInputs(), this.live) : null) ?? drawableV3Parts(geometry ?? [])
311
333
  return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
312
334
  }
313
335
  /**
@@ -317,11 +339,20 @@ export class FigureV3Editor extends LitElement {
317
339
  private fanOf(asset: V3Asset | undefined) {
318
340
  if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
319
341
  try {
320
- return jointFanOf(asset, this.selected, this.live.values)
342
+ return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })
321
343
  } catch {
322
344
  return null
323
345
  }
324
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
+ }
325
356
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
326
357
  private hold(values: Record<string, number>, edit?: V3PartEdit) {
327
358
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
@@ -338,9 +369,20 @@ export class FigureV3Editor extends LitElement {
338
369
  not from the one on screen, which stands wherever the author left it turned. A picture that cannot
339
370
  be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
340
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
+ }
341
383
  const view = this.session.read()
342
384
  const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
343
- const promise = this.session.save({ thumbnail })
385
+ const promise = this.session.save({ thumbnail, type })
344
386
  this.changed()
345
387
  await promise
346
388
  this.error = ''
@@ -411,12 +453,37 @@ export class FigureV3Editor extends LitElement {
411
453
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
412
454
  private transformed(id: string, result: GizmoResult) {
413
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
+ }
414
469
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
415
- const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
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)
416
477
  if (changes.length) {
417
478
  this.run(() => this.session!.apply(changes))
418
479
  return
419
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
+ }
420
487
  /*
421
488
  A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
422
489
  its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
@@ -424,14 +491,19 @@ export class FigureV3Editor extends LitElement {
424
491
  */
425
492
  const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
426
493
  if (!body) return this.requestUpdate()
427
- if (result.scale && !result.position && !result.rotation) {
428
- this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) })
429
- return this.requestUpdate()
430
- }
431
494
  const { by, turn } = nudgeOf(body, result)
432
- if (!Object.keys(by).length && !Object.keys(turn).length) return this.requestUpdate()
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()
433
499
  try {
434
- this.session!.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) })
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
+ })
435
507
  this.error = ''
436
508
  this.changed()
437
509
  } catch (error) {
@@ -593,9 +665,7 @@ export class FigureV3Editor extends LitElement {
593
665
  ? i18next.t('figure.text.follow-would-loop')
594
666
  : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
595
667
  ? i18next.t('figure.text.fasten-would-loop')
596
- : error instanceof Error
597
- ? error.message
598
- : String(error)
668
+ : sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
599
669
  }
600
670
  }
601
671
  private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
@@ -785,6 +855,11 @@ export class FigureV3Editor extends LitElement {
785
855
  })
786
856
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
787
857
  </p>
858
+ ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
859
+ ? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
860
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
861
+ </button>`
862
+ : nothing}
788
863
  </section>`
789
864
  }
790
865
  /**
@@ -0,0 +1,34 @@
1
+ import { isPlaceholderType, TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
2
+
3
+ /*
4
+ * The first save of a new figure asks for its type and name, as V2's page did. The type is the name a board places
5
+ * the figure by; it freezes when the figure is released (ADR-0063 decision 4). The V3 modeller saved a new figure
6
+ * under a temporary machine name instead and asked nothing -- the author had to find the field afterwards
7
+ * (user, 2026-09-25: V2 features are not to go missing).
8
+ */
9
+
10
+ /** A type as typed, made into the shape a type has: letters, digits and underscores, upper case. */
11
+ export function typeOf(raw: string): string {
12
+ return raw.trim().replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
13
+ }
14
+
15
+ /**
16
+ * Whether this name could be released, by the format's own rules (`type-not-identifier`, `type-is-placeholder`).
17
+ * Saving under it is not blocked -- it can still change before release -- but the author hears it now.
18
+ */
19
+ export function releasableType(type: string): boolean {
20
+ return !isPlaceholderType(type) && TYPE_PATTERN.test(type) && type.length >= TYPE_LENGTH.min && type.length <= TYPE_LENGTH.max
21
+ }
22
+
23
+ /** Why the first save cannot go on with this type, as an i18n key, or null when it can. */
24
+ export function firstSaveRefusalOf(type: string, taken: readonly string[]): { key: string; type?: string } | null {
25
+ if (!type) return { key: 'figure.text.enter-a-type-name' }
26
+ if (taken.includes(type)) return { key: 'figure.text.type-name-already-taken', type }
27
+ return null
28
+ }
29
+
30
+ /** What the first save asks for. */
31
+ export interface FirstSave {
32
+ type: string
33
+ name: string
34
+ }
@@ -17,6 +17,16 @@ export interface GizmoReach {
17
17
  scale: boolean
18
18
  /** The inputs sizing the part along X, Y and Z, when it can be sized. */
19
19
  sizes?: [string, string, string]
20
+ /**
21
+ * Per axis, the part's own measure, or null where the measure follows another (a square section's depth following
22
+ * its width): a scale drag writes the own ones and leaves a followed one to what it follows.
23
+ */
24
+ ownSizes?: (string | null)[]
25
+ /**
26
+ * A round part keeps its section round, as V2 kept it (`keepRound`): the radii across (X and Z) take the one factor
27
+ * the drag gave most; the length along Y takes its own. A sphere's three radii take one factor together.
28
+ */
29
+ round?: { across: string[]; along: string[] }
20
30
  }
21
31
 
22
32
  /** Shapes whose first three arguments are the extent along X, Y and Z. */
@@ -26,10 +36,11 @@ interface Node {
26
36
  id: string
27
37
  op: string
28
38
  args: string[]
39
+ outputs?: Record<string, string>
29
40
  params?: Record<string, unknown>
30
41
  }
31
42
  interface AssetLike {
32
- document: { model: { nodes: Node[]; inputs: Array<{ id: string; role?: string }> } }
43
+ document: { model: { nodes: Node[]; inputs: Array<{ id: string; role?: string }>; constants?: Array<{ id: string; value: number }> } }
33
44
  }
34
45
 
35
46
  const AXES = ['x', 'y', 'z'] as const
@@ -53,14 +64,39 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
53
64
  v3AttachmentsOf(asset as unknown as V3Asset)[part] === null
54
65
  const posed = direct || reseated
55
66
  const shape = nodes.find(node => node.id === `${part}.shape`)
67
+ const mine = (ids: string[]) => ids.every(id => id.startsWith(`${part}.`) && design.has(id))
68
+ const round =
69
+ shape?.op === 'cylinder-shape@1' && mine(shape.args.slice(0, 2))
70
+ ? { across: [shape.args[0]!], along: [shape.args[1]!] }
71
+ : shape?.op === 'frustum-shape@1' && mine(shape.args.slice(0, 3))
72
+ ? { across: shape.args.slice(0, 2), along: [shape.args[2]!] }
73
+ : shape?.op === 'sphere-shape@1' && mine(shape.args.slice(0, 3))
74
+ ? { across: shape.args.slice(0, 3), along: [] }
75
+ : undefined
56
76
  const extent = shape && BOXES.has(shape.op) ? shape.args.slice(0, 3) : []
57
77
  const sizes =
58
78
  extent.length === 3 && extent.every(id => id.startsWith(`${part}.`) && design.has(id)) ? (extent as [string, string, string]) : undefined
79
+ /*
80
+ The part's own measure, also when another reads it: a measure some other part follows is read through a node of its
81
+ own times 1 (`link-dimension`), and it is still the author's number -- the upper arm's width, which the forearm
82
+ follows, is the upper arm's to drag.
83
+ */
84
+ const constants = asset.document.model.constants ?? []
85
+ const ownMeasure = (id: string): string | null => {
86
+ if (id.startsWith(`${part}.`) && design.has(id)) return id
87
+ const writer = nodes.find(node => Object.values(node.outputs ?? {}).includes(id))
88
+ const one = writer?.op === 'mul@1' && writer.args.length === 2 && constants.find(c => c.id === writer.args[1])?.value === 1
89
+ return one && writer!.args[0]!.startsWith(`${part}.`) && design.has(writer!.args[0]!) ? writer!.args[0]! : null
90
+ }
91
+ const ownSizes = extent.length === 3 && !sizes ? extent.map(ownMeasure) : undefined
92
+ const someOwn = !!ownSizes?.some(Boolean)
59
93
  return {
60
94
  translate: posed && allDesign(own('position')) && own('position').every((id, i) => pose!.args[i] === id),
61
95
  rotate: posed && allDesign(own('rotation')) && own('rotation').every((id, i) => pose!.args[3 + i] === id),
62
- scale: !!sizes,
63
- ...(sizes ? { sizes } : {})
96
+ scale: !!sizes || !!round || someOwn,
97
+ ...(sizes ? { sizes } : {}),
98
+ ...(someOwn ? { ownSizes } : {}),
99
+ ...(round ? { round } : {})
64
100
  }
65
101
  }
66
102
 
@@ -87,5 +123,18 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
87
123
  reach.sizes.forEach((id, i) => {
88
124
  if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * result.scale![i]!)))
89
125
  })
126
+ if (result.scale && reach.scale && reach.ownSizes)
127
+ reach.ownSizes.forEach((id, i) => {
128
+ if (id && sizes[id] !== undefined && Math.abs(result.scale![i]! - 1) > 1e-4) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * result.scale![i]!)))
129
+ })
130
+ if (result.scale && reach.scale && reach.round) {
131
+ const [sx, sy, sz] = result.scale
132
+ /* The factor the drag moved most: across for a cylinder (X or Z), any axis for a sphere. */
133
+ const most = (factors: number[]) => factors.reduce((a, b) => (Math.abs(b - 1) > Math.abs(a - 1) ? b : a), 1)
134
+ const sphere = reach.round.along.length === 0
135
+ const across = sphere ? most([sx!, sy!, sz!]) : most([sx!, sz!])
136
+ for (const id of reach.round.across) if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * across)))
137
+ for (const id of reach.round.along) if (sizes[id] !== undefined) put(id, 'mm', Math.max(1, Math.round(sizes[id]! * sy!)))
138
+ }
90
139
  return changes
91
140
  }
@@ -0,0 +1,116 @@
1
+ import * as THREE from 'three'
2
+ import { compileV3Asset } from '@hatiolab/figure-model'
3
+ import type { V3Asset, V3Joint } from '@hatiolab/figure-model'
4
+
5
+ import type { GizmoResult } from './v3-gizmo.js'
6
+ import type { V3RenderPart } from './v3-renderer.js'
7
+
8
+ /*
9
+ * A gizmo drag on a part that hangs from a joint edits the joint, not the part (ADR-0093; r3 §3: the modeller changes
10
+ * the inputs of the relation, and never breaks it because a computed place was moved):
11
+ *
12
+ * - turning a part on a revolute or continuous joint sets the joint's value -- the turn about the joint's axis;
13
+ * - sliding a part on a prismatic joint along its axis sets its value;
14
+ * - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it.
15
+ *
16
+ * What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
17
+ * another, a turn on a joint that does not turn.
18
+ */
19
+
20
+ export type JointDrag =
21
+ | { kind: 'value'; input: string; unit: 'deg' | 'mm' | 'ratio'; value: number }
22
+ | { kind: 'joint'; joint: V3Joint }
23
+ | { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis'; joint: string; leader?: string; axis?: string }
24
+
25
+ type Axis = 'x' | 'y' | 'z'
26
+ const AXES: Axis[] = ['x', 'y', 'z']
27
+ /** The axis each face faces along, and which way (as figure-model's `V3_FACES`). */
28
+ const FACE_AXIS: Record<string, Axis> = { left: 'x', right: 'x', bottom: 'y', top: 'y', back: 'z', front: 'z' }
29
+ const FACE_SIGN: Record<string, number> = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 }
30
+
31
+ const matrixOf = (r: number[][]) => new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
32
+ const eulerMatrix = (deg: number[]) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), 'XYZ'))
33
+ const unit = (axis: string) => new THREE.Vector3(/x/i.test(axis) ? 1 : 0, /y/i.test(axis) ? 1 : 0, /z/i.test(axis) ? 1 : 0)
34
+ const round = (v: number, step: number) => Number((Math.round(v / step) * step).toFixed(Math.max(0, -Math.floor(Math.log10(step)))))
35
+
36
+ /** The part's own quarter turn in the joint frame, as the record says it. */
37
+ const turnMatrix = (j: V3Joint) => eulerMatrix(AXES.map(a => j.origin?.turn?.[a] ?? 0))
38
+
39
+ /** The joint frame's rotation in the figure: the part's rotation now, less its own turn and less the joint's. */
40
+ function jointFrameRotation(j: V3Joint, part: V3RenderPart, angle: number): THREE.Matrix4 {
41
+ const now = matrixOf(part.pose.r as number[][])
42
+ const own = turnMatrix(j)
43
+ const turn = j.type === 'revolute' ? new THREE.Matrix4().makeRotationAxis(unit(j.axis!), THREE.MathUtils.degToRad(angle)) : new THREE.Matrix4()
44
+ return now.multiply(own.invert()).multiply(turn.invert())
45
+ }
46
+
47
+ export function jointDragOf(asset: V3Asset, id: string, part: V3RenderPart, result: GizmoResult, values: Record<string, number>): JointDrag | null {
48
+ const j = asset.joints?.find(joint => joint.body1.part === id)
49
+ /* A size is the part's own, not the joint's: a scale drag goes to the part's measures (the editor drops the shift). */
50
+ if (!j || result.scale || (!result.rotation && !result.position)) return null
51
+ const stateId = j.state?.id ?? j.id
52
+ const now = values[stateId] ?? asset.stateDefaults[stateId] ?? 0
53
+ const turns = j.type === 'revolute'
54
+ /* A revolute joint without limits turns without end (USD). */
55
+ const endless = turns && j.lowerLimit === undefined && j.upperLimit === undefined
56
+
57
+ if (result.rotation) {
58
+ if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
59
+ if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
60
+ /* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
61
+ const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
62
+ const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
63
+ const change = after.multiply(before.invert())
64
+ const axis = unit(j.axis!).applyMatrix4(jointFrameRotation(j, part, now)).normalize()
65
+ const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
66
+ /* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
67
+ const whole = 2 * Math.acos(Math.min(1, Math.abs(change.w)))
68
+ if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: j.axis! }
69
+ let value = now + THREE.MathUtils.radToDeg(twist)
70
+ if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
71
+ else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
72
+ return { kind: 'value', input: stateId, unit: 'deg', value: round(value, 0.1) }
73
+ }
74
+
75
+ const moved = new THREE.Vector3(...result.position!).sub(new THREE.Vector3(...(part.pose.t as [number, number, number])))
76
+ if (moved.length() < 0.5) return null
77
+ const frame = jointFrameRotation(j, part, turns ? now : 0)
78
+
79
+ if (j.type === 'prismatic') {
80
+ if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
81
+ /* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
82
+ const along = unit(j.axis!).applyMatrix4(frame).normalize()
83
+ const at = (v: number) => {
84
+ const g = compileV3Asset(asset).evaluate({ ...values, [stateId]: v }).geometry.find(g => g.id === id && 'pose' in g) as { pose: { t: number[] } } | undefined
85
+ return g ? new THREE.Vector3(...(g.pose.t as [number, number, number])).dot(along) : 0
86
+ }
87
+ const step = j.travel ? 0.01 : 1
88
+ const rate = (at(now + step) - at(now)) / step
89
+ if (Math.abs(rate) > 1e-9 && Math.abs(moved.dot(along)) >= moved.length() * 0.7) {
90
+ const value = Math.min(Math.max(now + moved.dot(along) / rate, j.lowerLimit!), j.upperLimit!)
91
+ return { kind: 'value', input: stateId, unit: j.travel ? 'ratio' : 'mm', value: round(value, j.travel ? 0.001 : 1) }
92
+ }
93
+ }
94
+
95
+ /* Moving the joint's origin: the change in the frame the joint sits in. */
96
+ const local = moved.clone().applyMatrix4(frame.clone().invert())
97
+ const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
98
+ /* Which way a larger gap moves the part: out of the face it meets (on the plane: up when it stands, down when it hangs). */
99
+ const outward = 'plane' in j.body0 ? (j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
100
+ const origin: NonNullable<V3Joint['origin']> = { ...(j.origin ?? {}) }
101
+ const along = local[normal]
102
+ if (Math.abs(along) >= 0.5) {
103
+ const gap = origin.gap ?? 0
104
+ if (typeof gap !== 'number') return { kind: 'refused', reason: 'origin-follows', joint: j.id }
105
+ origin.gap = round(gap + outward * along, 1)
106
+ }
107
+ for (const axis of AXES.filter(a => a !== normal)) {
108
+ const d = local[axis]
109
+ if (Math.abs(d) < 0.5) continue
110
+ const how = origin.align?.[axis] ?? 'centre'
111
+ const was = how === 'centre' ? 0 : typeof how === 'object' && typeof how.mm === 'number' ? how.mm : null
112
+ if (was === null) return { kind: 'refused', reason: 'origin-follows', joint: j.id }
113
+ origin.align = { ...(origin.align ?? {}), [axis]: { mm: round(was + d, 1) } }
114
+ }
115
+ return { kind: 'joint', joint: { ...j, origin } }
116
+ }
@@ -14,8 +14,7 @@ import type { V3RenderPart } from './v3-renderer.js'
14
14
  * the slope of the control, and a joint inside other joints is drawn where its parents put it.
15
15
  *
16
16
  * Two ways a part turns are read:
17
- * - a motion made in the modeller (`add-motion`): `<part>.motion`, the frame it moves is its seat, the pivot the
18
- * seat's origin;
17
+ * - a joint record (ADR-0093): at the joint's origin, about its axis;
19
18
  * - a joint the converter wrote from V2 (`<joint>.motion` · `<joint>.chain`): the part is composed on the joint's
20
19
  * chain, the pivot is the joint's origin (`<joint>.frameOnParent`).
21
20
  * A slide has no fan. Nothing is drawn when the turn cannot be found -- a fan in some guessed plane would point the
@@ -92,15 +91,29 @@ function turnOf(asset: V3Asset, part: string, values: Record<string, unknown>, b
92
91
  return axis.every(v => typeof v === 'number') ? norm(axis as number[]) : null
93
92
  }
94
93
 
95
- /* Made in the modeller: the part's own motion turns its seat about the seat's origin. */
96
- const own = node(`${part}.motion`)
97
- if (own) {
98
- if (own.op !== 'axis-turn@1') return null
99
- const axis = axisOf(own)
100
- const control = controlOf(own.args[3])
101
- const at = valueOf(node(`${part}.pose`))
102
- if (!axis || !control || !at) return null
103
- return { carrier: own.id, pivot: [0, 0, 0], axis, toward: at.t, control, turnedPart: part }
94
+ /*
95
+ A joint record (ADR-0093): the fan stands at the joint -- the point on the parent's face the child is fastened at --
96
+ about the joint's axis. A part that does not turn itself shows the joint that sits on it, when there is one: the
97
+ elbow block shows the elbow (user, 2026-09-24: 「elbow-housing itself is a axis」).
98
+ */
99
+ const turning = (asset.joints ?? []).filter(j => j.type === 'revolute')
100
+ const record = turning.find(j => j.body1.part === part) ?? (() => {
101
+ const on = turning.filter(j => 'part' in j.body0 && j.body0.part === part)
102
+ return on.length === 1 && !(asset.joints ?? []).some(j => j.body1.part === part && j.type !== 'fixed') ? on[0] : undefined
103
+ })()
104
+ if (record) {
105
+ const child = record.body1.part
106
+ const carrier = node(`${child}.world`) ? `${child}.world` : `${child}.chain`
107
+ const pose = valueOf(node(`${child}.pose`))
108
+ const motion = node(`${child}.motion`)
109
+ const control = controlOf(motion?.args[3])
110
+ if (!node(carrier) || !pose || !control) return null
111
+ /* In the child's own frame: the joint is where its pose puts the seat's origin, the axis turned back by its pose. */
112
+ const back = (v: number[]) => [0, 1, 2].map(i => pose.r[0]![i]! * v[0]! + pose.r[1]![i]! * v[1]! + pose.r[2]![i]! * v[2]!)
113
+ const pivot = back(pose.t.map(v => -v))
114
+ const axis = norm(back(AXIS_NAMES.map(a => (a.toUpperCase() === record.axis ? 1 : 0))))
115
+ if (!axis) return null
116
+ return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
104
117
  }
105
118
 
106
119
  /*
@@ -5,7 +5,11 @@
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'
8
+ export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | GizmoKey
9
+
10
+ /** The gizmo's modes on W, E and R, as the scene has them. */
11
+ export type GizmoKey = 'translate' | 'rotate' | 'scale'
12
+ const GIZMO_KEYS: Record<string, GizmoKey> = { w: 'translate', e: 'rotate', r: 'scale' }
9
13
 
10
14
  export interface KeyLike {
11
15
  key: string
@@ -15,6 +19,12 @@ export interface KeyLike {
15
19
  altKey?: boolean
16
20
  /** The element the key was pressed in -- the first of the event's composed path. */
17
21
  target?: { tagName?: string; isContentEditable?: boolean } | null
22
+ /**
23
+ * Pressed with the canvas focused. The scene takes W, E and R there itself; they are passed on only from elsewhere
24
+ * -- a part picked in the parts list left the focus there, and the gizmo could not be had at all (2026-09-25).
25
+ * Passing a key the scene already took would toggle the gizmo off again.
26
+ */
27
+ inScene?: boolean
18
28
  }
19
29
 
20
30
  const TYPING = new Set(['INPUT', 'SELECT', 'TEXTAREA'])
@@ -30,5 +40,6 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
30
40
  if (mod && key === 'd') return 'duplicate'
31
41
  if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
32
42
  if (!mod && key === 'escape') return 'deselect'
43
+ if (!mod && !e.shiftKey && !e.inScene && GIZMO_KEYS[key]) return GIZMO_KEYS[key]
33
44
  return null
34
45
  }