@things-factory/figure-ui 10.1.46 → 10.1.47

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 (138) 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 +221 -263
  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 +531 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +93 -9
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +5 -2
  45. package/dist-client/modeller/figure-v3-links.js +64 -82
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +555 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +30 -1
  90. package/dist-client/modeller/v3-links.js +94 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +77 -6
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +84 -5
  135. package/translations/ja.json +86 -7
  136. package/translations/ko.json +84 -5
  137. package/translations/ms.json +86 -7
  138. package/translations/zh.json +86 -7
@@ -1,307 +1,265 @@
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
+
11
+ /*
12
+ * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
13
+ *
14
+ * Each body figure-model evaluates stands on the board as a `figure-v3-part` component, so everything the V2 canvas
15
+ * had comes from the scene, not from code of our own: picking and the orange outline, the gizmo with its snapping and
16
+ * live readout, box and shift selection, the camera (damping, zoom toward the cursor, double-click to fit, the six
17
+ * view presets and their keys), the studio environment, the coordinate ground, lights and shadows, supersampling.
18
+ * An earlier canvas drew the bodies with three.js directly and lost all of that (user, 2026-09-24: "why nerf it?").
19
+ *
20
+ * What the gizmo writes back to a component comes out as `part-transformed` in the inputs' terms (`v3-stage-model`),
21
+ * and the editor turns it into one step of the history; the next evaluation puts the component where the inputs say,
22
+ * so a change a part cannot take (a fastened part moved) springs back.
23
+ */
24
+
25
+ interface SceneComponent {
26
+ state: Record<string, unknown>
27
+ set: (props: Record<string, unknown>) => void
28
+ dispose?: () => void
29
+ }
30
+ interface SceneLayer extends SceneComponent {
31
+ components?: SceneComponent[]
32
+ root?: { on?: (event: string, handler: (...args: unknown[]) => void) => void; selected?: SceneComponent[] }
33
+ on?: (event: string, handler: (...args: unknown[]) => void) => void
34
+ addComponent?: (component: SceneComponent) => void
35
+ removeComponent?: (component: SceneComponent) => void
36
+ invalidate?: () => void
37
+ fitCameraToModel?: () => void
38
+ app?: unknown
39
+ }
40
+ interface SceneHandle {
41
+ dispose?: () => void
42
+ resize?: () => void
43
+ fit?: (type?: string) => void
44
+ root?: SceneLayer
45
+ }
46
+
47
+ /* The 2D side of the board, which the author never sees on this page but the scene paints. */
48
+ const PAPER = '#ffffff'
9
49
 
10
- /** Renders evaluated V3 geometry directly. There is no parts/base or things-scene conversion. */
11
50
  @customElement('figure-v3-canvas')
12
51
  export class FigureV3Canvas extends LitElement {
13
52
  static styles = css`
14
53
  :host {
15
- display: block;
16
- min-width: 0;
17
- min-height: 0;
18
54
  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);
55
+ display: flex;
56
+ min-height: 0;
57
+ overflow: hidden;
58
+ background-color: ${unsafeCSS(WORKBENCH)};
25
59
  }
26
- canvas {
27
- display: block;
60
+ /* The scene sets position: relative inline on its target; the size is given so it still fills the host. */
61
+ div[stage] {
62
+ position: absolute;
63
+ inset: 0;
28
64
  width: 100%;
29
65
  height: 100%;
30
66
  }
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
67
  p {
61
68
  position: absolute;
62
69
  inset: 45% 1rem auto;
70
+ margin: 0;
63
71
  text-align: center;
64
72
  pointer-events: none;
65
- color: var(--md-sys-color-on-surface-variant);
73
+ color: #f4f4f4;
66
74
  }
67
75
  `
68
76
  @property({ attribute: false }) parts: V3RenderPart[] = []
69
77
  @property({ attribute: false }) palette: Record<string, string> = {}
78
+ /** Which figure is open. Another one stands the board up again. */
70
79
  @property() context = ''
71
- /** The selected part, and what the gizmo may write for it (`gizmoReachOf`). Nothing reachable, no gizmo. */
72
80
  @property() selected = ''
81
+ /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
73
82
  @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 = ''
83
+ @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW
84
+
85
+ @query('div[stage]') private stage!: HTMLDivElement
86
+
87
+ private scene?: SceneHandle
88
+ private board?: Board & { depth: number }
89
+ private standingFor?: string
90
+ private watcher?: ResizeObserver
91
+ /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */
92
+ private writing = false
93
+
91
94
  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
112
- }
113
- protected firstUpdated() {
114
- this.initialize()
95
+ return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
115
96
  }
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)
97
+
98
+ firstUpdated() {
99
+ this.standUp()
100
+ this.watcher = new ResizeObserver(() => this.scene?.resize?.())
101
+ this.watcher.observe(this)
159
102
  }
103
+
160
104
  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()
