@things-factory/figure-ui 10.1.34 → 10.1.36

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 (49) hide show
  1. package/client/graphql/index.ts +13 -0
  2. package/client/graphql/v3-store.ts +30 -0
  3. package/client/modeller/figure-history-panel.ts +29 -2
  4. package/client/modeller/v3-canvas.ts +145 -0
  5. package/client/modeller/v3-editor.ts +317 -0
  6. package/client/modeller/v3-product-session.ts +141 -0
  7. package/client/modeller/v3-renderer.ts +61 -0
  8. package/client/pages/figure-modeller-page.ts +50 -3
  9. package/client/pages/figure-v3-modeller-page.ts +117 -0
  10. package/client/types.ts +8 -0
  11. package/dist-client/graphql/index.js +13 -0
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/graphql/v3-store.d.ts +3 -0
  14. package/dist-client/graphql/v3-store.js +32 -0
  15. package/dist-client/graphql/v3-store.js.map +1 -0
  16. package/dist-client/modeller/figure-history-panel.d.ts +2 -0
  17. package/dist-client/modeller/figure-history-panel.js +28 -1
  18. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  19. package/dist-client/modeller/v3-canvas.d.ts +28 -0
  20. package/dist-client/modeller/v3-canvas.js +157 -0
  21. package/dist-client/modeller/v3-canvas.js.map +1 -0
  22. package/dist-client/modeller/v3-editor.d.ts +19 -0
  23. package/dist-client/modeller/v3-editor.js +332 -0
  24. package/dist-client/modeller/v3-editor.js.map +1 -0
  25. package/dist-client/modeller/v3-product-session.d.ts +366 -0
  26. package/dist-client/modeller/v3-product-session.js +130 -0
  27. package/dist-client/modeller/v3-product-session.js.map +1 -0
  28. package/dist-client/modeller/v3-renderer.d.ts +31 -0
  29. package/dist-client/modeller/v3-renderer.js +81 -0
  30. package/dist-client/modeller/v3-renderer.js.map +1 -0
  31. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  32. package/dist-client/pages/figure-modeller-page.js +55 -2
  33. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  34. package/dist-client/pages/figure-v3-modeller-page.d.ts +26 -0
  35. package/dist-client/pages/figure-v3-modeller-page.js +140 -0
  36. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-client/types.d.ts +7 -0
  39. package/dist-client/types.js.map +1 -1
  40. package/docs/v3-product-cutover.md +35 -0
  41. package/package.json +4 -4
  42. package/test/v3-product-session.test.ts +133 -0
  43. package/test/v3-product-write.test.ts +48 -0
  44. package/test/v3-renderer.test.ts +24 -0
  45. package/translations/en.json +3 -0
  46. package/translations/ja.json +3 -0
  47. package/translations/ko.json +3 -0
  48. package/translations/ms.json +3 -0
  49. package/translations/zh.json +3 -0
@@ -310,6 +310,19 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
310
310
  resolved
311
311
  safe
312
312
  }
313
+ fixPlan {
314
+ resolved
315
+ safe
316
+ fixes {
317
+ findingWay
318
+ part
319
+ axis
320
+ from
321
+ to
322
+ resolved
323
+ safe
324
+ }
325
+ }
313
326
  blocking
314
327
  }
315
328
  }
@@ -0,0 +1,30 @@
1
+ import gql from 'graphql-tag'
2
+ import { client } from '@operato/graphql'
3
+ import { fetchFigure } from './index.js'
4
+ import type { V3ProductStore, V3ProductRecord } from '../modeller/v3-product-session.js'
5
+
6
+ /** V3 source is sent intact. No conversion into the old parts/base authoring representation. */
7
+ export const v3ProductStore: V3ProductStore = {
8
+ async load(id) {
9
+ const record = await fetchFigure(id)
10
+ if (!record?.source) throw Error('Figure 정본이 없습니다.')
11
+ return { id: record.id, source: record.source }
12
+ },
13
+ async save(request) {
14
+ const response = await client.mutate({
15
+ mutation: gql`
16
+ mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String) {
17
+ saveFigureV3(source: $source, id: $id, expectedSource: $expectedSource, creationId: $creationId) {
18
+ id
19
+ source
20
+ }
21
+ }
22
+ `,
23
+ variables: request
24
+ })
25
+ if (response.errors?.length) throw Error(response.errors.map(e => e.message).join('\n'))
26
+ const result = response.data?.saveFigureV3 as V3ProductRecord | undefined
27
+ if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')
28
+ return result
29
+ }
30
+ }
@@ -7,7 +7,7 @@ import { i18next, localize } from '@operato/i18n'
7
7
  import { ScrollbarStyles } from '@operato/styles'
