@things-factory/figure-ui 10.1.35 → 10.1.37

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.
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ createV3Asset,
3
+ createV3EditorWorkspace,
4
+ serializeV3Asset,
5
+ parseV3Asset,
6
+ type V3InputChange,
7
+ type V3PrimitiveCreation,
8
+ type V3PartEdit
9
+ } from '@hatiolab/figure-model'
10
+
11
+ export interface V3ProductRecord {
12
+ id: string
13
+ source: string
14
+ }
15
+
16
+ /** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
17
+ export interface V3ProductStore {
18
+ load(id: string): Promise<V3ProductRecord>
19
+ save(request: { id?: string; source: string; expectedSource?: string; creationId?: string }): Promise<V3ProductRecord>
20
+ }
21
+
22
+ /** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
23
+ export function createV3ProductSession(store: V3ProductStore) {
24
+ const workspace = createV3EditorWorkspace()
25
+ let epoch = 0,
26
+ id: string | undefined,
27
+ baseline: string | undefined
28
+ let loading = false,
29
+ saving = false,
30
+ error: string | undefined
31
+ let creationId: string | undefined
32
+ function read() {
33
+ return { ...workspace.read(), id, loading, saving, error }
34
+ }
35
+ function writable() {
36
+ const view = workspace.read()
37
+ if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
38
+ // Serializing writes also makes create retries safe: no second create while the first is pending.
39
+ if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
40
+ return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
41
+ }
42
+ function clear() {
43
+ workspace.close({ discardDesignChanges: true })
44
+ id = undefined
45
+ baseline = undefined
46
+ error = undefined
47
+ saving = false
48
+ creationId = undefined
49
+ }
50
+ function canLeave(discard: boolean) {
51
+ if (saving) throw Error('저장이 끝난 뒤 모델을 전환해 주세요.')
52
+ if (workspace.read().dirty && !discard) throw Error('저장하지 않은 변경이 있습니다.')
53
+ }
54
+ return {
55
+ read,
56
+ startNew({ discard = false } = {}) {
57
+ canLeave(discard)
58
+ ++epoch
59
+ clear()
60
+ loading = false
61
+ workspace.open(serializeV3Asset(createV3Asset('새 Figure')))
62
+ creationId = crypto.randomUUID()
63
+ return read()
64
+ },
65
+ async open(nextId: string, { discard = false } = {}) {
66
+ canLeave(discard)
67
+ const requestEpoch = ++epoch
68
+ clear()
69
+ loading = true
70
+ try {
71
+ const record = await store.load(nextId)
72
+ if (requestEpoch !== epoch) return read()
73
+ if (record.id !== nextId) throw Error('요청한 Figure와 응답의 대상이 다릅니다.')
74
+ workspace.open(record.source)
75
+ id = nextId
76
+ baseline = record.source
77
+ } catch (cause) {
78
+ if (requestEpoch === epoch) error = cause instanceof Error ? cause.message : String(cause)
79
+ } finally {
80
+ if (requestEpoch === epoch) loading = false
81
+ }
82
+ return read()
83
+ },
84
+ close({ discard = false } = {}) {
85
+ canLeave(discard)
86
+ ++epoch
87
+ clear()
88
+ loading = false
89
+ return read()
90
+ },
91
+ apply(changes: V3InputChange[], origin: 'ui' | 'ai' = 'ui', expectedRevision?: number, context?: string) {
92
+ const control = writable()
93
+ // AI must supply the context AND revision captured when it started. Never substitute current values.
94
+ if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
95
+ throw Error('AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
96
+ return workspace.apply({
97
+ ...control,
98
+ origin,
99
+ context: context ?? control.context,
100
+ expectedRevision: expectedRevision ?? control.expectedRevision,
101
+ changes
102
+ })
103
+ },
104
+ add(part: V3PrimitiveCreation) {
105
+ return workspace.addPrimitive({ ...writable(), part })
106
+ },
107
+ edit(edit: V3PartEdit) {
108
+ return workspace.editPart({ ...writable(), edit })
109
+ },
110
+ undo() {
111
+ return workspace.undo(writable())
112
+ },
113
+ redo() {
114
+ return workspace.redo(writable())
115
+ },
116
+ source() {
117
+ const { context } = writable()
118
+ return workspace.export({ context }).text
119
+ },
120
+ async save() {
121
+ const control = writable(),
122
+ requestEpoch = epoch
123
+ const exported = workspace.export(control)
124
+ saving = true
125
+ try {
126
+ const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId })
127
+ if (requestEpoch !== epoch) return read()
128
+ if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
129
+ if (serializeV3Asset(parseV3Asset(record.source)) !== exported.text)
130
+ throw Error('저장 응답이 보낸 모델과 다릅니다. 저장 상태를 다시 확인해 주세요.')
131
+ workspace.acknowledgeSaved(exported)
132
+ id = record.id
133
+ baseline = record.source
134
+ } finally {
135
+ if (requestEpoch === epoch) saving = false
136
+ }
137
+ return read()
138
+ }
139
+ }
140
+ }
141
+ export type V3ProductSession = ReturnType<typeof createV3ProductSession>