@things-factory/figure-ui 10.1.46 → 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 (148) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +61 -81
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +254 -262
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +566 -223
  18. package/client/modeller/v3-fields.ts +84 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-joint-fan.ts +292 -0
  21. package/client/modeller/v3-keys.ts +34 -0
  22. package/client/modeller/v3-links.ts +101 -9
  23. package/client/modeller/v3-live.ts +79 -0
  24. package/client/modeller/v3-maturity.ts +415 -0
  25. package/client/modeller/v3-palette.ts +45 -0
  26. package/client/modeller/v3-preview-model.ts +54 -0
  27. package/client/modeller/v3-stage-model.ts +159 -0
  28. package/client/modeller/v3-thumbnail.ts +8 -4
  29. package/client/pages/figure-list-page.ts +4 -4
  30. package/client/pages/figure-v3-modeller-page.ts +66 -134
  31. package/dist-client/modeller/angle-dial.d.ts +32 -0
  32. package/dist-client/modeller/angle-dial.js +41 -0
  33. package/dist-client/modeller/angle-dial.js.map +1 -0
  34. package/dist-client/modeller/figure-settings.d.ts +39 -0
  35. package/dist-client/modeller/figure-settings.js +317 -0
  36. package/dist-client/modeller/figure-settings.js.map +1 -0
  37. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  38. package/dist-client/modeller/figure-v3-capability.js +141 -0
  39. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  40. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  41. package/dist-client/modeller/figure-v3-clips.js +268 -0
  42. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  43. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  44. package/dist-client/modeller/figure-v3-controls.js +226 -0
  45. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  46. package/dist-client/modeller/figure-v3-links.d.ts +4 -1
  47. package/dist-client/modeller/figure-v3-links.js +64 -82
  48. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  49. package/dist-client/modeller/figure-v3-material.d.ts +4 -1
  50. package/dist-client/modeller/figure-v3-material.js +109 -35
  51. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  52. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  53. package/dist-client/modeller/figure-v3-parts.js +88 -3
  54. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  55. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  56. package/dist-client/modeller/figure-v3-preview.js +322 -0
  57. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  58. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  59. package/dist-client/modeller/figure-v3-properties.js +29 -57
  60. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  61. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  62. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  63. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  64. package/dist-client/modeller/figure-view.d.ts +118 -0
  65. package/dist-client/modeller/figure-view.js +78 -0
  66. package/dist-client/modeller/figure-view.js.map +1 -0
  67. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  68. package/dist-client/modeller/side-panel-styles.js +326 -0
  69. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  70. package/dist-client/modeller/v3-canvas.d.ts +44 -37
  71. package/dist-client/modeller/v3-canvas.js +230 -260
  72. package/dist-client/modeller/v3-canvas.js.map +1 -1
  73. package/dist-client/modeller/v3-clips.d.ts +39 -0
  74. package/dist-client/modeller/v3-clips.js +58 -0
  75. package/dist-client/modeller/v3-clips.js.map +1 -0
  76. package/dist-client/modeller/v3-controls.d.ts +28 -0
  77. package/dist-client/modeller/v3-controls.js +28 -0
  78. package/dist-client/modeller/v3-controls.js.map +1 -0
  79. package/dist-client/modeller/v3-editor.d.ts +73 -1
  80. package/dist-client/modeller/v3-editor.js +593 -218
  81. package/dist-client/modeller/v3-editor.js.map +1 -1
  82. package/dist-client/modeller/v3-fields.d.ts +39 -0
  83. package/dist-client/modeller/v3-fields.js +69 -0
  84. package/dist-client/modeller/v3-fields.js.map +1 -0
  85. package/dist-client/modeller/v3-help.d.ts +18 -0
  86. package/dist-client/modeller/v3-help.js +37 -0
  87. package/dist-client/modeller/v3-help.js.map +1 -0
  88. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  89. package/dist-client/modeller/v3-joint-fan.js +264 -0
  90. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  91. package/dist-client/modeller/v3-keys.d.ts +14 -0
  92. package/dist-client/modeller/v3-keys.js +23 -0
  93. package/dist-client/modeller/v3-keys.js.map +1 -0
  94. package/dist-client/modeller/v3-links.d.ts +35 -1
  95. package/dist-client/modeller/v3-links.js +101 -8
  96. package/dist-client/modeller/v3-links.js.map +1 -1
  97. package/dist-client/modeller/v3-live.d.ts +31 -0
  98. package/dist-client/modeller/v3-live.js +58 -0
  99. package/dist-client/modeller/v3-live.js.map +1 -0
  100. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  101. package/dist-client/modeller/v3-maturity.js +317 -0
  102. package/dist-client/modeller/v3-maturity.js.map +1 -0
  103. package/dist-client/modeller/v3-palette.d.ts +7 -0
  104. package/dist-client/modeller/v3-palette.js +42 -0
  105. package/dist-client/modeller/v3-palette.js.map +1 -0
  106. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  107. package/dist-client/modeller/v3-preview-model.js +48 -0
  108. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  109. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  110. package/dist-client/modeller/v3-stage-model.js +121 -0
  111. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  112. package/dist-client/modeller/v3-thumbnail.js +6 -3
  113. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  114. package/dist-client/pages/figure-list-page.js +4 -4
  115. package/dist-client/pages/figure-list-page.js.map +1 -1
  116. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  117. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  118. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  119. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  120. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  121. package/dist-client/tsconfig.tsbuildinfo +1 -1
  122. package/package.json +6 -6
  123. package/test/v3-clips.test.ts +70 -0
  124. package/test/v3-controls.test.ts +68 -0
  125. package/test/v3-fields.test.ts +71 -0
  126. package/test/v3-gizmo.test.ts +3 -5
  127. package/test/v3-joint-fan.test.ts +210 -0
  128. package/test/v3-keys.test.ts +37 -0
  129. package/test/v3-layout.test.ts +78 -0
  130. package/test/v3-links.test.ts +89 -6
  131. package/test/v3-live.test.ts +99 -0
  132. package/test/v3-material.test.ts +6 -0
  133. package/test/v3-maturity.test.ts +268 -0
  134. package/test/v3-palette-icons.test.ts +22 -0
  135. package/test/v3-parts-wiring.test.ts +3 -1
  136. package/test/v3-preview.test.ts +42 -0
  137. package/test/v3-properties.test.ts +4 -2
  138. package/test/v3-scene-canvas.test.ts +85 -0
  139. package/test/v3-stage-model.test.ts +25 -0
  140. package/test/v3-theme.test.ts +16 -25
  141. package/test/v3-thumbnail.test.ts +29 -0
  142. package/test/v3-verdict.test.ts +12 -6
  143. package/test/v3-view.test.ts +20 -0
  144. package/translations/en.json +90 -6
  145. package/translations/ja.json +92 -8
  146. package/translations/ko.json +90 -6
  147. package/translations/ms.json +92 -8
  148. package/translations/zh.json +92 -8
