@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.
- 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 +254 -262
- package/client/modeller/v3-clips.ts +89 -0
- package/client/modeller/v3-controls.ts +54 -0
- package/client/modeller/v3-editor.ts +566 -223
- package/client/modeller/v3-fields.ts +84 -0
- package/client/modeller/v3-help.ts +41 -0
- package/client/modeller/v3-joint-fan.ts +292 -0
- package/client/modeller/v3-keys.ts +34 -0
- package/client/modeller/v3-links.ts +101 -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/modeller/v3-thumbnail.ts +8 -4
- 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 +4 -1
- 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 +4 -1
- 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 +1 -1
- 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 +44 -37
- package/dist-client/modeller/v3-canvas.js +230 -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 +73 -1
- package/dist-client/modeller/v3-editor.js +593 -218
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +39 -0
- package/dist-client/modeller/v3-fields.js +69 -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-joint-fan.d.ts +9 -0
- package/dist-client/modeller/v3-joint-fan.js +264 -0
- package/dist-client/modeller/v3-joint-fan.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 +35 -1
- package/dist-client/modeller/v3-links.js +101 -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/modeller/v3-thumbnail.js +6 -3
- package/dist-client/modeller/v3-thumbnail.js.map +1 -1
- 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 +71 -0
- package/test/v3-gizmo.test.ts +3 -5
- package/test/v3-joint-fan.test.ts +210 -0
- package/test/v3-keys.test.ts +37 -0
- package/test/v3-layout.test.ts +78 -0
- package/test/v3-links.test.ts +89 -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-palette-icons.test.ts +22 -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-thumbnail.test.ts +29 -0
- package/test/v3-verdict.test.ts +12 -6
- package/test/v3-view.test.ts +20 -0
- package/translations/en.json +90 -6
- package/translations/ja.json +92 -8
- package/translations/ko.json +90 -6
- package/translations/ms.json +92 -8
- 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
|
|
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
|
+
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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:
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
private
|
|
87
|
-
private
|
|
88
|
-
private
|
|
89
|
-
private
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
162
|
-
|
|
163
|
-
if (
|
|
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
|
-
|
|
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()
|
|
122
|
+
|
|
123
|
+
disconnectedCallback() {
|
|
124
|
+
super.disconnectedCallback()
|
|
125
|
+
this.watcher?.disconnect()
|
|
126
|
+
this.watcher = undefined
|
|
127
|
+
this.teardown()
|
|
195
128
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
+
})
|