@things-factory/figure-ui 10.1.47 → 10.1.49

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 (55) hide show
  1. package/client/modeller/figure-v3-parts.ts +86 -11
  2. package/client/modeller/side-panel-styles.ts +8 -0
  3. package/client/modeller/v3-canvas.ts +62 -8
  4. package/client/modeller/v3-editor.ts +196 -24
  5. package/client/modeller/v3-fields.ts +36 -0
  6. package/client/modeller/v3-joint-fan.ts +292 -0
  7. package/client/modeller/v3-links.ts +8 -0
  8. package/client/modeller/v3-stage-model.ts +24 -0
  9. package/client/modeller/v3-thumbnail.ts +8 -4
  10. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  11. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  12. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  13. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  14. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  16. package/dist-client/modeller/figure-v3-parts.js +81 -11
  17. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  18. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  19. package/dist-client/modeller/side-panel-styles.js +8 -0
  20. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  21. package/dist-client/modeller/v3-canvas.d.ts +17 -0
  22. package/dist-client/modeller/v3-canvas.js +72 -8
  23. package/dist-client/modeller/v3-canvas.js.map +1 -1
  24. package/dist-client/modeller/v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/v3-editor.d.ts +34 -0
  26. package/dist-client/modeller/v3-editor.js +200 -22
  27. package/dist-client/modeller/v3-editor.js.map +1 -1
  28. package/dist-client/modeller/v3-fields.d.ts +15 -0
  29. package/dist-client/modeller/v3-fields.js +35 -0
  30. package/dist-client/modeller/v3-fields.js.map +1 -1
  31. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  32. package/dist-client/modeller/v3-joint-fan.js +264 -0
  33. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  34. package/dist-client/modeller/v3-links.d.ts +5 -0
  35. package/dist-client/modeller/v3-links.js +7 -0
  36. package/dist-client/modeller/v3-links.js.map +1 -1
  37. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  38. package/dist-client/modeller/v3-stage-model.js +26 -0
  39. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  40. package/dist-client/modeller/v3-thumbnail.js +6 -3
  41. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +5 -5
  44. package/test/v3-fields.test.ts +46 -1
  45. package/test/v3-gizmo.test.ts +3 -1
  46. package/test/v3-joint-fan.test.ts +210 -0
  47. package/test/v3-links.test.ts +13 -1
  48. package/test/v3-maturity.test.ts +1 -1
  49. package/test/v3-palette-icons.test.ts +22 -0
  50. package/test/v3-thumbnail.test.ts +29 -0
  51. package/translations/en.json +17 -2
  52. package/translations/ja.json +17 -2
  53. package/translations/ko.json +17 -2
  54. package/translations/ms.json +17 -2
  55. package/translations/zh.json +17 -2
@@ -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,14 +20,17 @@ 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, 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'
24
25
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
25
26
  import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
26
27
  import { paletteRecipe } from './v3-palette.js'
27
28
  import type { FigureV3Canvas } from './v3-canvas.js'
29
+ import { nudgeOf } from './v3-stage-model.js'
30
+ import type { V3RenderPart } from './v3-renderer.js'
28
31
  import type { V3BudgetLine } from './v3-maturity.js'
29
- import { instanceRangeOf, rangeDiffers } from './v3-links.js'
32
+ import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
33
+ import { jointFanOf } from './v3-joint-fan.js'
30
34
  import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
31
35
 
32
36
 
@@ -287,7 +291,7 @@ export class FigureV3Editor extends LitElement {
287
291
  this.error = ''
288
292
  this.hiddenParts = new Set()
289
293
  }
290
- if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
294
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
291
295
  }