@@ -1,307 +1,299 @@
1
- import { LitElement, html, css, type PropertyValues } from 'lit'
2
- import { customElement, property } from 'lit/decorators.js'
1
+ import { LitElement, html, css, nothing, unsafeCSS, type PropertyValues } from 'lit'
2
+ import { customElement, property, query } from 'lit/decorators.js'
3
3
  import { i18next } from '@operato/i18n'
4
- import * as THREE from 'three'
5
- import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
6
- import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
7
- import type { GizmoReach, GizmoResult } from './v3-gizmo.js'
8
- import { replaceV3Meshes, disposeV3Meshes, type V3RenderPart } from './v3-renderer.js'
4
+ import { create, Model, niceGridStep } from '@hatiolab/things-scene'
5
+
6
+ import type { GizmoReach } from './v3-gizmo.js'
7
+ import type { V3RenderPart } from './v3-renderer.js'
8
+ import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
9
+ import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
10
+ import { JOINT_FAN } from './v3-joint-fan.js'
11
+
12
+ /*
13
+ * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
14
+ *
15
+ * Each body figure-model evaluates stands on the board as a `figure-v3-part` component, so everything the V2 canvas
16
+ * had comes from the scene, not from code of our own: picking and the orange outline, the gizmo with its snapping and
17
+ * live readout, box and shift selection, the camera (damping, zoom toward the cursor, double-click to fit, the six
18
+ * view presets and their keys), the studio environment, the coordinate ground, lights and shadows, supersampling.
19
+ * An earlier canvas drew the bodies with three.js directly and lost all of that (user, 2026-09-24: "why nerf it?").
20
+ *
21
+ * What the gizmo writes back to a component comes out as `part-transformed` in the inputs' terms (`v3-stage-model`),
22
+ * and the editor turns it into one step of the history; the next evaluation puts the component where the inputs say,
23
+ * so a change a part cannot take (a fastened part moved) springs back.
24
+ */
25
+
26
+ interface SceneComponent {
27
+ state: Record<string, unknown>
28
+ set: (props: Record<string, unknown>) => void
29
+ dispose?: () => void
30
+ }
31
+ interface SceneLayer extends SceneComponent {
32
+ components?: SceneComponent[]
33
+ root?: { on?: (event: string, handler: (...args: unknown[]) => void) => void; selected?: SceneComponent[] }
34
+ on?: (event: string, handler: (...args: unknown[]) => void) => void
35
+ addComponent?: (component: SceneComponent) => void
36
+ removeComponent?: (component: SceneComponent) => void
37
+ invalidate?: () => void
38
+ fitCameraToModel?: () => void
39
+ app?: unknown
40
+ }
41
+ interface SceneHandle {
42
+ dispose?: () => void
43
+ resize?: () => void
44
+ fit?: (type?: string) => void
45
+ root?: SceneLayer
46
+ }
47
+
48
+ /* The fan's colour when the figure's palette names no accent. */
49
+ const FAN_COLOUR = '#ff8a00'
50
+
51
+ /* The 2D side of the board, which the author never sees on this page but the scene paints. */
52
+ const PAPER = '#ffffff'
9
53
 