8
8
 
9
9
  import { fetchFigureVersions, inspectFigure, releaseFigure, revertFigureVersion } from '../graphql/index.js'
10
- import type { FigureGateFinding, FigureGateFix, FigureInspection, FigureVersion } from '../types.js'
10
+ import type { FigureGateFinding, FigureGateFix, FigureGateFixPlan, FigureInspection, FigureVersion } from '../types.js'
11
11
 
12
12
  /**
13
13
  * 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
@@ -234,6 +234,16 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
234
234
  opacity: 0.85;
235
235
  }
236
236
 
237
+ li[finding] details[affected] {
238
+ width: 100%;
239
+ color: inherit;
240
+ }
241
+
242
+ li[finding] details[affected] summary {
243
+ cursor: pointer;
244
+ font-weight: 600;
245
+ }
246
+
237
247
  /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
238
248
  li[finding] span[why-line] {
239
249
  opacity: 0.8;
@@ -461,7 +471,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
461
471
  return html`
462
472
  <li finding ?blocking=${blocking}>
463
473
  <span msg>${finding.message}</span>
464
- ${finding.at ? html`<code where>${finding.at}</code>` : nothing}
474
+ ${finding.at ? html`
475
+ <details affected>
476
+ <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
477
+ <code where>${finding.at}</code>
478
+ </details>
479
+ ` : nothing}
465
480
  ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
466
481
  ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
467
482
  ${finding.code === 'part-outside-base'
@@ -495,6 +510,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
495
510
  ${i18next.t('figure.button.preview-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
496
511
  </button>
497
512
  `)}
513
+ ${finding.fixPlan?.safe ? html`
514
+ <span how-line><md-icon>auto_fix_high</md-icon>${i18next.t('figure.text.recommended-fix-explained', { n: finding.fixPlan.fixes.length })}</span>
515
+ <button apply-fix @click=${() => this.applyFixPlan(finding.fixPlan!)}>
516
+ ${i18next.t('figure.button.apply-recommended-fix')}
517
+ </button>
518
+ ` : nothing}
498
519
  </li>
499
520
  `
500
521
  }
@@ -513,6 +534,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
513
534
  this.dispatchEvent(new CustomEvent('apply-sizing-fix', { detail: fix, bubbles: true, composed: true }))
514
535
  }
515
536
 
537
+ /** Apply the server-remeasured coordinated repair to the draft only; save remains explicit. */
538
+ private applyFixPlan(plan: FigureGateFixPlan) {
539
+ if (!plan.safe || plan.fixes.length === 0) return
540
+ this.dispatchEvent(new CustomEvent('apply-sizing-fix-plan', { detail: plan, bubbles: true, composed: true }))
541
+ }
542
+
516
543
  /**
517
544
  * 넘친 것을 담는 길 하나를 여기서도 내민다 — **같은 동작이 속성 패널에도 있다**(「부품에 맞추기」).
518
545
  *
@@ -0,0 +1,145 @@
1
+ import { LitElement, html, css, type PropertyValues } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import * as THREE from 'three'
4
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
5
+ import { replaceV3Meshes, disposeV3Meshes, type V3RenderPart } from './v3-renderer.js'
6
+
7
+ /** Renders evaluated V3 geometry directly. There is no parts/base or things-scene conversion. */
8
+ @customElement('figure-v3-canvas')
9
+ export class FigureV3Canvas extends LitElement {
10
+ static styles = css`
11
+ :host {
12
+ display: block;
13
+ min-width: 0;
14
+ min-height: 0;
15
+ position: relative;
16
+ background: #eef2f4;
17
+ }
18
+ canvas {
19
+ display: block;
20
+ width: 100%;
21
+ height: 100%;
22
+ }
23
+ p {
24
+ position: absolute;
25
+ inset: 45% 1rem auto;
26
+ text-align: center;
27
+ pointer-events: none;
28
+ color: #526773;
29
+ }
30
+ `
31
+ @property({ attribute: false }) parts: V3RenderPart[] = []
32
+ @property({ attribute: false }) palette: Record<string, string> = {}
33
+ @property() context = ''
34
+ private renderer?: THREE.WebGLRenderer
35
+ private readonly scene = new THREE.Scene()
36
+ private readonly group = new THREE.Group()
37
+ private readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000000)
38
+ private controls?: OrbitControls
39
+ private observer?: ResizeObserver
40
+ private initialized = false
41
+ private contextAtRender = ''
42
+ render() {
43
+ return html`<canvas aria-label="Figure 3D 모델" @pointerup=${this.pick}></canvas
44
+ >${this.parts.length ? null : html`<p>도형을 추가해 모델링을 시작하세요.</p>`}`
45
+ }
46
+ protected firstUpdated() {
47
+ this.initialize()
48
+ }
49
+ connectedCallback() {
50
+ super.connectedCallback()
51
+ if (this.hasUpdated) void this.updateComplete.then(() => this.initialize())
52
+ }
53
+ private initialize() {
54
+ if (this.initialized || !this.isConnected) return
55
+ const canvas = this.renderRoot.querySelector('canvas')!
56
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
57
+ this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
58
+ this.scene.background = new THREE.Color('#eef2f4')
59
+ this.scene.add(this.group, new THREE.HemisphereLight(0xffffff, 0x62717d, 2.5))
60
+ const light = new THREE.DirectionalLight(0xffffff, 3)
61
+ light.position.set(1, 2, 3)
62
+ this.scene.add(light)
63
+ this.controls = new OrbitControls(this.camera, canvas)
64
+ this.controls.addEventListener('change', this.draw)
65
+ this.observer = new ResizeObserver(() => {
66
+ const r = this.getBoundingClientRect()
67
+ if (r.width && r.height) {
68
+ this.renderer!.setSize(r.width, r.height, false)
69
+ this.camera.aspect = r.width / r.height
70
+ this.camera.updateProjectionMatrix()
71
+ this.draw()
72
+ }
73
+ })
74
+ this.observer.observe(this)
75
+ this.initialized = true
76
+ this.updateGeometry(true)
77
+ }
78
+ protected updated(changed: PropertyValues) {
79
+ if (this.initialized && (changed.has('parts') || changed.has('palette') || changed.has('context')))
80
+ this.updateGeometry(this.contextAtRender !== this.context)
81
+ }
82
+ private updateGeometry(fit: boolean) {
83
+ try {
84
+ const wasEmpty = this.group.children.length === 0
85
+ replaceV3Meshes(this.group, this.parts, this.palette)
86
+ this.contextAtRender = this.context
87
+ if (fit || wasEmpty) this.fit()
88
+ this.draw()
89
+ } catch (error) {
90
+ // Do not leave another model's geometry visible after a failed context switch.
91
+ if (this.contextAtRender !== this.context) {
92
+ disposeV3Meshes(this.group)
93
+ this.draw()
94
+ }
95
+ this.dispatchEvent(new CustomEvent('render-error', { detail: error, bubbles: true, composed: true }))
96
+ }
97
+ }
98
+ fit() {
99
+ const bounds = new THREE.Box3().setFromObject(this.group),
100
+ center = new THREE.Vector3(),
101
+ size = new THREE.Vector3()
102
+ if (!bounds.isEmpty()) {
103
+ bounds.getCenter(center)
104
+ bounds.getSize(size)
105
+ }
106
+ const radius = Math.max(size.length() / 2, 100),
107
+ distance = (radius / Math.sin(THREE.MathUtils.degToRad(this.camera.fov / 2))) * 1.3
108
+ this.camera.position.copy(center).add(new THREE.Vector3(1, 0.8, 1).normalize().multiplyScalar(distance))
109
+ this.camera.near = Math.max(0.01, distance / 10000)
110
+ this.camera.far = Math.max(10000, distance * 100)
111
+ this.camera.updateProjectionMatrix()
112
+ this.controls?.target.copy(center)
113
+ this.controls?.update()
114
+ this.draw()
115
+ }
116
+ private draw = () => {
117
+ if (this.renderer) this.renderer.render(this.scene, this.camera)
118
+ }
119
+ private pick = (event: PointerEvent) => {
120
+ const r = (event.currentTarget as HTMLCanvasElement).getBoundingClientRect(),
121
+ ray = new THREE.Raycaster()
122
+ ray.setFromCamera(
123
+ new THREE.Vector2(((event.clientX - r.left) / r.width) * 2 - 1, (-(event.clientY - r.top) / r.height) * 2 + 1),
124
+ this.camera
125
+ )
126
+ const hit = ray.intersectObjects(this.group.children)[0]
127
+ this.dispatchEvent(
128
+ new CustomEvent('part-selected', { detail: hit?.object.name ?? '', bubbles: true, composed: true })
129
+ )
130
+ }
131
+ thumbnail() {
132
+ this.draw()
133
+ return this.renderer?.domElement.toDataURL('image/png')
134
+ }
135
+ disconnectedCallback() {
136
+ this.observer?.disconnect()
137
+ this.controls?.dispose()
138
+ disposeV3Meshes(this.group)
139
+ this.renderer?.dispose()
140
+ this.renderer = undefined
141
+ this.scene.clear()
142
+ this.initialized = false
143
+ super.disconnectedCallback()
144
+ }
145
+ }
@@ -0,0 +1,317 @@
1
+ import { LitElement, html, css, type PropertyValues } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
4
+ import type { V3ProductSession } from './v3-product-session.js'
5
+ import './v3-canvas.js'
6
+ import type { FigureV3Canvas } from './v3-canvas.js'
7
+
8
+ const names: Record<string, string> = {
9
+ position: '위치',
10
+ rotation: '회전',
11
+ size: '크기',
12
+ dimension: '치수',
13
+ width: '너비',
14
+ height: '높이',
15
+ depth: '깊이',
16
+ radius: '반지름',
17
+ length: '길이',
18
+ round: '모서리 반경',
19
+ left: '왼쪽',
20
+ right: '오른쪽',
21
+ bottom: '아랫면',
22
+ top: '윗면',
23
+ openingLeft: '개구부 왼쪽',
24
+ openingRight: '개구부 오른쪽',
25
+ openingTop: '개구부 윗면'
26
+ }
27
+ function fieldLabel(id: string) {
28
+ return id
29
+ .split('.')
30
+ .map(p => names[p] ?? p.toUpperCase())
31
+ .join(' · ')
32
+ }
33
+
34
+ /** Product editor surface. The injected session is shared by page persistence and AI commands. */
35
+ @customElement('figure-v3-editor')
36
+ export class FigureV3Editor extends LitElement {
37
+ static styles = css`
38
+ :host {
39
+ display: flex;
40
+ flex-direction: column;
41
+ min-height: 0;
42
+ height: 100%;
43
+ font: 13px sans-serif;
44
+ color: var(--md-sys-color-on-surface, #22343f);
45
+ }
46
+ header {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+ padding: 10px;
51
+ border-bottom: 1px solid #d4dde2;
52
+ flex-wrap: wrap;
53
+ }
54
+ main {
55
+ flex: 1;
56
+ min-height: 0;
57
+ display: grid;
58
+ grid-template-columns: 180px minmax(180px, 1fr) 300px;
59
+ }
60
+ aside {
61
+ padding: 12px;
62
+ overflow: auto;
63
+ background: var(--md-sys-color-surface, #fff);
64
+ }
65
+ aside:first-child {
66
+ border-right: 1px solid #d4dde2;
67
+ }
68
+ aside:last-child {
69
+ border-left: 1px solid #d4dde2;
70
+ }
71
+ h3 {
72
+ font-size: var(--ui-t-body, 13px);
73
+ margin: 22px 0 10px;
74
+ }
75
+ h3:first-child {
76
+ margin-top: 0;
77
+ }
78
+ button,
79
+ input,
80
+ select {
81
+ font: inherit;
82
+ box-sizing: border-box;
83
+ height: 34px;
84
+ border: 1px solid #cbd5dc;
85
+ border-radius: 6px;
86
+ background: var(--md-sys-color-surface, #fff);
87
+ color: inherit;
88
+ padding: 0 8px;
89
+ min-width: 0;
90
+ }
91
+ button {
92
+ cursor: pointer;
93
+ }
94
+ button:disabled {
95
+ opacity: 0.45;
96
+ cursor: default;
97
+ }
98
+ button[aria-pressed='true'] {
99
+ background: #d6edf0;
100
+ border-color: #337783;
101
+ }
102
+ .parts {
103
+ display: grid;
104
+ gap: 6px;
105
+ }
106
+ .recipes {
107
+ display: grid;
108
+ grid-template-columns: 1fr 1fr;
109
+ gap: 6px;
110
+ }
111
+ label {
112
+ display: grid;
113
+ grid-template-columns: 112px minmax(0, 1fr);
114
+ align-items: center;
115
+ gap: 8px;
116
+ margin: 8px 0;
117
+ }
118
+ label span {
119
+ overflow-wrap: anywhere;
120
+ }
121
+ .error {
122
+ padding: 10px;
123
+ background: #fff0eb;
124
+ color: #922716;
125
+ white-space: pre-wrap;
126
+ }
127
+ .status {
128
+ margin-left: auto;
129
+ }
130
+ .hint {
131
+ color: #637783;
132
+ line-height: 1.5;
133
+ }
134
+ @media (max-width: 800px) {
135
+ main {
136
+ grid-template-columns: 140px minmax(160px, 1fr);
137
+ }
138
+ aside:last-child {
139
+ grid-column: 1/-1;
140
+ max-height: 260px;
141
+ border-top: 1px solid #d4dde2;
142
+ }
143
+ }
144
+ `
145
+ @property({ attribute: false }) session?: V3ProductSession
146
+ @property({ type: Number }) revision = 0
147
+ @state() private selected = ''
148
+ @state() private error = ''
149
+ private lastContext: string | null = null
150
+ protected willUpdate(_changed: PropertyValues) {
151
+ const view = this.session?.read()
152
+ if (view?.context !== this.lastContext) {
153
+ this.lastContext = view?.context ?? null
154
+ this.selected = ''
155
+ this.error = ''
156
+ }
157
+ if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
158
+ }
159
+ private run(action: () => unknown) {
160
+ try {
161
+ action()
162
+ this.error = ''
163
+ this.changed()
164
+ } catch (error) {
165
+ this.error = error instanceof Error ? error.message : String(error)
166
+ }
167
+ }
168
+ private changed() {
169
+ this.requestUpdate()
170
+ this.dispatchEvent(new CustomEvent('v3-changed', { bubbles: true, composed: true }))
171
+ }
172
+ private async save() {
173
+ if (!this.session) return
174
+ try {
175
+ const promise = this.session.save()
176
+ this.changed()
177
+ await promise
178
+ this.error = ''
179
+ } catch (error) {
180
+ this.error = error instanceof Error ? error.message : String(error)
181
+ } finally {
182
+ this.changed()
183
+ }
184
+ }
185
+ private add(kind: V3PrimitiveCreation['kind']) {
186
+ if (!this.session) return
187
+ this.run(() => {
188
+ const ids = new Set(this.session!.read().geometry.map(g => g.id))
189
+ let n = 1
190
+ while (ids.has(`${kind}-${n}`)) n++
191
+ const common = { id: `${kind}-${n}`, position: { x: 0, y: 50, z: 0 } }
192
+ const part: V3PrimitiveCreation =
193
+ kind === 'box'
194
+ ? { ...common, kind, dimensions: { width: 100, height: 100, depth: 100 } }
195
+ : kind === 'cylinder'
196
+ ? { ...common, kind, dimensions: { radius: 50, length: 100 } }
197
+ : kind === 'rounded-box'
198
+ ? { ...common, kind, dimensions: { width: 100, height: 100, depth: 100, round: 10 } }
199
+ : {
200
+ ...common,
201
+ position: { x: 0, y: 0, z: 0 },
202
+ kind: 'portal',
203
+ dimensions: {
204
+ left: -100,
205
+ right: 100,
206
+ bottom: 0,
207
+ top: 200,
208
+ openingLeft: -70,
209
+ openingRight: 70,
210
+ openingTop: 170,
211
+ depth: 30
212
+ }
213
+ }
214
+ this.session!.add(part)
215
+ this.selected = part.id
216
+ })
217
+ }
218
+ private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
219
+ const input = event.target as HTMLInputElement,
220
+ value = input.valueAsNumber
221
+ this.run(() =>
222
+ this.session!.apply([
223
+ { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
224
+ ])
225
+ )
226
+ // Rejected edits must also restore the displayed numeric value.
227
+ input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
228
+ }
229
+ render() {
230
+ const view = this.session?.read()
231
+ if (!view) return html`<p>모델을 여는 중입니다.</p>`
232
+ const unavailable = view.loading || !!view.error || view.saving || !view.context
233
+ const part = view.geometry.find(g => g.id === this.selected)
234
+ // Shared/root parameters remain available even when a part is selected.
235
+ const own = view.fields.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
236
+ const common = view.fields.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
237
+ const fields = this.selected ? [...own, ...common] : view.fields
238
+ return html` <header>
239
+ <strong>V3 모델링</strong>
240
+ <button ?disabled=${unavailable || !view.canUndo} @click=${() => this.run(() => this.session!.undo())}>
241
+ 실행 취소
242
+ </button>
243
+ <button ?disabled=${unavailable || !view.canRedo} @click=${() => this.run(() => this.session!.redo())}>
244
+ 다시 실행
245
+ </button>
246
+ <button @click=${() => this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.fit()}>
247
+ 전체 보기
248
+ </button>
249
+ <span class="status" role="status"
250
+ >${view.loading ? '불러오는 중' : view.saving ? '저장 중' : view.dirty || !view.id ? '저장 안 됨' : '저장됨'}</span
251
+ >
252
+ <button ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>저장</button>
253
+ </header>
254
+ ${this.error || view.error ? html`<div role="alert" class="error">${this.error || view.error}</div>` : null}
255
+ <main>
256
+ <aside>
257
+ <h3>도형 추가</h3>
258
+ <div class="recipes">
259
+ ${(['box', 'cylinder', 'rounded-box', 'portal'] as const).map((kind, i) => html`<button ?disabled=${unavailable} @click=${() => this.add(kind)}>${['상자', '원기둥', '둥근 상자', '문틀'][i]}</button>`)}
260
+ </div>
261
+ <h3>부품 ${view.geometry.length}</h3>
262
+ <div class="parts">
263
+ ${view.geometry.map(
264
+ g =>
265
+ html`<button
266
+ aria-pressed=${g.id === this.selected}
267
+ @click=${() => {
268
+ this.selected = g.id
269
+ }}
270
+ >
271
+ ${g.id}
272
+ </button>`
273
+ )}
274
+ </div>
275
+ </aside>
276
+ <figure-v3-canvas
277
+ .parts=${view.geometry}
278
+ .palette=${view.palette ?? {}}
279
+ .context=${view.context ?? ''}
280
+ @part-selected=${(e: CustomEvent<string>) => {
281
+ this.selected = e.detail
282
+ }}
283
+ @render-error=${(e: CustomEvent<Error>) => {
284
+ this.error = e.detail.message
285
+ }}
286
+ ></figure-v3-canvas>
287
+ <aside>
288
+ <h3>${this.selected || '모델 입력'}</h3>
289
+ ${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>`)}
290
+ ${
291
+ part
292
+ ? html`<h3>재질</h3>
293
+ <label
294
+ ><span>색 역할</span
295
+ ><select
296
+ .value=${part.material.token}
297
+ ?disabled=${unavailable}
298
+ @change=${(e: Event) => this.run(() => this.session!.edit({ kind: 'set-material', target: this.selected, material: { ...part.material, token: (e.target as HTMLSelectElement).value } }))}
299
+ >
300
+ ${Object.keys(view.palette ?? {}).map(token => html`<option value=${token}>${token === 'palette.primary' ? '기본 몸체' : token}</option>`)}
301
+ </select></label
302
+ >
303
+ <p class="hint">
304
+ 도형과 위치를 결정하는 입력을 편집합니다. 다른 부품과 공유하는 입력은 함께 영향을 줄 수 있습니다.
305
+ </p>`
306
+ : null
307
+ }
308
+ ${
309
+ view.bindings.length
310
+ ? html`<h3>배치 연결점</h3>
311
+ ${view.bindings.map(b => html`<p>${b.label} · ${b.role}</p>`)}`
312
+ : null
313
+ }
314
+ </aside>
315
+ </main>`
316
+ }
317
+ }