@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.
Files changed (70) hide show
  1. package/client/graphql/index.ts +1 -0
  2. package/client/modeller/figure-v3-material.ts +248 -0
  3. package/client/modeller/figure-v3-parts.ts +16 -1
  4. package/client/modeller/figure-v3-properties.ts +254 -0
  5. package/client/modeller/modeller-route.ts +25 -0
  6. package/client/modeller/v3-align.ts +84 -0
  7. package/client/modeller/v3-canvas.ts +167 -5
  8. package/client/modeller/v3-editor.ts +207 -47
  9. package/client/modeller/v3-figure-facts.ts +53 -0
  10. package/client/modeller/v3-gizmo.ts +80 -0
  11. package/client/modeller/v3-occupancy.ts +6 -2
  12. package/client/pages/figure-list-page.ts +5 -3
  13. package/client/pages/figure-modeller-page.ts +9 -0
  14. package/client/pages/figure-v3-modeller-page.ts +68 -13
  15. package/client/types.ts +2 -0
  16. package/dist-client/graphql/index.js +1 -0
  17. package/dist-client/graphql/index.js.map +1 -1
  18. package/dist-client/modeller/figure-v3-material.d.ts +36 -0
  19. package/dist-client/modeller/figure-v3-material.js +249 -0
  20. package/dist-client/modeller/figure-v3-material.js.map +1 -0
  21. package/dist-client/modeller/figure-v3-parts.js +16 -1
  22. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
  24. package/dist-client/modeller/figure-v3-properties.js +259 -0
  25. package/dist-client/modeller/figure-v3-properties.js.map +1 -0
  26. package/dist-client/modeller/modeller-route.d.ts +16 -0
  27. package/dist-client/modeller/modeller-route.js +27 -0
  28. package/dist-client/modeller/modeller-route.js.map +1 -0
  29. package/dist-client/modeller/v3-align.d.ts +26 -0
  30. package/dist-client/modeller/v3-align.js +65 -0
  31. package/dist-client/modeller/v3-align.js.map +1 -0
  32. package/dist-client/modeller/v3-canvas.d.ts +24 -0
  33. package/dist-client/modeller/v3-canvas.js +181 -5
  34. package/dist-client/modeller/v3-canvas.js.map +1 -1
  35. package/dist-client/modeller/v3-editor.d.ts +24 -0
  36. package/dist-client/modeller/v3-editor.js +218 -44
  37. package/dist-client/modeller/v3-editor.js.map +1 -1
  38. package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
  39. package/dist-client/modeller/v3-figure-facts.js +34 -0
  40. package/dist-client/modeller/v3-figure-facts.js.map +1 -0
  41. package/dist-client/modeller/v3-gizmo.d.ts +48 -0
  42. package/dist-client/modeller/v3-gizmo.js +40 -0
  43. package/dist-client/modeller/v3-gizmo.js.map +1 -0
  44. package/dist-client/modeller/v3-occupancy.d.ts +5 -1
  45. package/dist-client/modeller/v3-occupancy.js +4 -2
  46. package/dist-client/modeller/v3-occupancy.js.map +1 -1
  47. package/dist-client/pages/figure-list-page.d.ts +1 -0
  48. package/dist-client/pages/figure-list-page.js +5 -3
  49. package/dist-client/pages/figure-list-page.js.map +1 -1
  50. package/dist-client/pages/figure-modeller-page.js +9 -0
  51. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  52. package/dist-client/pages/figure-v3-modeller-page.d.ts +16 -2
  53. package/dist-client/pages/figure-v3-modeller-page.js +78 -22
  54. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/dist-client/types.d.ts +2 -0
  57. package/dist-client/types.js.map +1 -1
  58. package/package.json +4 -4
  59. package/test/modeller-route.test.ts +42 -0
  60. package/test/v3-align.test.ts +64 -0
  61. package/test/v3-gizmo.test.ts +97 -0
  62. package/test/v3-material.test.ts +68 -0
  63. package/test/v3-parts-wiring.test.ts +56 -0
  64. package/test/v3-properties.test.ts +93 -0
  65. package/test/v3-theme.test.ts +67 -0
  66. package/translations/en.json +11 -0
  67. package/translations/ja.json +11 -0
  68. package/translations/ko.json +11 -0
  69. package/translations/ms.json +11 -0
  70. 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
- background: #eef2f4;
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: #526773;
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
- return html`<canvas aria-label="Figure 3D 모델" @pointerup=${this.pick}></canvas
44
- >${this.parts.length ? null : html`<p>도형을 추가해 모델링을 시작하세요.</p>`}`
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.scene.background = new THREE.Color('#eef2f4')
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 #d4dde2;
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
- aside:first-child {
68
- border-right: 1px solid #d4dde2;
79
+ figure-v3-parts {
80
+ min-height: 0;
69
81
  }
70
82
  aside:last-child {
71
- border-left: 1px solid #d4dde2;
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 #cbd5dc;
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: #d6edf0;
102
- border-color: #337783;
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: #fff0eb;
126
- color: #922716;
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: #637783;
164
+ color: var(--md-sys-color-on-surface-variant);
134
165
  line-height: 1.5;
135
166
  }
136
- @media (max-width: 800px) {
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-top: 1px solid #d4dde2;
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
- <aside>
266
- <h3>도형 추가</h3>
267
- <div class="recipes">
268
- ${(['box', 'cylinder', 'rounded-box', 'portal'] as const).map((kind, i) => html`<button ?disabled=${unavailable} @click=${() => this.add(kind)}>${['상자', '원기둥', '둥근 상자', '문틀'][i]}</button>`)}
269
- </div>
270
- <h3>부품 ${view.geometry.length}</h3>
271
- <div class="parts">
272
- ${view.geometry.map(
273
- g =>
274
- html`<button
275
- aria-pressed=${g.id === this.selected}
276
- @click=${() => {
277
- this.selected = g.id
278
- }}
279
- >
280
- ${g.id}
281
- </button>`
282
- )}
283
- </div>
284
- </aside>
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`<h3>재질</h3>
302
- <label
303
- ><span>색 역할</span
304
- ><select
305
- .value=${part.material.token}
306
- ?disabled=${unavailable}
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
+ }