10
- /** Renders evaluated V3 geometry directly. There is no parts/base or things-scene conversion. */
11
54
  @customElement('figure-v3-canvas')
12
55
  export class FigureV3Canvas extends LitElement {
13
56
  static styles = css`
14
57
  :host {
15
- display: block;
16
- min-width: 0;
17
- min-height: 0;
18
58
  position: relative;
19
- /*
20
- The working board is one step above the surface in both themes (UX, 2026-09-24): surface-container is 238
21
- in light and 31 in dark. The 3D background is read from this same value (paintBackground), so the
22
- first paint and the scene agree and there is no flash between them.
23
- */
24
- background: var(--md-sys-color-surface-container);
59
+ display: flex;
60
+ min-height: 0;
61
+ overflow: hidden;
62
+ background-color: ${unsafeCSS(WORKBENCH)};
25
63
  }
26
- canvas {
27
- display: block;
64
+ /* The scene sets position: relative inline on its target; the size is given so it still fills the host. */
65
+ div[stage] {
66
+ position: absolute;
67
+ inset: 0;
28
68
  width: 100%;
29
69
  height: 100%;
30
70
  }
31
- div[gizmo] {
32
- position: absolute;
33
- top: 8px;
34
- left: 8px;
35
- display: flex;
36
- gap: 2px;
37
- padding: 2px;
38
- border-radius: 6px;
39
- background: var(--md-sys-color-surface);
40
- border: 1px solid var(--md-sys-color-outline-variant);
41
- }
42
- div[gizmo] button {
43
- font: inherit;
44
- font-size: 12px;
45
- padding: 3px 8px;
46
- border: none;
47
- border-radius: 4px;
48
- background: none;
49
- color: var(--md-sys-color-on-surface);
50
- cursor: pointer;
51
- }
52
- div[gizmo] button[on] {
53
- background: var(--md-sys-color-secondary-container);
54
- color: var(--md-sys-color-on-secondary-container);
55
- }
56
- div[gizmo] button:disabled {
57
- opacity: 0.4;
58
- cursor: default;
59
- }
60
71
  p {
61
72
  position: absolute;
62
73
  inset: 45% 1rem auto;
74
+ margin: 0;
63
75
  text-align: center;
64
76
  pointer-events: none;
65
- color: var(--md-sys-color-on-surface-variant);
77
+ color: #f4f4f4;
66
78
  }
67
79
  `
68
80
  @property({ attribute: false }) parts: V3RenderPart[] = []
69
81
  @property({ attribute: false }) palette: Record<string, string> = {}
82
+ /** Which figure is open. Another one stands the board up again. */
70
83
  @property() context = ''
71
- /** The selected part, and what the gizmo may write for it (`gizmoReachOf`). Nothing reachable, no gizmo. */
72
84
  @property() selected = ''
85
+ /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
73
86
  @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }
