@things-factory/figure-ui 10.1.39 → 10.1.42
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/graphql/index.ts +1 -0
- package/client/modeller/figure-v3-material.ts +248 -0
- package/client/modeller/figure-v3-parts.ts +16 -1
- package/client/modeller/figure-v3-properties.ts +254 -0
- package/client/modeller/modeller-route.ts +25 -0
- package/client/modeller/v3-align.ts +84 -0
- package/client/modeller/v3-canvas.ts +167 -5
- package/client/modeller/v3-editor.ts +207 -47
- package/client/modeller/v3-figure-facts.ts +53 -0
- package/client/modeller/v3-gizmo.ts +80 -0
- package/client/modeller/v3-occupancy.ts +6 -2
- package/client/pages/figure-list-page.ts +5 -3
- package/client/pages/figure-modeller-page.ts +9 -0
- package/client/pages/figure-v3-modeller-page.ts +68 -13
- package/client/types.ts +2 -0
- package/dist-client/graphql/index.js +1 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +36 -0
- package/dist-client/modeller/figure-v3-material.js +249 -0
- package/dist-client/modeller/figure-v3-material.js.map +1 -0
- package/dist-client/modeller/figure-v3-parts.js +16 -1
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
- package/dist-client/modeller/figure-v3-properties.js +259 -0
- package/dist-client/modeller/figure-v3-properties.js.map +1 -0
- package/dist-client/modeller/modeller-route.d.ts +16 -0
- package/dist-client/modeller/modeller-route.js +27 -0
- package/dist-client/modeller/modeller-route.js.map +1 -0
- package/dist-client/modeller/v3-align.d.ts +26 -0
- package/dist-client/modeller/v3-align.js +65 -0
- package/dist-client/modeller/v3-align.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +24 -0
- package/dist-client/modeller/v3-canvas.js +181 -5
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +24 -0
- package/dist-client/modeller/v3-editor.js +218 -44
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
- package/dist-client/modeller/v3-figure-facts.js +34 -0
- package/dist-client/modeller/v3-figure-facts.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +48 -0
- package/dist-client/modeller/v3-gizmo.js +40 -0
- package/dist-client/modeller/v3-gizmo.js.map +1 -0
- package/dist-client/modeller/v3-occupancy.d.ts +5 -1
- package/dist-client/modeller/v3-occupancy.js +4 -2
- package/dist-client/modeller/v3-occupancy.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +1 -0
- package/dist-client/pages/figure-list-page.js +5 -3
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.js +9 -0
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +16 -2
- package/dist-client/pages/figure-v3-modeller-page.js +78 -22
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +2 -0
- package/dist-client/types.js.map +1 -1
- package/package.json +4 -4
- package/test/modeller-route.test.ts +42 -0
- package/test/v3-align.test.ts +64 -0
- package/test/v3-gizmo.test.ts +97 -0
- package/test/v3-material.test.ts +68 -0
- package/test/v3-parts-wiring.test.ts +56 -0
- package/test/v3-properties.test.ts +93 -0
- package/test/v3-theme.test.ts +67 -0
- package/translations/en.json +11 -0
- package/translations/ja.json +11 -0
- package/translations/ko.json +11 -0
- package/translations/ms.json +11 -0
- package/translations/zh.json +11 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { LitElement, html, css, type PropertyValues } from 'lit'
|
|
2
2
|
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
3
4
|
import * as THREE from 'three'
|
|
4
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'
|
|
5
8
|
import { replaceV3Meshes, disposeV3Meshes, type V3RenderPart } from './v3-renderer.js'
|
|
6
9
|
|
|
7
10
|
/** Renders evaluated V3 geometry directly. There is no parts/base or things-scene conversion. */
|
|
@@ -13,35 +16,99 @@ export class FigureV3Canvas extends LitElement {
|
|
|
13
16
|
min-width: 0;
|
|
14
17
|
min-height: 0;
|
|
15
18
|
position: relative;
|
|
16
|
-
|
|
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);
|
|
17
25
|
}
|
|
18
26
|
canvas {
|
|
19
27
|
display: block;
|
|
20
28
|
width: 100%;
|
|
21
29
|
height: 100%;
|
|
22
30
|
}
|
|
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
|
+
}
|
|
23
60
|
p {
|
|
24
61
|
position: absolute;
|
|
25
62
|
inset: 45% 1rem auto;
|
|
26
63
|
text-align: center;
|
|
27
64
|
pointer-events: none;
|
|
28
|
-
color:
|
|
65
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
29
66
|
}
|
|
30
67
|
`
|
|
31
68
|
@property({ attribute: false }) parts: V3RenderPart[] = []
|
|
32
69
|
@property({ attribute: false }) palette: Record<string, string> = {}
|
|
33
70
|
@property() context = ''
|
|
71
|
+
/** The selected part, and what the gizmo may write for it (`gizmoReachOf`). Nothing reachable, no gizmo. */
|
|
72
|
+
@property() selected = ''
|
|
73
|
+
@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
|
|
34
82
|
private renderer?: THREE.WebGLRenderer
|
|
35
83
|
private readonly scene = new THREE.Scene()
|
|
36
84
|
private readonly group = new THREE.Group()
|
|
37
85
|
private readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000000)
|
|
38
86
|
private controls?: OrbitControls
|
|
39
87
|
private observer?: ResizeObserver
|
|
88
|
+
private themeObserver?: MutationObserver
|
|
40
89
|
private initialized = false
|
|
41
90
|
private contextAtRender = ''
|
|
42
91
|
render() {
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
45
112
|
}
|
|
46
113
|
protected firstUpdated() {
|
|
47
114
|
this.initialize()
|
|
@@ -55,13 +122,28 @@ export class FigureV3Canvas extends LitElement {
|
|
|
55
122
|
const canvas = this.renderRoot.querySelector('canvas')!
|
|
56
123
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
|
|
57
124
|
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
|
|
58
|
-
this.
|
|
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'] })
|
|
59
135
|
this.scene.add(this.group, new THREE.HemisphereLight(0xffffff, 0x62717d, 2.5))
|
|
60
136
|
const light = new THREE.DirectionalLight(0xffffff, 3)
|
|
61
137
|
light.position.set(1, 2, 3)
|
|
62
138
|
this.scene.add(light)
|
|
63
139
|
this.controls = new OrbitControls(this.camera, canvas)
|
|
64
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())
|
|
65
147
|
this.observer = new ResizeObserver(() => {
|
|
66
148
|
const r = this.getBoundingClientRect()
|
|
67
149
|
if (r.width && r.height) {
|
|
@@ -78,6 +160,73 @@ export class FigureV3Canvas extends LitElement {
|
|
|
78
160
|
protected updated(changed: PropertyValues) {
|
|
79
161
|
if (this.initialized && (changed.has('parts') || changed.has('palette') || changed.has('context')))
|
|
80
162
|
this.updateGeometry(this.contextAtRender !== this.context)
|
|
163
|
+
if (this.initialized && ['parts', 'selected', 'reach', 'mode', 'context'].some(key => changed.has(key))) this.placeGizmo()
|
|
164
|
+
}
|
|
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()
|
|
195
|
+
}
|
|
196
|
+
private dragging = (event: { value: unknown }) => {
|
|
197
|
+
const on = !!event.value
|
|
198
|
+
if (this.controls) this.controls.enabled = !on
|
|
199
|
+
const body = this.group.children.find(child => child.name === this.selected) as THREE.Mesh | undefined
|
|
200
|
+
if (on && body) {
|
|
201
|
+
this.proxy.updateMatrix()
|
|
202
|
+
this.dragStart = { proxy: this.proxy.matrix.clone(), mesh: body.matrix.clone(), body }
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const start = this.dragStart
|
|
206
|
+
this.dragStart = undefined
|
|
207
|
+
if (!start) return
|
|
208
|
+
/* Either listener may see the release first; the flag lasts past both, then clears. */
|
|
209
|
+
this.justDragged = true
|
|
210
|
+
setTimeout(() => (this.justDragged = false))
|
|
211
|
+
const result: GizmoResult = {}
|
|
212
|
+
if (this.mode === 'translate') result.position = this.proxy.position.toArray() as [number, number, number]
|
|
213
|
+
if (this.mode === 'rotate') {
|
|
214
|
+
/* rigid@1 composes Rx·Ry·Rz — three's 'XYZ' order — so the angles come back as the inputs mean them. */
|
|
215
|
+
const euler = new THREE.Euler().setFromQuaternion(this.proxy.quaternion, 'XYZ')
|
|
216
|
+
result.rotation = [euler.x, euler.y, euler.z].map(THREE.MathUtils.radToDeg) as [number, number, number]
|
|
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)
|
|
81
230
|
}
|
|
82
231
|
private updateGeometry(fit: boolean) {
|
|
83
232
|
try {
|
|
@@ -113,10 +262,19 @@ export class FigureV3Canvas extends LitElement {
|
|
|
113
262
|
this.controls?.update()
|
|
114
263
|
this.draw()
|
|
115
264
|
}
|
|
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)
|
|
272
|
+
}
|
|
116
273
|
private draw = () => {
|
|
117
274
|
if (this.renderer) this.renderer.render(this.scene, this.camera)
|
|
118
275
|
}
|
|
119
276
|
private pick = (event: PointerEvent) => {
|
|
277
|
+
if (this.justDragged || this.transform?.dragging) return
|
|
120
278
|
const r = (event.currentTarget as HTMLCanvasElement).getBoundingClientRect(),
|
|
121
279
|
ray = new THREE.Raycaster()
|
|
122
280
|
ray.setFromCamera(
|
|
@@ -134,7 +292,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
134
292
|
}
|
|
135
293
|
disconnectedCallback() {
|
|
136
294
|
this.observer?.disconnect()
|
|
295
|
+
this.themeObserver?.disconnect()
|
|
137
296
|
this.controls?.dispose()
|
|
297
|
+
this.transform?.detach()
|
|
298
|
+
this.transform?.dispose()
|
|
299
|
+
this.transform = undefined
|
|
138
300
|
disposeV3Meshes(this.group)
|
|
139
301
|
this.renderer?.dispose()
|
|
140
302
|
this.renderer = undefined
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { LitElement, html, css, type PropertyValues } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
2
3
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
4
|
import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
4
5
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
5
6
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
6
7
|
import { drawableV3Parts } from './v3-renderer.js'
|
|
7
8
|
import './v3-canvas.js'
|
|
9
|
+
import './figure-v3-parts.js'
|
|
10
|
+
import './figure-v3-material.js'
|
|
11
|
+
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
12
|
+
import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
13
|
+
import type { V3PartRow } from './figure-v3-parts.js'
|
|
8
14
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
9
15
|
|
|
10
16
|
const names: Record<string, string> = {
|
|
@@ -42,6 +48,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
42
48
|
flex-direction: column;
|
|
43
49
|
min-height: 0;
|
|
44
50
|
height: 100%;
|
|
51
|
+
/*
|
|
52
|
+
The editor folds by the width it is given, not the window's. The rail (208 open, 56 closed) and the page's
|
|
53
|
+
320 side take their share first, so a window rule was wrong whenever either was there: at 945 with the rail
|
|
54
|
+
open the editor had 417 and still drew its 660 of columns, over the side (UX ruling, 2026-09-24).
|
|
55
|
+
*/
|
|
56
|
+
container-type: inline-size;
|
|
45
57
|
font: 13px sans-serif;
|
|
46
58
|
color: var(--md-sys-color-on-surface, #22343f);
|
|
47
59
|
}
|
|
@@ -50,7 +62,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
50
62
|
align-items: center;
|
|
51
63
|
gap: 8px;
|
|
52
64
|
padding: 10px;
|
|
53
|
-
border-bottom: 1px solid
|
|
65
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
54
66
|
flex-wrap: wrap;
|
|
55
67
|
}
|
|
56
68
|
main {
|
|
@@ -64,11 +76,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
64
76
|
overflow: auto;
|
|
65
77
|
background: var(--md-sys-color-surface, #fff);
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
figure-v3-parts {
|
|
80
|
+
min-height: 0;
|
|
69
81
|
}
|
|
70
82
|
aside:last-child {
|
|
71
|
-
border-left: 1px solid
|
|
83
|
+
border-left: 1px solid var(--md-sys-color-outline-variant);
|
|
72
84
|
}
|
|
73
85
|
h3 {
|
|
74
86
|
font-size: var(--ui-t-body, 13px);
|
|
@@ -83,7 +95,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
83
95
|
font: inherit;
|
|
84
96
|
box-sizing: border-box;
|
|
85
97
|
height: 34px;
|
|
86
|
-
border: 1px solid
|
|
98
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
87
99
|
border-radius: 6px;
|
|
88
100
|
background: var(--md-sys-color-surface, #fff);
|
|
89
101
|
color: inherit;
|
|
@@ -98,8 +110,27 @@ export class FigureV3Editor extends LitElement {
|
|
|
98
110
|
cursor: default;
|
|
99
111
|
}
|
|
100
112
|
button[aria-pressed='true'] {
|
|
101
|
-
background:
|
|
102
|
-
|
|
113
|
+
background: var(--md-sys-color-secondary-container);
|
|
114
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
115
|
+
border-color: var(--md-sys-color-primary);
|
|
116
|
+
}
|
|
117
|
+
div[tools] {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-wrap: wrap;
|
|
120
|
+
gap: 4px;
|
|
121
|
+
margin: 0 0 8px;
|
|
122
|
+
}
|
|
123
|
+
div[tools] button {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
width: 34px;
|
|
128
|
+
padding: 0;
|
|
129
|
+
}
|
|
130
|
+
div[tools] md-icon {
|
|
131
|
+
font-size: 18px;
|
|
132
|
+
width: 18px;
|
|
133
|
+
height: 18px;
|
|
103
134
|
}
|
|
104
135
|
.parts {
|
|
105
136
|
display: grid;
|
|
@@ -122,25 +153,46 @@ export class FigureV3Editor extends LitElement {
|
|
|
122
153
|
}
|
|
123
154
|
.error {
|
|
124
155
|
padding: 10px;
|
|
125
|
-
background:
|
|
126
|
-
color:
|
|
156
|
+
background: var(--md-sys-color-error-container);
|
|
157
|
+
color: var(--md-sys-color-on-error-container);
|
|
127
158
|
white-space: pre-wrap;
|
|
128
159
|
}
|
|
129
160
|
.status {
|
|
130
161
|
margin-left: auto;
|
|
131
162
|
}
|
|
132
163
|
.hint {
|
|
133
|
-
color:
|
|
164
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
134
165
|
line-height: 1.5;
|
|
135
166
|
}
|
|
136
|
-
|
|
167
|
+
/* 660 and over: parts | canvas | inputs. Below that the sum of the columns' least widths no longer fits. */
|
|
168
|
+
@container (max-width: 659px) {
|
|
137
169
|
main {
|
|
138
170
|
grid-template-columns: 140px minmax(160px, 1fr);
|
|
139
171
|
}
|
|
140
172
|
aside:last-child {
|
|
141
173
|
grid-column: 1/-1;
|
|
142
174
|
max-height: 260px;
|
|
143
|
-
border-
|
|
175
|
+
border-left: none;
|
|
176
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/* Under 420: one column. The canvas first — seeing what is being made comes before the lists — and main scrolls. */
|
|
180
|
+
@container (max-width: 419px) {
|
|
181
|
+
main {
|
|
182
|
+
grid-template-columns: minmax(0, 1fr);
|
|
183
|
+
grid-template-rows: minmax(240px, 45%);
|
|
184
|
+
grid-auto-rows: auto;
|
|
185
|
+
overflow-y: auto;
|
|
186
|
+
}
|
|
187
|
+
figure-v3-canvas {
|
|
188
|
+
order: -1;
|
|
189
|
+
}
|
|
190
|
+
figure-v3-parts {
|
|
191
|
+
border-right: none;
|
|
192
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
193
|
+
}
|
|
194
|
+
aside:last-child {
|
|
195
|
+
max-height: none;
|
|
144
196
|
}
|
|
145
197
|
}
|
|
146
198
|
`
|
|
@@ -148,6 +200,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
148
200
|
@property({ type: Number }) revision = 0
|
|
149
201
|
@state() private selected = ''
|
|
150
202
|
@state() private error = ''
|
|
203
|
+
/**
|
|
204
|
+
* Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved, as in
|
|
205
|
+
* the V2 modeller: a part hidden here is still in the figure, still checked, still drawn on a board.
|
|
206
|
+
*/
|
|
207
|
+
@state() private hiddenParts = new Set<string>()
|
|
151
208
|
private lastContext: string | null = null
|
|
152
209
|
protected willUpdate(_changed: PropertyValues) {
|
|
153
210
|
const view = this.session?.read()
|
|
@@ -155,6 +212,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
155
212
|
this.lastContext = view?.context ?? null
|
|
156
213
|
this.selected = ''
|
|
157
214
|
this.error = ''
|
|
215
|
+
this.hiddenParts = new Set()
|
|
158
216
|
}
|
|
159
217
|
if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
|
|
160
218
|
}
|
|
@@ -224,6 +282,111 @@ export class FigureV3Editor extends LitElement {
|
|
|
224
282
|
this.selected = part.id
|
|
225
283
|
})
|
|
226
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* The list's own operations, as V3 part edits — each one step of the session's history. The copy and the moved
|
|
287
|
+
* part stay selected, as they do in the V2 list.
|
|
288
|
+
*/
|
|
289
|
+
private duplicatePart(id: string) {
|
|
290
|
+
this.run(() => {
|
|
291
|
+
const before = new Set(this.session!.read().geometry.map(g => g.id))
|
|
292
|
+
this.session!.edit({ kind: 'duplicate-part', target: id })
|
|
293
|
+
this.selected = this.session!.read().geometry.find(g => !before.has(g.id))?.id ?? id
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
private movePart(id: string, to: number) {
|
|
297
|
+
this.run(() => this.session!.edit({ kind: 'move-part', target: id, to }))
|
|
298
|
+
}
|
|
299
|
+
private removePart(id: string) {
|
|
300
|
+
try {
|
|
301
|
+
this.session!.edit({ kind: 'remove-part', target: id })
|
|
302
|
+
this.error = ''
|
|
303
|
+
if (this.selected === id) this.selected = ''
|
|
304
|
+
this.changed()
|
|
305
|
+
} catch (error) {
|
|
306
|
+
/* The refusal a person meets most: another part stands on this one or follows its size. Said in their words. */
|
|
307
|
+
this.error =
|
|
308
|
+
(error as { code?: string })?.code === 'PART_REFERENCED'
|
|
309
|
+
? i18next.t('figure.text.part-is-referenced', { part: id })
|
|
310
|
+
: error instanceof Error
|
|
311
|
+
? error.message
|
|
312
|
+
: String(error)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
private toggleHiddenPart(id: string) {
|
|
316
|
+
const next = new Set(this.hiddenParts)
|
|
317
|
+
if (next.has(id)) next.delete(id)
|
|
318
|
+
else next.add(id)
|
|
319
|
+
this.hiddenParts = next
|
|
320
|
+
}
|
|
321
|
+
/** What the gizmo may write for the selected part — read off the asset being edited. */
|
|
322
|
+
private reachOf(id: string): GizmoReach {
|
|
323
|
+
const none = { translate: false, rotate: false, scale: false }
|
|
324
|
+
if (!id || !this.session) return none
|
|
325
|
+
try {
|
|
326
|
+
return gizmoReachOf(JSON.parse(this.session.source()), id)
|
|
327
|
+
} catch {
|
|
328
|
+
return none
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
|
|
332
|
+
private transformed(id: string, result: GizmoResult) {
|
|
333
|
+
const view = this.session!.read()
|
|
334
|
+
const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
|
|
335
|
+
const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
|
|
336
|
+
if (changes.length) this.run(() => this.session!.apply(changes))
|
|
337
|
+
else this.requestUpdate()
|
|
338
|
+
}
|
|
339
|
+
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
340
|
+
private align(tool: AlignTool) {
|
|
341
|
+
this.run(() => {
|
|
342
|
+
const asset = JSON.parse(this.session!.source())
|
|
343
|
+
const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
|
|
344
|
+
if (changes.length) this.session!.apply(changes)
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
/** A mirrored copy: duplicate, then place the copy on the other side — two steps, as the V2 list makes it. */
|
|
348
|
+
private mirror(axis: 'x' | 'z') {
|
|
349
|
+
this.run(() => {
|
|
350
|
+
const id = this.selected
|
|
351
|
+
const before = new Set(this.session!.read().geometry.map(g => g.id))
|
|
352
|
+
this.session!.edit({ kind: 'duplicate-part', target: id })
|
|
353
|
+
const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
|
|
354
|
+
if (!copy) return
|
|
355
|
+
this.session!.apply(mirrorChanges(JSON.parse(this.session!.source()), id, copy, axis))
|
|
356
|
+
this.selected = copy
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
private renderTools(unavailable: boolean) {
|
|
360
|
+
const reach = this.reachOf(this.selected)
|
|
361
|
+
if (!this.selected || !reach.translate) return null
|
|
362
|
+
let asset: unknown
|
|
363
|
+
try {
|
|
364
|
+
asset = JSON.parse(this.session!.source())
|
|
365
|
+
} catch {
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
const tool = (name: AlignTool, icon: string, label: string, title: string) =>
|
|
369
|
+
html`<button ?disabled=${unavailable} aria-label=${i18next.t(label)} title=${i18next.t(title)} @click=${() => this.align(name)}>
|
|
370
|
+
<md-icon>${icon}</md-icon>
|
|
371
|
+
</button>`
|
|
372
|
+
return html`<div tools title=${i18next.t('figure.text.tools-explained')}>
|
|
373
|
+
${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
|
|
374
|
+
${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
|
|
375
|
+
${tool('floor', 'vertical_align_bottom', 'figure.button.to-floor', 'figure.text.put-on-floor')}
|
|
376
|
+
${(['x', 'z'] as const).map(axis => {
|
|
377
|
+
const able = canMirror(asset as never, this.selected, axis, reach)
|
|
378
|
+
return html`<button
|
|
379
|
+
?disabled=${unavailable || !able}
|
|
380
|
+
aria-label=${i18next.t('figure.button.mirror-' + axis)}
|
|
381
|
+
title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
|
|
382
|
+
@click=${() => this.mirror(axis)}
|
|
383
|
+
>
|
|
384
|
+
<md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
|
|
385
|
+
</button>`
|
|
386
|
+
})}
|
|
387
|
+
${tool('snap', 'grid_on', 'figure.button.snap', 'figure.text.snap-explained')}
|
|
388
|
+
</div>`
|
|
389
|
+
}
|
|
227
390
|
private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
|
|
228
391
|
const input = event.target as HTMLInputElement,
|
|
229
392
|
value = input.valueAsNumber
|
|
@@ -262,30 +425,34 @@ export class FigureV3Editor extends LitElement {
|
|
|
262
425
|
</header>
|
|
263
426
|
${this.error || view.error ? html`<div role="alert" class="error">${this.error || view.error}</div>` : null}
|
|
264
427
|
<main>
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
428
|
+
<figure-v3-parts
|
|
429
|
+
.parts=${view.geometry.map(
|
|
430
|
+
(g): V3PartRow => ({
|
|
431
|
+
id: g.id,
|
|
432
|
+
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
433
|
+
hidden: this.hiddenParts.has(g.id),
|
|
434
|
+
invisible: (g as { visible?: boolean }).visible === false
|
|
435
|
+
})
|
|
436
|
+
)}
|
|
437
|
+
.selected=${this.selected}
|
|
438
|
+
?locked=${unavailable}
|
|
439
|
+
@add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
|
|
440
|
+
@select-part=${(e: CustomEvent) => (this.selected = e.detail.id)}
|
|
441
|
+
@toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
|
|
442
|
+
@set-all-parts-hidden=${(e: CustomEvent) =>
|
|
443
|
+
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
|
|
444
|
+
@move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
445
|
+
@move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
446
|
+
@duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
|
|
447
|
+
@remove-part=${(e: CustomEvent) => this.removePart(e.detail.id)}
|
|
448
|
+
></figure-v3-parts>
|
|
285
449
|
<figure-v3-canvas
|
|
286
|
-
.parts=${drawableV3Parts(view.geometry ?? [])}
|
|
450
|
+
.parts=${drawableV3Parts(view.geometry ?? []).filter(body => !this.hiddenParts.has((body as { id: string }).id))}
|
|
287
451
|
.palette=${view.palette ?? {}}
|
|
288
452
|
.context=${view.context ?? ''}
|
|
453
|
+
.selected=${this.selected}
|
|
454
|
+
.reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
|
|
455
|
+
@part-transformed=${(e: CustomEvent) => this.transformed(e.detail.id, e.detail.result)}
|
|
289
456
|
@part-selected=${(e: CustomEvent<string>) => {
|
|
290
457
|
this.selected = e.detail
|
|
291
458
|
}}
|
|
@@ -295,23 +462,16 @@ export class FigureV3Editor extends LitElement {
|
|
|
295
462
|
></figure-v3-canvas>
|
|
296
463
|
<aside>
|
|
297
464
|
<h3>${this.selected || '모델 입력'}</h3>
|
|
465
|
+
${this.renderTools(unavailable)}
|
|
298
466
|
${fields.map(field => html`<label><span>${fieldLabel(field.id)} (${field.unit})${field.role === 'state' ? ' · 동작' : ''}</span><input aria-label=${field.id} type="number" step="any" .value=${String(field.value)} ?disabled=${unavailable} @change=${(e: Event) => this.changeField(field, e)} /></label>`)}
|
|
299
467
|
${
|
|
300
468
|
part
|
|
301
|
-
? html`<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
@change=${(e: Event) => this.run(() => this.session!.edit({ kind: 'set-material', target: this.selected, material: { ...part.material, token: (e.target as HTMLSelectElement).value } }))}
|
|
308
|
-
>
|
|
309
|
-
${Object.keys(view.palette ?? {}).map(token => html`<option value=${token}>${token === 'palette.primary' ? '기본 몸체' : token}</option>`)}
|
|
310
|
-
</select></label
|
|
311
|
-
>
|
|
312
|
-
<p class="hint">
|
|
313
|
-
도형과 위치를 결정하는 입력을 편집합니다. 다른 부품과 공유하는 입력은 함께 영향을 줄 수 있습니다.
|
|
314
|
-
</p>`
|
|
469
|
+
? html`<figure-v3-material
|
|
470
|
+
.body=${part}
|
|
471
|
+
.palette=${view.palette ?? {}}
|
|
472
|
+
?locked=${unavailable}
|
|
473
|
+
@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
474
|
+
></figure-v3-material>`
|
|
315
475
|
: null
|
|
316
476
|
}
|
|
317
477
|
${
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseV3Asset } from '@hatiolab/figure-model'
|
|
2
|
+
import type { V3Asset, V3Face } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* What the V3 properties tab reads, kept apart from the element so it can be tested without a browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Placement = 'floor' | 'ceiling' | 'center'
|
|
9
|
+
|
|
10
|
+
/** What the properties tab reads off the asset. Absent parts of the asset read as absent, never as a default. */
|
|
11
|
+
export interface V3FigureFacts {
|
|
12
|
+
name: string
|
|
13
|
+
parts: string[]
|
|
14
|
+
declared: boolean
|
|
15
|
+
placement?: Placement
|
|
16
|
+
mounting?: { part: string; face: V3Face }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The asset's own facts, as the properties tab shows them.
|
|
21
|
+
*
|
|
22
|
+
* The mounting face is read back from the feature `set-mounting-face` writes (`<part>.mount.<face>`): the asset
|
|
23
|
+
* records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
|
|
24
|
+
* bodies (`place@1`), in the order the model lists them.
|
|
25
|
+
*/
|
|
26
|
+
export function v3FigureFacts(source: string): V3FigureFacts {
|
|
27
|
+
const asset = parseV3Asset(source) as V3Asset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
|
|
28
|
+
const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
29
|
+
const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
|
|
30
|
+
const found = feature?.match(/^(.+)\.mount\.(left|right|bottom|top|back|front)\.value$/)
|
|
31
|
+
return {
|
|
32
|
+
name: asset.name,
|
|
33
|
+
parts,
|
|
34
|
+
declared: !!asset.occupancy,
|
|
35
|
+
placement: asset.occupancy?.placement as Placement | undefined,
|
|
36
|
+
mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A tag line as a list — the V2 inspector's rule: split on commas, drop a leading `#`, keep each once. An empty
|
|
42
|
+
* line is an empty list, so an author can clear every tag.
|
|
43
|
+
*/
|
|
44
|
+
export function tagsOf(raw: string): string[] {
|
|
45
|
+
return [
|
|
46
|
+
...new Set(
|
|
47
|
+
raw
|
|
48
|
+
.split(',')
|
|
49
|
+
.map(one => one.trim().replace(/^#+/, '').trim())
|
|
50
|
+
.filter(one => one.length > 0)
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
}
|