292
296
  private run(action: () => unknown) {
293
297
  try {
@@ -304,7 +308,19 @@ export class FigureV3Editor extends LitElement {
304
308
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
305
309
  private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
306
310
  const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
307
- return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
311
+ return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
312
+ }
313
+ /**
314
+ * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
315
+ * fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
316
+ */
317
+ private fanOf(asset: V3Asset | undefined) {
318
+ if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
319
+ try {
320
+ return jointFanOf(asset, this.selected, this.live.values)
321
+ } catch {
322
+ return null
323
+ }
308
324
  }
309
325
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
310
326
  private hold(values: Record<string, number>, edit?: V3PartEdit) {
@@ -384,7 +400,8 @@ export class FigureV3Editor extends LitElement {
384
400
  /** What the gizmo may write for the selected part — read off the asset being edited. */
385
401
  private reachOf(id: string): GizmoReach {
386
402
  const none = { translate: false, rotate: false, scale: false }
387
- if (!id || !this.session) return none
403
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
404
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
388
405
  try {
389
406
  return gizmoReachOf(JSON.parse(this.session.source()), id)
390
407
  } catch {
@@ -396,8 +413,36 @@ export class FigureV3Editor extends LitElement {
396
413
  const view = this.session!.read()
397
414
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
398
415
  const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
399
- if (changes.length) this.run(() => this.session!.apply(changes))
400
- else this.requestUpdate()
416
+ if (changes.length) {
417
+ this.run(() => this.session!.apply(changes))
418
+ return
419
+ }
420
+ /*
421
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
422
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
423
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
424
+ */
425
+ const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
426
+ 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
+ const { by, turn } = nudgeOf(body, result)
432
+ if (!Object.keys(by).length && !Object.keys(turn).length) return this.requestUpdate()
433
+ try {
434
+ this.session!.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) })
435
+ this.error = ''
436
+ this.changed()
437
+ } catch (error) {
438
+ const message = error instanceof Error ? error.message : String(error)
439
+ this.error = /carried by a joint/.test(message)
440
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
441
+ : /is fastened to/.test(message)
442
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
443
+ : message
444
+ this.requestUpdate()
445
+ }
401
446
  }
402
447
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
403
448
  private align(tool: AlignTool) {
@@ -454,6 +499,14 @@ export class FigureV3Editor extends LitElement {
454
499
  * A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
455
500
  * (figure-model's rename-part), as one step of the history.
456
501
  */
502
+ /**
503
+ * A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
504
+ * its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
505
+ */
506
+ private renderRepeatNote(geometry: readonly unknown[] | undefined) {
507
+ const repeat = repeatOf(this.selected, geometry)
508
+ return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
509
+ }
457
510
  private renamePart(input: HTMLInputElement) {
458
511
  const name = input.value.trim()
459
512
  const from = this.selected
@@ -637,11 +690,131 @@ export class FigureV3Editor extends LitElement {
637
690
  <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
638
691
  </button>`
639
692
  : nothing}
693
+ ${this.renderSizingRule(asset, unavailable)}
640
694
  </section>`
641
695
  }
696
+ /**
697
+ * How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
698
+ * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
699
+ */
700
+ private renderSizingRule(asset: V3Asset, unavailable: boolean) {
701
+ if (asset.sizing === 'uniform') return html`<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>`
702
+ return html`${turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
703
+ <button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
704
+ <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
705
+ </button>`
706
+ }
707
+ /**
708
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
709
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
710
+ */
711
+ private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
712
+ const rows = new Map<string, V3PartRow>()
713
+ for (const g of geometry) {
714
+ const id = ownerOf(g.id)
715
+ const row = rows.get(id)
716
+ if (row) {
717
+ row.copies = (row.copies ?? 1) + 1
718
+ continue
719
+ }
720
+ rows.set(id, {
721
+ id,
722
+ provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
723
+ hidden: this.hiddenParts.has(id),
724
+ invisible: (g as { visible?: boolean }).visible === false,
725
+ ...(id !== g.id ? { copies: 1 } : {})
726
+ })
727
+ }
728
+ return [...rows.values()]
729
+ }
730
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
731
+ private partBudgetOf(asset: V3Asset | undefined) {
732
+ if (!asset) return undefined
733
+ try {
734
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
735
+ } catch {
736
+ return undefined
737
+ }
738
+ }
739
+ /**
740
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
741
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
742
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
743
+ */
744
+ private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
745
+ const repeat = repeatOf(this.selected, geometry)
746
+ if (!asset || !repeat || repeat.index) return nothing
747
+ const model = asset.document.model as unknown as {
748
+ nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
749
+ constants: { id: string; value: number }[]
750
+ inputs: { id: string; max?: number }[]
751
+ }
752
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
753
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
754
+ const own = `${this.selected}.repeat.pitch`
755
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
756
+ const fits = layout?.op === 'fit-pitch@1'
757
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
758
+ const lengthRef = layout?.args[0] ?? ''
759
+ const length = asset.designInputs[lengthRef]
760
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max
761
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value
762
+ const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
763
+ return html`<section>
764
+ <h3>${i18next.t('figure.label.repeat')}</h3>
765
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
766
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
767
+ <div inline-field>
768
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
769
+ <input
770
+ type="number"
771
+ step="any"
772
+ aria-label=${own}
773
+ .value=${String(pitch ?? '')}
774
+ ?disabled=${unavailable || !fits}
775
+ @change=${(e: Event) => this.setPitch(field, e)}
776
+ />
777
+ </div>
778
+ <p hint>
779
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
780
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
781
+ length: length.toLocaleString(),
782
+ count: repeat.count,
783
+ longest: longest.toLocaleString(),
784
+ limit
785
+ })
786
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
787
+ </p>
788
+ </section>`
789
+ }
790
+ /**
791
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
792
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
793
+ * that -- said with the numbers.
794
+ */
795
+ private setPitch(field: { id: string; value: number }, event: Event) {
796
+ const input = event.target as HTMLInputElement
797
+ const pitch = input.valueAsNumber
798
+ if (!Number.isFinite(pitch) || pitch === field.value) return
799
+ try {
800
+ this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
801
+ this.error = ''
802
+ this.changed()
803
+ } catch (error) {
804
+ input.value = String(field.value)
805
+ const code = (error as { code?: string })?.code
806
+ const message = error instanceof Error ? error.message : String(error)
807
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
808
+ this.error =
809
+ code === 'INPUT_RANGE' && found
810
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
811
+ : message
812
+ }
813
+ }
642
814
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
643
815
  private choose(id: string) {
644
- this.selected = id
816
+ /* A copy on the canvas selects its repeat: the part the author made. */
817
+ this.selected = id ? ownerOf(id) : id
645
818
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
646
819
  }
647
820
  private asset(): unknown {
@@ -674,11 +847,12 @@ export class FigureV3Editor extends LitElement {
674
847
  const view = this.session?.read()
675
848
  if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
676
849
  const unavailable = view.loading || !!view.error || view.saving || !view.context
677
- const part = view.geometry.find(g => g.id === this.selected)
850
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
678
851
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
679
852
  const design = view.fields.filter(i => i.role !== 'state')
680
- const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
681
- const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
853
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
854
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
855
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
682
856
  const status = view.loading
683
857
  ? 'figure.text.loading-figure'
684
858
  : view.saving
@@ -714,21 +888,16 @@ export class FigureV3Editor extends LitElement {
714
888
  slot="start"
715
889
  .budget=${this.budget}
716
890
  @show-maturity=${() => (this.tab = 'verdict')}
717
- .parts=${view.geometry.map(
718
- (g): V3PartRow => ({
719
- id: g.id,
720
- provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
721
- hidden: this.hiddenParts.has(g.id),
722
- invisible: (g as { visible?: boolean }).visible === false
723
- })
724
- )}
891
+ .parts=${this.partRows(view.geometry)}
892
+ .partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
893
+ @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
725
894
  .selected=${this.selected}
726
895
  ?locked=${unavailable}
727
896
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
728
897
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
729
898
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
730
899
  @set-all-parts-hidden=${(e: CustomEvent) =>
731
- (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
900
+ (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
732
901
  @move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
733
902
  @move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
734
903
  @duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
@@ -744,6 +913,7 @@ export class FigureV3Editor extends LitElement {
744
913
  : html`<figure-v3-canvas
745
914
  .view=${this.view}
746
915
  .parts=${this.drawnParts(view.geometry, asset)}
916
+ .fan=${this.fanOf(asset as V3Asset | undefined)}
747
917
  .palette=${view.palette ?? {}}
748
918
  .context=${view.context ?? ''}
749
919
  .selected=${this.selected}
@@ -774,16 +944,18 @@ export class FigureV3Editor extends LitElement {
774
944
  <input
775
945
  name-field
776
946
  aria-label=${i18next.t('figure.label.name')}
777
- .value=${this.selected}
778
- ?disabled=${unavailable}
947
+ .value=${partLabelOf(this.selected)}
948
+ ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
779
949
  @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
780
950
  />
781
951
  </div>
952
+ ${this.renderRepeatNote(view.geometry)}
782
953
  </section>
783
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
954
+ ${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
955
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
784
956
  ${this.renderLinks(unavailable, 'shape')}
785
957
  <figure-v3-material
786
- .body=${part}
958
+ .body=${{ ...part, id: this.selected }}
787
959
  .palette=${view.palette ?? {}}
788
960
  ?locked=${unavailable}
789
961
  @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
@@ -60,3 +60,39 @@ export function partLabelOf(id: string): string {
60
60
  return places.length ? `${path[0].replace(/\.repeat$/, '')} ${places.join('·')}` : id
61
61
  }
62
62
 
63
+ /**
64
+ * The repeat a body is a copy of: its name, how many copies it makes, and which copy this is (counted from 1). Nothing
65
+ * for a body that is not a copy.
66
+ */
67
+ export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
68
+ const path = pathOf(id)
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
+ }
74
+ const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
75
+ const index = path.find((step): step is number => typeof step === 'number')
76
+ return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
77
+ }
78
+
79
+ function pathOf(id: string): unknown[] | null {
80
+ if (!id.startsWith('[')) return null
81
+ try {
82
+ const path = JSON.parse(id)
83
+ return Array.isArray(path) && typeof path[0] === 'string' ? path : null
84
+ } catch {
85
+ return null
86
+ }
87
+ }
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
+
@@ -0,0 +1,292 @@
1
+ import { compileV3Asset, compileV3Graph, v3WorldBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset } from '@hatiolab/figure-model'
3
+
4
+ import type { V3RenderPart } from './v3-renderer.js'
5
+
6
+ /*
7
+ * Where the selected part turns, and how far -- a translucent fan on the editing board (V2's joint gizmo, inventory
8
+ * 3.38). An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
9
+ * pushed each slider to its end to find out. The fan lies in the plane the part turns in, at the pivot, open from the
10
+ * control's lowest value to its highest; the edge at the value the figure holds now is where the part is.
11
+ *
12
+ * It is found by evaluating, not by reading the graph's shape: the frame that carries the part is sampled across the
13
+ * control's range, and a point at a fixed distance from the pivot traces the fan. So the direction is right whatever
14
+ * the slope of the control, and a joint inside other joints is drawn where its parents put it.
15
+ *
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;
19
+ * - a joint the converter wrote from V2 (`<joint>.motion` · `<joint>.chain`): the part is composed on the joint's
20
+ * chain, the pivot is the joint's origin (`<joint>.frameOnParent`).
21
+ * A slide has no fan. Nothing is drawn when the turn cannot be found -- a fan in some guessed plane would point the
22
+ * wrong way.
23
+ */
24
+
25
+ /** The fan's name on the board. It is not a part: not in the list, not saved, not picked. */
26
+ export const JOINT_FAN = '__joint-fan'
27
+
28
+ /** How thick the fan is: thin enough not to hide the part, thick enough to be seen edge-on. */
29
+ const THICKNESS = 6
30
+ /** About one segment every six degrees, so a full turn reads as a circle. */
31
+ const STEP_DEG = 6
32
+
33
+ type Pose = { r: number[][]; t: number[] }
34
+ interface Node {
35
+ id: string
36
+ op: string
37
+ args: string[]
38
+ outputs: Record<string, string>
39
+ params?: Record<string, string>
40
+ }
41
+
42
+ const apply = (p: Pose, v: number[]) => [0, 1, 2].map(i => p.r[i]![0]! * v[0]! + p.r[i]![1]! * v[1]! + p.r[i]![2]! * v[2]! + p.t[i]!)
43
+ const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]!)
44
+ const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i]!, 0)
45
+ const cross = (a: number[], b: number[]) => [a[1]! * b[2]! - a[2]! * b[1]!, a[2]! * b[0]! - a[0]! * b[2]!, a[0]! * b[1]! - a[1]! * b[0]!]
46
+ const norm = (a: number[]) => {
47
+ const l = Math.hypot(...a)
48
+ return l > 1e-9 ? a.map(v => v / l) : null
49
+ }
50
+
51
+ /** How far outside a part's box a pivot may lie and still be on the part: the kernel's contact skin. */
52
+ const SEAM = 1
53
+ /** An origin this deep in a part's box (a share of its half-measure, on every axis) is housed by the part. */
54
+ const HOUSED = 0.5
55
+ const AXIS_NAMES = ['x', 'y', 'z'] as const
56
+ type WorldBox = { centre: Record<'x' | 'y' | 'z', number>; extent: Record<'x' | 'y' | 'z', number> }
57
+
58
+ interface Turn {
59
+ /** The node whose value carries the moving frame into the figure's frame. */
60
+ carrier: string
61
+ /** The pivot, in the moving frame. */
62
+ pivot: number[]
63
+ /** The axis, in the moving frame. */
64
+ axis: number[]
65
+ /** Where the part sits, in the moving frame -- which way the fan's reference edge points. */
66
+ toward: number[]
67
+ /** The part the joint turns -- the fan is sized from it, so a joint shows one fan whichever part is selected. */
68
+ turnedPart: string
69
+ /** The state input that turns it. */
70
+ control: { id: string; min: number; max: number }
71
+ }
72
+
73
+ function turnOf(asset: V3Asset, part: string, values: Record<string, unknown>, box: WorldBox | undefined): Turn | null {
74
+ const m = asset.document.model as unknown as { nodes: Node[]; constants: { id: string; value: number }[]; inputs: { id: string; role?: string; min?: number; max?: number }[] }
75
+ const node = (id: string) => m.nodes.find(n => n.id === id)
76
+ const constant = (ref: string) => m.constants.find(c => c.id === ref)?.value
77
+ const valueOf = (n: Node | undefined) => (n ? values[Object.values(n.outputs)[0]!] : undefined) as Pose | undefined
78
+ const controlOf = (ref: string | undefined, seen = new Set<string>()): Turn['control'] | null => {
79
+ if (!ref || seen.has(ref)) return null
80
+ seen.add(ref)
81
+ const input = m.inputs.find(i => i.id === ref && i.role === 'state')
82
+ if (input) return { id: input.id, min: input.min ?? 0, max: input.max ?? 0 }
83
+ const writer = m.nodes.find(n => Object.values(n.outputs).includes(ref))
84
+ for (const arg of writer?.args ?? []) {
85
+ const found = controlOf(arg, seen)
86
+ if (found) return found
87
+ }
88
+ return null
89
+ }
90
+ const axisOf = (motion: Node) => {
91
+ const axis = motion.args.slice(0, 3).map(constant)
92
+ return axis.every(v => typeof v === 'number') ? norm(axis as number[]) : null
93
+ }
94
+
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 }
104
+ }
105
+
106
+ /*
107
+ Written by the converter: a joint (`<joint>.motion`, its origin `<joint>.frameOnParent`) carries its own part and
108
+ everything fastened below it, through its chain (the first joint of a chain has only its attachment). A part shows
109
+ the joint it is to that part of the mechanism (user, 2026-09-24: 「elbow-housing itself is a axis」 · 「please check
110
+ all that logic and concept」), tested in this order on the joints as they stand now:
111
+ 1. housing -- a joint's origin lies deep inside the part, which is centred on it: the shoulder housing shows the
112
+ shoulder and the elbow housing the elbow, though another joint carries them;
113
+ 2. turned -- the joint that carries the part, when its axis passes through the part: through its middle for a
114
+ body that rolls on it (the wrist roll, the tool flange, the gripper body), through its root for a link (the
115
+ upper arm, the forearm);
116
+ 3. otherwise none: the pedestal (the base's axis runs through it but turns it not), the fingers.
117
+ An axis passing through a part is not enough by itself: the wrist roll's axis runs the forearm's length and the
118
+ tool roll's through the wrist pitch, and neither turns them (an earlier rule said so, and was wrong).
119
+ */
120
+ if (!box) return null
121
+ const carrierOf = (joint: string) => (node(`${joint}.chain`) ? `${joint}.chain` : `${joint}.attach`)
122
+ const mine = node(`${part}.moved`)?.args.find(arg => /\.(chain|attach)\.value$/.test(arg))?.replace(/\.value$/, '')
123
+ const centre = AXIS_NAMES.map(a => box.centre[a])
124
+ const half = AXIS_NAMES.map(a => box.extent[a] / 2)
125
+ const joints = m.nodes
126
+ .filter(n => n.op === 'axis-turn@1' && n.id.endsWith('.motion') && node(n.id.replace(/\.motion$/, '.frameOnParent')))
127
+ .map(motion => {
128
+ const joint = motion.id.replace(/\.motion$/, '')
129
+ const carrier = carrierOf(joint)
130
+ const frame = valueOf(node(carrier))
131
+ const origin = valueOf(node(`${joint}.frameOnParent`))
132
+ const local = axisOf(motion)
133
+ if (!frame || !origin || !local) return null
134
+ const at = apply(frame, origin.t)
135
+ const along = norm([0, 1, 2].map(i => dot(frame.r[i]!, local)))
136
+ if (!along) return null
137
+ const off = sub(centre, at)
138
+ return {
139
+ joint,
140
+ motion,
141
+ carrier,
142
+ origin,
143
+ /* How deep the origin lies: 0 at the centre, 1 on the box's face, on the axis where it is shallowest. */
144
+ depth: Math.max(...[0, 1, 2].map(i => Math.abs(off[i]!) / (half[i]! + SEAM))),
145
+ fromCentre: Math.hypot(...off),
146
+ through: lineMeetsBox(at, along, centre, half)
147
+ }
148
+ })
149
+ .filter((c): c is NonNullable<typeof c> => !!c)
150
+ const housing = joints.filter(c => c.depth <= HOUSED).sort((a, b) => a.fromCentre - b.fromCentre)[0]
151
+ const carrying = joints.find(c => c.carrier === mine)
152
+ const chosen = housing ?? (carrying && carrying.through ? carrying : undefined)
153
+ if (!chosen) return null
154
+ const axis = axisOf(chosen.motion)
155
+ const control = controlOf(chosen.motion.args[3])
156
+ if (!axis || !control) return null
157
+ /* The reference edge points at what the joint turns: of the parts it carries, the one farthest off its axis. */
158
+ const flatOff = (t: number[]) => {
159
+ const rel = sub(t, chosen.origin.t)
160
+ return Math.hypot(...sub(rel, axis.map(v => v * dot(rel, axis))))
161
+ }
162
+ const turned = m.nodes
163
+ .filter(n => n.id.endsWith('.moved') && n.args.includes(`${chosen.carrier}.value`))
164
+ .map(n => ({ id: n.id.replace(/\.moved$/, ''), t: valueOf(node(n.id.replace(/\.moved$/, '.pose')))?.t }))
165
+ .filter((one): one is { id: string; t: number[] } => !!one.t)
166
+ .sort((a, b) => flatOff(b.t) - flatOff(a.t))[0]
167
+ return { carrier: chosen.carrier, pivot: chosen.origin.t, axis, toward: turned?.t ?? chosen.origin.t, control, turnedPart: turned?.id ?? part }
168
+ }
169
+
170
+ /**
171
+ * The fan for the part, as a body to stand on the board, or null when the part does not turn. `values` are the
172
+ * inputs the figure is drawn with now (a pose in hand included), so the fan follows the arm as it is posed.
173
+ */
174
+ export function jointFanOf(asset: V3Asset, part: string, values: Record<string, number> = {}): V3RenderPart | null {
175
+ if (!part) return null
176
+ let graph: ReturnType<typeof compileV3Graph>
177
+ try {
178
+ graph = compileV3Graph(asset.document.model as never)
179
+ } catch {
180
+ return null
181
+ }
182
+ const inputs = { ...asset.designInputs, ...asset.stateDefaults, ...values }
183
+ const at = (over: Record<string, number>) => graph.evaluate({ ...inputs, ...over }).values as Record<string, unknown>
184
+ let now: Record<string, unknown>
185
+ try {
186
+ now = at({})
187
+ } catch {
188
+ return null
189
+ }
190
+ let box: WorldBox | undefined
191
+ try {
192
+ box = v3WorldBoxesOf(
193
+ compileV3Asset({ ...asset, designInputs: { ...asset.designInputs, ...pick(values, asset.designInputs) }, stateDefaults: { ...asset.stateDefaults, ...pick(values, asset.stateDefaults) } })
194
+ .evaluate()
195
+ .geometry.filter(g => g.id === part),
196
+ v3WritersOf(asset)
197
+ )[0]
198
+ } catch {
199
+ box = undefined
200
+ }
201
+ const turn = turnOf(asset, part, now, box)
202
+ if (!turn) return null
203
+ const carrierRef = Object.values(asset.document.model.nodes.find(n => n.id === turn.carrier)!.outputs)[0]!
204
+ const carrierNow = now[carrierRef] as Pose | undefined
205
+ if (!carrierNow) return null
206
+
207
+ /* The reference edge points from the pivot toward the part, in the plane it turns in; any perpendicular if it sits on the axis. */
208
+ const rel = sub(turn.toward, turn.pivot)
209
+ const inPlane = sub(rel, turn.axis.map(v => v * dot(rel, turn.axis)))
210
+ const d = norm(inPlane) ?? norm(cross(turn.axis, Math.abs(turn.axis[0]!) < 0.9 ? [1, 0, 0] : [0, 1, 0]))!
211
+ /*
212
+ Sized from what the joint turns, as V2 sized it from the joint's child: selecting the elbow housing or the forearm
213
+ shows the elbow's one fan, not a smaller one for the smaller part (user, 2026-09-24).
214
+ */
215
+ const radius = Math.max(60, Math.round(Math.max(largestMeasureOf(asset, turn.turnedPart, inputs), Math.hypot(...inPlane)) * 0.6))
216
+ const edge = turn.pivot.map((v, i) => v + d[i]! * radius)
217
+
218
+ const { min, max } = turn.control
219
+ if (!(max > min)) return null
220
+ const steps = Math.max(2, Math.ceil((max - min) / STEP_DEG))
221
+ const pivot = apply(carrierNow, turn.pivot)
222
+ const traced: number[][] = []
223
+ try {
224
+ for (let i = 0; i <= steps; i++) {
225
+ const s = min + ((max - min) * i) / steps
226
+ traced.push(apply(at({ [turn.control.id]: s })[carrierRef] as Pose, edge))
227
+ }
228
+ } catch {
229
+ return null
230
+ }
231
+
232
+ /* The fan's own frame: x toward the first traced point, y along the axis as it stands now, z completing them. */
233
+ const axisNow = norm([0, 1, 2].map(i => dot(carrierNow.r[i]!, turn.axis)))
234
+ const first = norm(sub(traced[0]!, pivot))
235
+ if (!axisNow || !first) return null
236
+ const zAxis = cross(first, axisNow)
237
+ const r = [0, 1, 2].map(i => [first[i]!, axisNow[i]!, zAxis[i]!])
238
+ const local = (p: number[]) => {
239
+ const v = sub(p, pivot)
240
+ return { x: dot(v, first), y: dot(v, zAxis) }
241
+ }
242
+ const full = max - min >= 359.999
243
+ const profile = [...(full ? [] : [{ x: 0, y: 0 }]), ...traced.map(local)].map(p => ({ x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 }))
244
+
245
+ return {
246
+ id: JOINT_FAN,
247
+ shape: { provider: 'polygon', dimensions: { height: THICKNESS }, profile },
248
+ pose: { t: pivot, r },
249
+ material: { token: '__joint-fan', transparent: true },
250
+ /** Which control turns it: the joint the fan shows. */
251
+ turns: turn.control.id
252
+ } as unknown as V3RenderPart
253
+ }
254
+
255
+ /** The values in hand that belong to a record's keys (design inputs, or state defaults). */
256
+ function pick(values: Record<string, number>, of: Record<string, number>): Record<string, number> {
257
+ return Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
258
+ }
259
+
260
+ /** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
261
+ function largestMeasureOf(asset: V3Asset, part: string, inputs: Record<string, number>): number {
262
+ try {
263
+ const body = compileV3Asset({ ...asset, designInputs: pick(inputs, asset.designInputs), stateDefaults: pick(inputs, asset.stateDefaults) })
264
+ .evaluate()
265
+ .geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
266
+ const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
267
+ return dims.length ? Math.max(...dims) : 0
268
+ } catch {
269
+ return 0
270
+ }
271
+ }
272
+
273
+ /** Whether the line through `at` along `along` passes through the box (centre, half-measures), within the skin. */
274
+ function lineMeetsBox(at: number[], along: number[], centre: number[], half: number[]): boolean {
275
+ let lo = -Infinity,
276
+ hi = Infinity
277
+ for (let i = 0; i < 3; i++) {
278
+ const min = centre[i]! - half[i]! - SEAM,
279
+ max = centre[i]! + half[i]! + SEAM
280
+ if (Math.abs(along[i]!) < 1e-12) {
281
+ if (at[i]! < min || at[i]! > max) return false
282
+ continue
283
+ }
284
+ const t1 = (min - at[i]!) / along[i]!,
285
+ t2 = (max - at[i]!) / along[i]!
286
+ lo = Math.max(lo, Math.min(t1, t2))
287
+ hi = Math.min(hi, Math.max(t1, t2))
288
+ if (lo > hi) return false
289
+ }
290
+ return true
291
+ }
292
+