74
- /** Move · turn · size — W · E · R, as in the V2 modeller. */
75
- @property() mode: 'translate' | 'rotate' | 'scale' = 'translate'
76
- private transform?: TransformControls
77
- /** What the gizmo holds: a stand-in at the body's origin, so the body's own matrix stays the evaluation's. */
78
- private readonly proxy = new THREE.Object3D()
79
- private dragStart?: { proxy: THREE.Matrix4; mesh: THREE.Matrix4; body: THREE.Mesh }
80
- /** The pointerup that ends a drag is not a pick — releasing off the body would otherwise deselect it. */
81
- private justDragged = false
82
- private renderer?: THREE.WebGLRenderer
83
- private readonly scene = new THREE.Scene()
84
- private readonly group = new THREE.Group()
85
- private readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000000)
86
- private controls?: OrbitControls
87
- private observer?: ResizeObserver
88
- private themeObserver?: MutationObserver
89
- private initialized = false
90
- private contextAtRender = ''
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
96
+
97
+ @query('div[stage]') private stage!: HTMLDivElement
98
+
99
+ private scene?: SceneHandle
100
+ private board?: Board & { depth: number }
101
+ private standingFor?: string
102
+ private watcher?: ResizeObserver
103
+ /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */
104
+ private writing = false
105
+
91
106
  render() {
92
- const any = this.selected && (this.reach.translate || this.reach.rotate || this.reach.scale)
93
- return html`<canvas aria-label="Figure 3D 모델" tabindex="0" @pointerup=${this.pick} @keydown=${this.key}></canvas
94
- >${any
95
- ? html`<div gizmo>
96
- ${(['translate', 'rotate', 'scale'] as const).map(
97
- (mode, i) => html`<button
98
- ?on=${this.mode === mode}
99
- ?disabled=${!this.reach[mode]}
100
- title=${`${i18next.t('figure.label.gizmo-' + mode)} (${'WER'[i]})`}
101
- @click=${() => (this.mode = mode)}
102
- >
103
- ${i18next.t('figure.label.gizmo-' + mode)}
104
- </button>`
105
- )}
106
- </div>`
107
- : null}${this.parts.length ? null : html`<p>도형을 추가해 모델링을 시작하세요.</p>`}`
108
- }
109
- private key = (event: KeyboardEvent) => {
110
- const mode = ({ w: 'translate', e: 'rotate', r: 'scale' } as const)[event.key.toLowerCase() as 'w' | 'e' | 'r']
111
- if (mode && this.reach[mode]) this.mode = mode
107
+ return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
112
108
  }
113
- protected firstUpdated() {
114
- this.initialize()
115
- }
116
- connectedCallback() {
117
- super.connectedCallback()
118
- if (this.hasUpdated) void this.updateComplete.then(() => this.initialize())
119
- }
120
- private initialize() {
121
- if (this.initialized || !this.isConnected) return
122
- const canvas = this.renderRoot.querySelector('canvas')!
123
- this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
124
- this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
125
- this.paintBackground()
126
- /*
127
- The app switches theme by the body's class (light · dark). Reading the colour once at start left a light
128
- board in the middle of a dark screen after every switch (seen 2026-09-24), so it is read again then.
129
- */
130
- this.themeObserver = new MutationObserver(() => {
131
- this.paintBackground()
132
- this.draw()
133
- })
134
- this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] })
135
- this.scene.add(this.group, new THREE.HemisphereLight(0xffffff, 0x62717d, 2.5))
136
- const light = new THREE.DirectionalLight(0xffffff, 3)
137
- light.position.set(1, 2, 3)
138
- this.scene.add(light)
139
- this.controls = new OrbitControls(this.camera, canvas)
140
- this.controls.addEventListener('change', this.draw)
141
- this.transform = new TransformControls(this.camera, canvas)
142
- this.transform.setSpace('world')
143
- this.transform.addEventListener('change', this.draw)
144
- this.transform.addEventListener('dragging-changed', this.dragging)
145
- this.transform.addEventListener('objectChange', this.follow)
146
- this.scene.add(this.proxy, this.transform.getHelper())
147
- this.observer = new ResizeObserver(() => {
148
- const r = this.getBoundingClientRect()
149
- if (r.width && r.height) {
150
- this.renderer!.setSize(r.width, r.height, false)
151
- this.camera.aspect = r.width / r.height
152
- this.camera.updateProjectionMatrix()
153
- this.draw()
154
- }
155
- })
156
- this.observer.observe(this)
157
- this.initialized = true
158
- this.updateGeometry(true)
109
+
110
+ firstUpdated() {
111
+ this.standUp()
112
+ this.watcher = new ResizeObserver(() => this.scene?.resize?.())
113
+ this.watcher.observe(this)
159
114
  }