105
+ if (!this.hasUpdated) return
106
+ if (changed.has('parts') || changed.has('palette') || changed.has('context')) this.standUp()
107
+ if (changed.has('view')) this.applyView()
108
+ if (changed.has('selected')) this.showSelected()
164
109
  }
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()
110
+
111
+ disconnectedCallback() {
112
+ super.disconnectedCallback()
113
+ this.watcher?.disconnect()
114
+ this.watcher = undefined
115
+ this.teardown()
195
116
  }
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 }
117
+
118
+ /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
119
+ fit() {
120
+ this.scene?.fit?.('ratio')
121
+ this.scene?.root?.fitCameraToModel?.()
122
+ }
123
+
124
+ /**
125
+ * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
126
+ * one that has outgrown it -- standing up again loses the selection and the camera.
127
+ */
128
+ private standUp() {
129
+ if (!this.stage) return
130
+ const needed = boardFor(this.parts, this.board)
131
+ const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
132
+ if (this.scene && this.standingFor === this.context && !outgrown) {
133
+ this.sync()
203
134
  return
204
135
  }
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]
217
- }
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
- }
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)
230
- }
231
- private updateGeometry(fit: boolean) {
136
+ this.teardown()
137
+ if (!this.parts.length) return
138
+ this.board = needed
232
139
  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()
140
+ this.scene = create({
141
+ target: this.stage,
142
+ model: {
143
+ type: 'model-layer',
144
+ width: needed.width,
145
+ height: needed.height,
146
+ depth: needed.depth,
147
+ threed: true,
148
+ /* Two samples a pixel: MSAA alone leaves steps on the silhouettes of one asset shown large (V2). */
149
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
150
+ ...sceneLook(this.view ?? DEFAULT_VIEW),
151
+ gridStep: niceGridStep(Math.max(needed.width, needed.height)),
152
+ sky: 'color',
153
+ fillStyle: PAPER,
154
+ components: this.components()
155
+ },
156
+ /* Editing mode: the camera view buttons, gizmo modes, help and keys V2's canvas had. */
157
+ mode: 1
158
+ }) as SceneHandle
159
+ this.standingFor = this.context
160
+ this.watchScene()
161
+ this.scene.resize?.()
162
+ this.fit()
163
+ this.showSelected()
238
164
  } 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
165
  this.dispatchEvent(new CustomEvent('render-error', { detail: error, bubbles: true, composed: true }))
245
166
  }
246
167
  }
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)
168
+
169
+ 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)
171
+ }
172
+
173
+ /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
174
+ private standing(): Map<string, SceneComponent> {
175
+ const found = new Map<string, SceneComponent>()
176
+ for (const component of this.scene?.root?.components ?? []) {
177
+ const name = component.state?.name
178
+ if (typeof name === 'string') found.set(name, component)
254
179
  }
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()
180
+ return found
264
181
  }
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)
182
+
183
+ /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
184
+ private sync() {
185
+ const layer = this.scene?.root
186
+ if (!layer || !this.board) return
187
+ const wanted = new Map(this.components().map(c => [c.name as string, c]))
188
+ const standing = this.standing()
189
+ this.writing = true
190
+ try {
191
+ for (const [name, component] of standing) {
192
+ if (wanted.has(name)) continue
193
+ layer.removeComponent?.(component)
194
+ component.dispose?.()
195
+ }
196
+ for (const [name, state] of wanted) {
197
+ const found = standing.get(name)
198
+ if (found) {
199
+ const diff = changedKeys(found.state, state)
200
+ if (diff) found.set(diff)
201
+ continue
202
+ }
203
+ const made = Model.compile({ ...state }, layer.app as never) as SceneComponent | null
204
+ if (made) layer.addComponent?.(made)
205
+ }
206
+ } finally {
207
+ this.writing = false
208
+ }
209
+ layer.invalidate?.()
272
210
  }
273
- private draw = () => {
274
- if (this.renderer) this.renderer.render(this.scene, this.camera)
211
+
212
+ /** Selection and drags, told out by the part's name -- never by its place in the list, which may change meanwhile. */
213
+ private watchScene() {
214
+ const layer = this.scene?.root
215
+ const root = layer?.root
216
+ if (!layer || !root?.on) return
217
+ root.on('selected', (after: unknown) => {
218
+ const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
219
+ const id = typeof name === 'string' ? name : ''
220
+ if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
221
+ })
222
+ /*
223
+ A change comes up to the board with the component in the last argument's `origin` (the scene appends
224
+ { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
225
+ */
226
+ layer.on?.('change', (...args: unknown[]) => {
227
+ if (this.writing) return
228
+ const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
229
+ const name = origin?.state?.name
230
+ const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined
231
+ if (!part || !this.board) return
232
+ const result = gizmoResultOf(part, origin!.state, this.board)
233
+ if (!result.position && !result.rotation && !result.scale) return
234
+ this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
235
+ /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
236
+ setTimeout(() => this.sync())
237
+ })
275
238
  }
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
- )
239
+
240
+ private showSelected() {
241
+ const layer = this.scene?.root
242
+ const root = layer?.root
243
+ if (!layer || !root) return
244
+ const component = this.selected ? this.standing().get(this.selected) : undefined
245
+ const now = root.selected ?? []
246
+ if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return
247
+ root.selected = component ? [component] : []
248
+ layer.invalidate?.()
288
249
  }
289
- thumbnail() {
290
- this.draw()
291
- return this.renderer?.domElement.toDataURL('image/png')
250
+
251
+ /** View settings go into the scene as they are: they are the board's own state names. Nothing is stood up again. */
252
+ private applyView() {
253
+ const layer = this.scene?.root
254
+ if (!layer) return
255
+ layer.set(sceneLook(this.view ?? DEFAULT_VIEW))
256
+ layer.invalidate?.()
292
257
  }
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()
258
+
259
+ private teardown() {
260
+ this.scene?.dispose?.()
261
+ this.scene = undefined
262
+ this.standingFor = undefined
263
+ this.board = undefined
306
264
  }
307
265
  }
@@ -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
+ })