@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.
- package/client/graphql/v3-store.ts +30 -0
- package/client/modeller/v3-canvas.ts +145 -0
- package/client/modeller/v3-editor.ts +317 -0
- package/client/modeller/v3-product-session.ts +141 -0
- package/client/pages/figure-v3-modeller-page.ts +117 -0
- package/dist-client/graphql/v3-store.d.ts +3 -0
- package/dist-client/graphql/v3-store.js +32 -0
- package/dist-client/graphql/v3-store.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +28 -0
- package/dist-client/modeller/v3-canvas.js +157 -0
- package/dist-client/modeller/v3-canvas.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +19 -0
- package/dist-client/modeller/v3-editor.js +332 -0
- package/dist-client/modeller/v3-editor.js.map +1 -0
- package/dist-client/modeller/v3-product-session.d.ts +366 -0
- package/dist-client/modeller/v3-product-session.js +130 -0
- package/dist-client/modeller/v3-product-session.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +26 -0
- package/dist-client/pages/figure-v3-modeller-page.js +140 -0
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/docs/v3-product-cutover.md +35 -0
- package/package.json +4 -4
- package/test/v3-product-session.test.ts +133 -0
- package/test/v3-product-write.test.ts +48 -0
|
@@ -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>
|