115
+
160
116
  protected updated(changed: PropertyValues) {
161
- if (this.initialized && (changed.has('parts') || changed.has('palette') || changed.has('context')))
162
- this.updateGeometry(this.contextAtRender !== this.context)
163
- if (this.initialized && ['parts', 'selected', 'reach', 'mode', 'context'].some(key => changed.has(key))) this.placeGizmo()
117
+ if (!this.hasUpdated) return
118
+ if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()
119
+ if (changed.has('view')) this.applyView()
120
+ if (changed.has('selected')) this.showSelected()
164
121
  }
165
- /** Puts the gizmo on the selected body, in a mode it may write — or takes it away. */
166
- private placeGizmo() {
167
- const transform = this.transform
168
- if (!transform || this.dragStart) return
169
- if (!this.reach[this.mode]) {
170
- const first = (['translate', 'rotate', 'scale'] as const).find(mode => this.reach[mode])
171
- if (first && first !== this.mode) {
172
- this.mode = first
173
- return
174
- }
175
- }
176
- const body = this.group.children.find(child => child.name === this.selected) as THREE.Mesh | undefined
177
- if (!body || !this.reach[this.mode]) {
178
- transform.detach()
179
- this.draw()
180
- return
181
- }
182
- const position = new THREE.Vector3(),
183
- quaternion = new THREE.Quaternion(),
184
- scale = new THREE.Vector3()
185
- body.matrix.decompose(position, quaternion, scale)
186
- this.proxy.position.copy(position)
187
- this.proxy.quaternion.copy(quaternion)
188
- this.proxy.scale.set(1, 1, 1)
189
- this.proxy.updateMatrixWorld(true)
190
- transform.setMode(this.mode)
191
- /* Size follows the body's own axes; moving and turning read in the asset's axes, which the inputs are in. */
192
- transform.setSpace(this.mode === 'scale' ? 'local' : 'world')
193
- transform.attach(this.proxy)
194
- this.draw()
122
+
123
+ disconnectedCallback() {
124
+ super.disconnectedCallback()
125
+ this.watcher?.disconnect()
126
+ this.watcher = undefined
127
+ this.teardown()
195
128
  }
196
- private dragging = (event: { value: unknown }) => {
197
- const on = !!event.value
198
- if (this.controls) this.controls.enabled = !on
199
- const body = this.group.children.find(child => child.name === this.selected) as THREE.Mesh | undefined
200
- if (on && body) {
201
- this.proxy.updateMatrix()
202
- this.dragStart = { proxy: this.proxy.matrix.clone(), mesh: body.matrix.clone(), body }
203
- return
204
- }
205
- const start = this.dragStart
206
- this.dragStart = undefined
207
- if (!start) return
208
- /* Either listener may see the release first; the flag lasts past both, then clears. */
209
- this.justDragged = true
210
- setTimeout(() => (this.justDragged = false))
211
- const result: GizmoResult = {}
212
- if (this.mode === 'translate') result.position = this.proxy.position.toArray() as [number, number, number]
213
- if (this.mode === 'rotate') {
214
- /* rigid@1 composes Rx·Ry·Rz — three's 'XYZ' order — so the angles come back as the inputs mean them. */
215
- const euler = new THREE.Euler().setFromQuaternion(this.proxy.quaternion, 'XYZ')
216
- result.rotation = [euler.x, euler.y, euler.z].map(THREE.MathUtils.radToDeg) as [number, number, number]
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
217
146
  }
218
- if (this.mode === 'scale') result.scale = this.proxy.scale.toArray() as [number, number, number]
219
- this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: this.selected, result }, bubbles: true, composed: true }))
220
147
  }
