@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.
- package/client/modeller/angle-dial.ts +70 -0
- package/client/modeller/figure-settings.ts +328 -0
- package/client/modeller/figure-v3-capability.ts +137 -0
- package/client/modeller/figure-v3-clips.ts +253 -0
- package/client/modeller/figure-v3-controls.ts +212 -0
- package/client/modeller/figure-v3-links.ts +61 -81
- package/client/modeller/figure-v3-material.ts +108 -35
- package/client/modeller/figure-v3-parts.ts +96 -3
- package/client/modeller/figure-v3-preview.ts +326 -0
- package/client/modeller/figure-v3-properties.ts +29 -57
- package/client/modeller/figure-v3-verdict.ts +598 -60
- package/client/modeller/figure-view.ts +152 -0
- package/client/modeller/side-panel-styles.ts +326 -0
- package/client/modeller/v3-canvas.ts +221 -263
- package/client/modeller/v3-clips.ts +89 -0
- package/client/modeller/v3-controls.ts +54 -0
- package/client/modeller/v3-editor.ts +531 -223
- package/client/modeller/v3-fields.ts +62 -0
- package/client/modeller/v3-help.ts +41 -0
- package/client/modeller/v3-keys.ts +34 -0
- package/client/modeller/v3-links.ts +93 -9
- package/client/modeller/v3-live.ts +79 -0
- package/client/modeller/v3-maturity.ts +415 -0
- package/client/modeller/v3-palette.ts +45 -0
- package/client/modeller/v3-preview-model.ts +54 -0
- package/client/modeller/v3-stage-model.ts +159 -0
- package/client/pages/figure-list-page.ts +4 -4
- package/client/pages/figure-v3-modeller-page.ts +66 -134
- package/dist-client/modeller/angle-dial.d.ts +32 -0
- package/dist-client/modeller/angle-dial.js +41 -0
- package/dist-client/modeller/angle-dial.js.map +1 -0
- package/dist-client/modeller/figure-settings.d.ts +39 -0
- package/dist-client/modeller/figure-settings.js +317 -0
- package/dist-client/modeller/figure-settings.js.map +1 -0
- package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
- package/dist-client/modeller/figure-v3-capability.js +141 -0
- package/dist-client/modeller/figure-v3-capability.js.map +1 -0
- package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
- package/dist-client/modeller/figure-v3-clips.js +268 -0
- package/dist-client/modeller/figure-v3-clips.js.map +1 -0
- package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
- package/dist-client/modeller/figure-v3-controls.js +226 -0
- package/dist-client/modeller/figure-v3-controls.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +5 -2
- package/dist-client/modeller/figure-v3-links.js +64 -82
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +5 -2
- package/dist-client/modeller/figure-v3-material.js +109 -35
- package/dist-client/modeller/figure-v3-material.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
- package/dist-client/modeller/figure-v3-parts.js +88 -3
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
- package/dist-client/modeller/figure-v3-preview.js +322 -0
- package/dist-client/modeller/figure-v3-preview.js.map +1 -0
- package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
- package/dist-client/modeller/figure-v3-properties.js +29 -57
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
- package/dist-client/modeller/figure-v3-verdict.js +579 -59
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +118 -0
- package/dist-client/modeller/figure-view.js +78 -0
- package/dist-client/modeller/figure-view.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.d.ts +23 -0
- package/dist-client/modeller/side-panel-styles.js +326 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +29 -37
- package/dist-client/modeller/v3-canvas.js +188 -260
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +39 -0
- package/dist-client/modeller/v3-clips.js +58 -0
- package/dist-client/modeller/v3-clips.js.map +1 -0
- package/dist-client/modeller/v3-controls.d.ts +28 -0
- package/dist-client/modeller/v3-controls.js +28 -0
- package/dist-client/modeller/v3-controls.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +58 -1
- package/dist-client/modeller/v3-editor.js +555 -218
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +30 -0
- package/dist-client/modeller/v3-fields.js +46 -0
- package/dist-client/modeller/v3-fields.js.map +1 -0
- package/dist-client/modeller/v3-help.d.ts +18 -0
- package/dist-client/modeller/v3-help.js +37 -0
- package/dist-client/modeller/v3-help.js.map +1 -0
- package/dist-client/modeller/v3-keys.d.ts +14 -0
- package/dist-client/modeller/v3-keys.js +23 -0
- package/dist-client/modeller/v3-keys.js.map +1 -0
- package/dist-client/modeller/v3-links.d.ts +30 -1
- package/dist-client/modeller/v3-links.js +94 -8
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +31 -0
- package/dist-client/modeller/v3-live.js +58 -0
- package/dist-client/modeller/v3-live.js.map +1 -0
- package/dist-client/modeller/v3-maturity.d.ts +120 -0
- package/dist-client/modeller/v3-maturity.js +317 -0
- package/dist-client/modeller/v3-maturity.js.map +1 -0
- package/dist-client/modeller/v3-palette.d.ts +7 -0
- package/dist-client/modeller/v3-palette.js +42 -0
- package/dist-client/modeller/v3-palette.js.map +1 -0
- package/dist-client/modeller/v3-preview-model.d.ts +32 -0
- package/dist-client/modeller/v3-preview-model.js +48 -0
- package/dist-client/modeller/v3-preview-model.js.map +1 -0
- package/dist-client/modeller/v3-stage-model.d.ts +31 -0
- package/dist-client/modeller/v3-stage-model.js +121 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -0
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
- package/dist-client/pages/figure-v3-modeller-page.js +64 -136
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
- package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/v3-clips.test.ts +70 -0
- package/test/v3-controls.test.ts +68 -0
- package/test/v3-fields.test.ts +62 -0
- package/test/v3-gizmo.test.ts +3 -5
- package/test/v3-keys.test.ts +37 -0
- package/test/v3-layout.test.ts +78 -0
- package/test/v3-links.test.ts +77 -6
- package/test/v3-live.test.ts +99 -0
- package/test/v3-material.test.ts +6 -0
- package/test/v3-maturity.test.ts +268 -0
- package/test/v3-parts-wiring.test.ts +3 -1
- package/test/v3-preview.test.ts +42 -0
- package/test/v3-properties.test.ts +4 -2
- package/test/v3-scene-canvas.test.ts +85 -0
- package/test/v3-stage-model.test.ts +25 -0
- package/test/v3-theme.test.ts +16 -25
- package/test/v3-verdict.test.ts +12 -6
- package/test/v3-view.test.ts +20 -0
- package/translations/en.json +84 -5
- package/translations/ja.json +86 -7
- package/translations/ko.json +84 -5
- package/translations/ms.json +86 -7
- 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
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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:
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
private
|
|
77
|
-
|
|
78
|
-
private
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
private
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
162
|
-
|
|
163
|
-
if (
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
this.
|
|
207
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
this.
|
|
296
|
-
this.
|
|
297
|
-
this.
|
|
298
|
-
this.
|
|
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
|
+
})
|