@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.
- package/client/graphql/index.ts +13 -0
- package/client/graphql/v3-store.ts +30 -0
- package/client/modeller/figure-history-panel.ts +29 -2
- 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/modeller/v3-renderer.ts +61 -0
- package/client/pages/figure-modeller-page.ts +50 -3
- package/client/pages/figure-v3-modeller-page.ts +117 -0
- package/client/types.ts +8 -0
- package/dist-client/graphql/index.js +13 -0
- package/dist-client/graphql/index.js.map +1 -1
- 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/figure-history-panel.d.ts +2 -0
- package/dist-client/modeller/figure-history-panel.js +28 -1
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- 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/modeller/v3-renderer.d.ts +31 -0
- package/dist-client/modeller/v3-renderer.js +81 -0
- package/dist-client/modeller/v3-renderer.js.map +1 -0
- package/dist-client/pages/figure-modeller-page.d.ts +7 -0
- package/dist-client/pages/figure-modeller-page.js +55 -2
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- 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/dist-client/types.d.ts +7 -0
- package/dist-client/types.js.map +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
- package/test/v3-renderer.test.ts +24 -0
- package/translations/en.json +3 -0
- package/translations/ja.json +3 -0
- package/translations/ko.json +3 -0
- package/translations/ms.json +3 -0
- package/translations/zh.json +3 -0
package/client/graphql/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|