@things-factory/figure-ui 10.1.47 → 10.1.48

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 (44) hide show
  1. package/client/modeller/figure-v3-parts.ts +1 -1
  2. package/client/modeller/v3-canvas.ts +37 -3
  3. package/client/modeller/v3-editor.ts +39 -4
  4. package/client/modeller/v3-fields.ts +22 -0
  5. package/client/modeller/v3-joint-fan.ts +292 -0
  6. package/client/modeller/v3-links.ts +8 -0
  7. package/client/modeller/v3-thumbnail.ts +8 -4
  8. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  9. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  10. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  11. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  12. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  13. package/dist-client/modeller/figure-v3-parts.js +1 -1
  14. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  15. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  16. package/dist-client/modeller/v3-canvas.d.ts +15 -0
  17. package/dist-client/modeller/v3-canvas.js +45 -3
  18. package/dist-client/modeller/v3-canvas.js.map +1 -1
  19. package/dist-client/modeller/v3-editor.d.ts +15 -0
  20. package/dist-client/modeller/v3-editor.js +42 -4
  21. package/dist-client/modeller/v3-editor.js.map +1 -1
  22. package/dist-client/modeller/v3-fields.d.ts +9 -0
  23. package/dist-client/modeller/v3-fields.js +23 -0
  24. package/dist-client/modeller/v3-fields.js.map +1 -1
  25. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  26. package/dist-client/modeller/v3-joint-fan.js +264 -0
  27. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  28. package/dist-client/modeller/v3-links.d.ts +5 -0
  29. package/dist-client/modeller/v3-links.js +7 -0
  30. package/dist-client/modeller/v3-links.js.map +1 -1
  31. package/dist-client/modeller/v3-thumbnail.js +6 -3
  32. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  33. package/dist-client/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +5 -5
  35. package/test/v3-fields.test.ts +10 -1
  36. package/test/v3-joint-fan.test.ts +210 -0
  37. package/test/v3-links.test.ts +13 -1
  38. package/test/v3-palette-icons.test.ts +22 -0
  39. package/test/v3-thumbnail.test.ts +29 -0
  40. package/translations/en.json +6 -1
  41. package/translations/ja.json +6 -1
  42. package/translations/ko.json +6 -1
  43. package/translations/ms.json +6 -1
  44. package/translations/zh.json +6 -1
@@ -59,7 +59,7 @@ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
59
59
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
60
60
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
61
61
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
62
- { kind: 'wall', icon: 'wall', label: 'figure.label.primitive-wall' },
62
+ { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
63
63
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
64
64
  ]
65
65
 
@@ -7,6 +7,7 @@ import type { GizmoReach } from './v3-gizmo.js'
7
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
+ import { JOINT_FAN } from './v3-joint-fan.js'
10
11
 
11
12
  /*
12
13
  * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
@@ -44,6 +45,9 @@ interface SceneHandle {
44
45
  root?: SceneLayer
45
46
  }
46
47
 
48
+ /* The fan's colour when the figure's palette names no accent. */
49
+ const FAN_COLOUR = '#ff8a00'
50
+
47
51
  /* The 2D side of the board, which the author never sees on this page but the scene paints. */
48
52
  const PAPER = '#ffffff'
49
53
 
@@ -81,6 +85,14 @@ export class FigureV3Canvas extends LitElement {
81
85
  /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
82
86
  @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }
83
87
  @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW
88
+ /**
89
+ * View only: nothing that happens on the board is told out. The card's picture is taken from a second canvas stood up
90
+ * off screen (`v3-thumbnail.ts`); the scene settles its components as it builds them, and a change told from there
91
+ * would reach the editor as an edit (V2's `viewOnly`, for the same reason).
92
+ */
93
+ @property({ type: Boolean }) viewOnly = false
94
+ /** Where the selected part turns and how far (`v3-joint-fan`): shown, never picked, never saved. */
95
+ @property({ attribute: false }) fan: V3RenderPart | null = null
84
96
 
85
97
  @query('div[stage]') private stage!: HTMLDivElement
86
98
 
@@ -103,7 +115,7 @@ export class FigureV3Canvas extends LitElement {
103
115
 
104
116
  protected updated(changed: PropertyValues) {
105
117
  if (!this.hasUpdated) return
106
- if (changed.has('parts') || changed.has('palette') || changed.has('context')) this.standUp()
118
+ if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()
107
119
  if (changed.has('view')) this.applyView()
108
120
  if (changed.has('selected')) this.showSelected()
109
121
  }
@@ -115,6 +127,25 @@ export class FigureV3Canvas extends LitElement {
115
127
  this.teardown()
116
128
  }
117
129
 
130
+ /**
131
+ * The board as a picture, for the card: the 3D view at this size, or nothing when it cannot be taken. The scene's
132
+ * `toDataURL` renders the 2D board (a white sheet, V2 found), and the 3D renderer keeps no drawing buffer, so the
133
+ * scene's own `snapshot3d` draws and reads in one task. A picture that cannot be taken is not an error and is not
134
+ * faked (V2's `snapshot`, lost when this canvas moved onto the scene -- no card had a picture since, user 2026-09-24).
135
+ */
136
+ async snapshot(width = 480, height = 360): Promise<string | undefined> {
137
+ const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined
138
+ if (!layer?.snapshot3d) return undefined
139
+ try {
140
+ /* webp: a card needs no alpha, and png is three or four times heavier in every list response. */
141
+ const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)
142
+ return typeof url === 'string' && url.startsWith('data:') ? url : undefined
143
+ } catch (e) {
144
+ console.warn(`[thumbnail] could not take the picture -- ${(e as Error).message}`)
145
+ return undefined
146
+ }
147
+ }
148
+
118
149
  /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