221
- /** While dragging, the body follows the stand-in; the next evaluation replaces it with where the inputs put it. */
222
- private follow = () => {
223
- const start = this.dragStart
224
- if (!start) return
225
- this.proxy.updateMatrix()
226
- const delta = this.proxy.matrix.clone().multiply(start.proxy.clone().invert())
227
- start.body.matrix.copy(delta.multiply(start.mesh))
228
- start.body.matrixWorldNeedsUpdate = true
229
- this.group.updateMatrixWorld(true)
148
+
149
+ /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
150
+ fit() {
151
+ this.scene?.fit?.('ratio')
152
+ this.scene?.root?.fitCameraToModel?.()
230
153
  }
231
- private updateGeometry(fit: boolean) {
154
+
155
+ /**
156
+ * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
157
+ * one that has outgrown it -- standing up again loses the selection and the camera.
158
+ */
159
+ private standUp() {
160
+ if (!this.stage) return
161
+ const needed = boardFor(this.parts, this.board)
162
+ const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
163
+ if (this.scene && this.standingFor === this.context && !outgrown) {
164
+ this.sync()
165
+ return
166
+ }
167
+ this.teardown()
168
+ if (!this.parts.length) return
169
+ this.board = needed
232
170
  try {
233
- const wasEmpty = this.group.children.length === 0
234
- replaceV3Meshes(this.group, this.parts, this.palette)
235
- this.contextAtRender = this.context
236
- if (fit || wasEmpty) this.fit()
237
- this.draw()
171
+ this.scene = create({
172
+ target: this.stage,
173
+ model: {
174
+ type: 'model-layer',
175
+ width: needed.width,
176
+ height: needed.height,
177
+ depth: needed.depth,
178
+ threed: true,
179
+ /* Two samples a pixel: MSAA alone leaves steps on the silhouettes of one asset shown large (V2). */
180
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
181
+ ...sceneLook(this.view ?? DEFAULT_VIEW),
182
+ gridStep: niceGridStep(Math.max(needed.width, needed.height)),
183
+ sky: 'color',
184
+ fillStyle: PAPER,
185
+ components: this.components()
186
+ },
187
+ /* Editing mode: the camera view buttons, gizmo modes, help and keys V2's canvas had. */
188
+ mode: 1
189
+ }) as SceneHandle
190
+ this.standingFor = this.context
191
+ this.watchScene()
192
+ this.scene.resize?.()
193
+ this.fit()
194
+ this.showSelected()
238
195
  } catch (error) {
239
- // Do not leave another model's geometry visible after a failed context switch.
240
- if (this.contextAtRender !== this.context) {
241
- disposeV3Meshes(this.group)
242
- this.draw()
243
- }
244
196
  this.dispatchEvent(new CustomEvent('render-error', { detail: error, bubbles: true, composed: true }))
245
197
  }
246
198
  }
247
- fit() {
248
- const bounds = new THREE.Box3().setFromObject(this.group),
249
- center = new THREE.Vector3(),
250
- size = new THREE.Vector3()
251
- if (!bounds.isEmpty()) {
252
- bounds.getCenter(center)
253
- bounds.getSize(size)
199
+
200
+ private components(): Record<string, unknown>[] {
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
205
+ }
206
+
207
+ /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
208
+ private standing(): Map<string, SceneComponent> {
209
+ const found = new Map<string, SceneComponent>()
210
+ for (const component of this.scene?.root?.components ?? []) {
211
+ const name = component.state?.name
212
+ if (typeof name === 'string') found.set(name, component)
254
213
  }
255
- const radius = Math.max(size.length() / 2, 100),
256
- distance = (radius / Math.sin(THREE.MathUtils.degToRad(this.camera.fov / 2))) * 1.3
257
- this.camera.position.copy(center).add(new THREE.Vector3(1, 0.8, 1).normalize().multiplyScalar(distance))
258
- this.camera.near = Math.max(0.01, distance / 10000)
259
- this.camera.far = Math.max(10000, distance * 100)
260
- this.camera.updateProjectionMatrix()
261
- this.controls?.target.copy(center)
262
- this.controls?.update()
263
- this.draw()
214
+ return found
264
215
  }
265
- /**
266
- * The scene's background, taken from the host's computed background — the token resolved and normalised to
267
- * `rgb(r, g, b)`, which THREE.Color reads. The token's own text may use the space-separated form it does not.
268
- */
269
- private paintBackground() {
270
- const colour = getComputedStyle(this).backgroundColor
271
- if (colour && colour !== 'rgba(0, 0, 0, 0)' && colour !== 'transparent') this.scene.background = new THREE.Color(colour)
216
+
217
+ /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
218
+ private sync() {
219
+ const layer = this.scene?.root
220
+ if (!layer || !this.board) return
221
+ const wanted = new Map(this.components().map(c => [c.name as string, c]))
222
+ const standing = this.standing()
223
+ this.writing = true
224
+ try {
225
+ for (const [name, component] of standing) {
226
+ if (wanted.has(name)) continue
227
+ layer.removeComponent?.(component)
228
+ component.dispose?.()
229
+ }
230
+ for (const [name, state] of wanted) {
231
+ const found = standing.get(name)
232
+ if (found) {
233
+ const diff = changedKeys(found.state, state)
234
+ if (diff) found.set(diff)
235
+ continue
236
+ }
237
+ const made = Model.compile({ ...state }, layer.app as never) as SceneComponent | null
238
+ if (made) layer.addComponent?.(made)
239
+ }
240
+ } finally {
241
+ this.writing = false
242
+ }
243
+ layer.invalidate?.()
272
244
  }
273
- private draw = () => {
274
- if (this.renderer) this.renderer.render(this.scene, this.camera)
245
+
246
+ /** Selection and drags, told out by the part's name -- never by its place in the list, which may change meanwhile. */
247
+ private watchScene() {
248
+ const layer = this.scene?.root
249
+ const root = layer?.root
250
+ if (!layer || !root?.on || this.viewOnly) return
251
+ root.on('selected', (after: unknown) => {
252
+ const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
253
+ const id = typeof name === 'string' ? name : ''
254
+ if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
255
+ })
256
+ /*
257
+ A change comes up to the board with the component in the last argument's `origin` (the scene appends
258
+ { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
259
+ */
260
+ layer.on?.('change', (...args: unknown[]) => {
261
+ if (this.writing) return
262
+ const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
263
+ const name = origin?.state?.name
264
+ const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined
265
+ if (!part || !this.board) return
266
+ const result = gizmoResultOf(part, origin!.state, this.board)
267
+ if (!result.position && !result.rotation && !result.scale) return
268
+ this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
269
+ /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
270
+ setTimeout(() => this.sync())
271
+ })
275
272
  }
276
- private pick = (event: PointerEvent) => {
277
- if (this.justDragged || this.transform?.dragging) return
278
- const r = (event.currentTarget as HTMLCanvasElement).getBoundingClientRect(),
279
- ray = new THREE.Raycaster()
280
- ray.setFromCamera(
281
- new THREE.Vector2(((event.clientX - r.left) / r.width) * 2 - 1, (-(event.clientY - r.top) / r.height) * 2 + 1),
282
- this.camera
283
- )
284
- const hit = ray.intersectObjects(this.group.children)[0]
285
- this.dispatchEvent(
286
- new CustomEvent('part-selected', { detail: hit?.object.name ?? '', bubbles: true, composed: true })
287
- )
273
+
274
+ private showSelected() {
275
+ const layer = this.scene?.root
276
+ const root = layer?.root
277
+ if (!layer || !root) return
278
+ const component = this.selected ? this.standing().get(this.selected) : undefined
279
+ const now = root.selected ?? []
280
+ if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return
281
+ root.selected = component ? [component] : []
282
+ layer.invalidate?.()
288
283
  }
289
- thumbnail() {
290
- this.draw()
291
- return this.renderer?.domElement.toDataURL('image/png')
284
+
285
+ /** View settings go into the scene as they are: they are the board's own state names. Nothing is stood up again. */
286
+ private applyView() {
287
+ const layer = this.scene?.root
288
+ if (!layer) return
289
+ layer.set(sceneLook(this.view ?? DEFAULT_VIEW))
290
+ layer.invalidate?.()
292
291
  }
293
- disconnectedCallback() {
294
- this.observer?.disconnect()
295
- this.themeObserver?.disconnect()
296
- this.controls?.dispose()
297
- this.transform?.detach()
298
- this.transform?.dispose()
299
- this.transform = undefined
300
- disposeV3Meshes(this.group)
301
- this.renderer?.dispose()
302
- this.renderer = undefined
303
- this.scene.clear()
304
- this.initialized = false
305
- super.disconnectedCallback()
292
+
293
+ private teardown() {
294
+ this.scene?.dispose?.()
295
+ this.scene = undefined
296
+ this.standingFor = undefined
297
+ this.board = undefined
306
298
  }
307
299
  }
@@ -0,0 +1,89 @@
1
+ import { driverValue } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3Driver, V3InputChange } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * The figure's clips -- V2's animations: controls played by keys over time, grouped into clips that each run on one
6
+ * clock. Kept apart from the element so the reading is tested without a browser.
7
+ */
8
+ export interface ClipKey {
9
+ seconds: number
10
+ value: number
11
+ }
12
+ export interface ClipTrack {
13
+ state: string
14
+ label: string
15
+ unit: string
16
+ min: number
17
+ max: number
18
+ keys: ClipKey[]
19
+ interpolation: 'linear' | 'step'
20
+ loop: 'wrap' | 'hold'
21
+ accumulates: boolean
22
+ }
23
+ export interface Clip {
24
+ name: string
25
+ duration: number
26
+ tracks: ClipTrack[]
27
+ }
28
+
29
+ const round = (n: number) => Math.round(n * 1000) / 1000
30
+
31
+ export function clipsOf(asset: V3Asset): Clip[] {
32
+ const inputs = new Map(asset.document.model.inputs.map(i => [i.id, i]))
33
+ const clips = new Map<string, Clip>()
34
+ for (const d of asset.drivers ?? []) {
35
+ const input = inputs.get(d.state)
36
+ const clip = clips.get(d.clip) ?? { name: d.clip, duration: d.time.duration, tracks: [] }
37
+ clip.tracks.push({
38
+ state: d.state,
39
+ label: asset.stateInputs?.[d.state]?.label ?? d.state,
40
+ unit: input?.unit ?? '',
41
+ min: input?.min ?? 0,
42
+ max: input?.max ?? 0,
43
+ keys: d.keys.map(k => ({ seconds: round(k.at * d.time.duration), value: k.value })),
44
+ interpolation: d.interpolation,
45
+ loop: d.loop,
46
+ accumulates: d.accumulates
47
+ })
48
+ clips.set(d.clip, clip)
49
+ }
50
+ return [...clips.values()]
51
+ }
52
+
53
+ /** The controls no clip plays yet -- the ones a new track can take. One driver per control. */
54
+ export function undrivenOf(asset: V3Asset): string[] {
55
+ const driven = new Set((asset.drivers ?? []).map(d => d.state))
56
+ return asset.document.model.inputs.filter(i => i.role === 'state' && !driven.has(i.id)).map(i => i.id)
57
+ }
58
+
59
+ /** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
60
+ export function poseAt(asset: V3Asset, clip: string, seconds: number): V3InputChange[] {
61
+ const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))
62
+ return (asset.drivers ?? [])
63
+ .filter((d: V3Driver) => d.clip === clip)
64
+ .map(d => {
65
+ const unit = units.get(d.state)!
66
+ return { kind: 'set-state' as const, input: d.state, unit, value: driverValue(d, seconds, unit === 'deg' || unit === 'rad' ? unit : 'other') }
67
+ })
68
+ }
69
+
70
+ /** A key added at a moment, replacing one already there; the keys stay in time order. */
71
+ export function withKey(keys: readonly ClipKey[], key: ClipKey): ClipKey[] {
72
+ return [...keys.filter(k => k.seconds !== key.seconds), key].sort((a, b) => a.seconds - b.seconds)
73
+ }
74
+
75
+ /** A track's keys without one; a track keeps at least its two ends, as the format asks. */
76
+ export function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] | null {
77
+ if (keys.length <= 2 || index === 0) return null
78
+ return keys.filter((_, i) => i !== index)
79
+ }
80
+
81
+ /** The edit that writes a track back. */
82
+ export const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<ClipTrack, 'keys' | 'loop' | 'interpolation'>> = {}) => ({
83
+ kind: 'set-driver' as const,
84
+ state: track.state,
85
+ clip: clip.name,
86
+ keys: patch.keys ?? track.keys,
87
+ loop: patch.loop ?? track.loop,
88
+ interpolation: patch.interpolation ?? track.interpolation
89
+ })