119
150
  fit() {
120
151
  this.scene?.fit?.('ratio')
@@ -167,7 +198,10 @@ export class FigureV3Canvas extends LitElement {
167
198
  }
168
199
 
169
200
  private components(): Record<string, unknown>[] {
170
- return this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
201
+ const parts = this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
202
+ /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
203
+ const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
204
+ return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts
171
205
  }
172
206
 
173
207
  /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
@@ -213,7 +247,7 @@ export class FigureV3Canvas extends LitElement {
213
247
  private watchScene() {
214
248
  const layer = this.scene?.root
215
249
  const root = layer?.root
216
- if (!layer || !root?.on) return
250
+ if (!layer || !root?.on || this.viewOnly) return
217
251
  root.on('selected', (after: unknown) => {
218
252
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
219
253
  const id = typeof name === 'string' ? name : ''
@@ -19,14 +19,15 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
19
19
  import './figure-v3-capability.js'
20
20
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
21
21
  import { keyActionOf } from './v3-keys.js'
22
- import { groupFields, type FieldLike, type FieldGroup } from './v3-fields.js'
22
+ import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
23
23
  import { SidePanelStyles } from './side-panel-styles.js'
24
24
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
25
25
  import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
26
26
  import { paletteRecipe } from './v3-palette.js'
27
27
  import type { FigureV3Canvas } from './v3-canvas.js'
28
28
  import type { V3BudgetLine } from './v3-maturity.js'
29
- import { instanceRangeOf, rangeDiffers } from './v3-links.js'
29
+ import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
30
+ import { jointFanOf } from './v3-joint-fan.js'
30
31
  import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
31
32
 
32
33
 
@@ -306,6 +307,18 @@ export class FigureV3Editor extends LitElement {
306
307
  const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
307
308
  return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
308
309
  }
310
+ /**
311
+ * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
312
+ * fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
313
+ */
314
+ private fanOf(asset: V3Asset | undefined) {
315
+ if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
316
+ try {
317
+ return jointFanOf(asset, this.selected, this.live.values)
318
+ } catch {
319
+ return null
320
+ }
321
+ }
309
322
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
310
323
  private hold(values: Record<string, number>, edit?: V3PartEdit) {
311
324
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
@@ -454,6 +467,14 @@ export class FigureV3Editor extends LitElement {
454
467
  * A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
455
468
  * (figure-model's rename-part), as one step of the history.
456
469
  */
470
+ /**
471
+ * A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
472
+ * its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
473
+ */
474
+ private renderRepeatNote(geometry: readonly unknown[] | undefined) {
475
+ 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
477
+ }
457
478
  private renamePart(input: HTMLInputElement) {
458
479
  const name = input.value.trim()
459
480
  const from = this.selected
@@ -637,8 +658,20 @@ export class FigureV3Editor extends LitElement {
637
658
  <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
638
659
  </button>`
639
660
  : nothing}
661
+ ${this.renderSizingRule(asset, unavailable)}
640
662
  </section>`
641
663
  }
664
+ /**
665
+ * How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
666
+ * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
667
+ */
668
+ private renderSizingRule(asset: V3Asset, unavailable: boolean) {
669
+ 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>`
670
+ return html`${turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
671
+ <button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
672
+ <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
673
+ </button>`
674
+ }
642
675
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
643
676
  private choose(id: string) {
644
677
  this.selected = id
@@ -744,6 +777,7 @@ export class FigureV3Editor extends LitElement {
744
777
  : html`<figure-v3-canvas
745
778
  .view=${this.view}
746
779
  .parts=${this.drawnParts(view.geometry, asset)}
780
+ .fan=${this.fanOf(asset as V3Asset | undefined)}
747
781
  .palette=${view.palette ?? {}}
748
782
  .context=${view.context ?? ''}
749
783
  .selected=${this.selected}
@@ -774,11 +808,12 @@ export class FigureV3Editor extends LitElement {
774
808
  <input
775
809
  name-field
776
810
  aria-label=${i18next.t('figure.label.name')}
777
- .value=${this.selected}
778
- ?disabled=${unavailable}
811
+ .value=${partLabelOf(this.selected)}
812
+ ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
779
813
  @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
780
814
  />
781
815
  </div>
816
+ ${this.renderRepeatNote(view.geometry)}
782
817
  </section>
783
818
  ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
784
819
  ${this.renderLinks(unavailable, 'shape')}
@@ -60,3 +60,25 @@ 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) return null
70
+ const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
71
+ const index = path.find((step): step is number => typeof step === 'number')
72
+ return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
73
+ }
74
+
75
+ function pathOf(id: string): unknown[] | null {
76
+ if (!id.startsWith('[')) return null
77
+ try {
78
+ const path = JSON.parse(id)
79
+ return Array.isArray(path) && typeof path[0] === 'string' ? path : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
@@ -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
+
@@ -252,3 +252,11 @@ export function rangeDiffers(a: V3SizeRange, b: V3SizeRange): boolean {
252
252
  return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
253
253
  }
254
254
 
255
+ /**
256
+ * Whether the figure is a mechanism -- a part turns about a joint. Such a figure should grow by one ratio (chief
257
+ * architect's ruling 2026-09-24); the modeller proposes it, the author declares it.
258
+ */
259
+ export function turnsAboutAJoint(asset: V3Asset): boolean {
260
+ return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
261
+ }
262
+
@@ -11,8 +11,9 @@ import type { V3RenderPart } from './v3-renderer.js'
11
11
  *
12
12
  * 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있다. 그것을 찍으면 카드마다 프레임이
13
13
  * 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이 된다. 목록은 견주는 자리이므로 그림은
14
- * **정해진 크기와 정해진 카메라**로 찍는다. `figure-v3-canvas` 의 `thumbnail()` 은 보이는 캔버스를
15
- * 그대로 뽑으므로 카드용이 아니다 — 여기서는 새로 세운 캔버스가 스스로 `fit()` 한 자리에서 찍는다.
14
+ * **정해진 크기와 정해진 카메라**로 찍는다 — 새로 세운 캔버스(`viewOnly`)가 스스로 `fit()` 한 자리에서
15
+ * `snapshot()` 으로 찍는다. 캔버스가 씬 위로 옮겨 가면서 찍는 길이 빠졌고, 그동안 저장은 그림 없이 갔다
16
+ * (사용자 2026-09-24).
16
17
  *
17
18
  * ## 왜 서버가 아니라 브라우저인가
18
19
  *
@@ -37,7 +38,8 @@ interface Shooter extends HTMLElement {
37
38
  parts?: V3RenderPart[]
38
39
  palette?: Record<string, string>
39
40
  context?: string
40
- thumbnail?: () => string | undefined
41
+ viewOnly?: boolean
42
+ snapshot?: (width?: number, height?: number) => Promise<string | undefined>
41
43
  fit?: () => void
42
44
  }
43
45
 
@@ -74,6 +76,7 @@ export async function captureV3Thumbnail(
74
76
  shooter.parts = parts
75
77
  shooter.palette = palette
76
78
  shooter.context = 'thumbnail'
79
+ shooter.viewOnly = true
77
80
  shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
78
81
 
79
82
  try {
@@ -82,7 +85,8 @@ export async function captureV3Thumbnail(
82
85
  (async () => {
83
86
  await settle()
84
87
  shooter.fit?.()
85
- return shooter.thumbnail?.()
88
+ await settle()
89
+ return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
86
90
  })(),
87
91
  PATIENCE
88
92
  )
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
15
15
  /** The selected part, whose places are marked. */
16
16
  selected: string;
17
17
  locked: boolean;
18
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
18
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
19
19
  private setRung;
20
20
  private pick;
21
21
  }
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
18
18
  /** Where each clip is scrubbed to, in seconds. */
19
19
  private at;
20
20
  private adding;
21
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
21
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
22
22
  private renderClip;
23
23
  private renderTrack;
24
24
  private renderAdd;
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
22
22
  private open;
23
23
  /** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
24
24
  private live;
25
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
25
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
26
26
  private renderControl;
27
27
  private renderDial;
28
28
  private renderSettings;
@@ -30,7 +30,7 @@ export declare class FigureV3Links extends FigureV3Links_base {
30
30
  private fasten;
31
31
  private motion;
32
32
  private place;
33
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
33
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
34
34
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
35
35
  private renderPlaces;
36
36
  /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
@@ -26,7 +26,7 @@ export declare class FigureV3Material extends FigureV3Material_base {
26
26
  locked: boolean;
27
27
  /** The glow while its slider is dragged: shown beside it and drawn on the canvas, recorded on release. */
28
28
  private glowing?;
29
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
29
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
30
30
  private renderSegments;
31
31
  private swatches;
32
32
  private offered;
@@ -27,7 +27,7 @@ const RECIPES = [
27
27
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
28
28
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
29
29
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
30
- { kind: 'wall', icon: 'wall', label: 'figure.label.primitive-wall' },
30
+ { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
31
31
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
32
32
  ];
33
33
  let FigureV3Parts = class FigureV3Parts extends localize(i18next)(LitElement) {