@things-factory/figure-ui 10.1.15 → 10.1.17
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/modeller/figure-animations.ts +12 -12
- package/client/modeller/figure-canvas.ts +32 -48
- package/client/modeller/figure-capabilities.ts +10 -10
- package/client/modeller/figure-inspector.ts +39 -28
- package/client/modeller/figure-parts.ts +1 -1
- package/client/modeller/figure-preview.ts +122 -25
- package/client/modeller/figure-report.ts +24 -6
- package/client/modeller/figure-settings.ts +45 -2
- package/client/modeller/figure-side.ts +6 -6
- package/client/modeller/figure-source.ts +84 -38
- package/client/modeller/figure-thumbnail.ts +5 -5
- package/client/modeller/figure-view.ts +53 -0
- package/client/modeller/part-edits.ts +36 -36
- package/client/modeller/proposal.ts +25 -4
- package/client/pages/figure-modeller-page.ts +142 -98
- package/dist-client/modeller/figure-animations.d.ts +4 -4
- package/dist-client/modeller/figure-animations.js +10 -10
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +7 -7
- package/dist-client/modeller/figure-canvas.js +27 -40
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-capabilities.d.ts +2 -2
- package/dist-client/modeller/figure-capabilities.js +9 -9
- package/dist-client/modeller/figure-capabilities.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +16 -5
- package/dist-client/modeller/figure-inspector.js +38 -27
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +1 -1
- package/dist-client/modeller/figure-parts.js +1 -1
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +39 -0
- package/dist-client/modeller/figure-preview.js +112 -23
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-report.d.ts +9 -0
- package/dist-client/modeller/figure-report.js +23 -5
- package/dist-client/modeller/figure-report.js.map +1 -1
- package/dist-client/modeller/figure-settings.js +42 -2
- package/dist-client/modeller/figure-settings.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +2 -2
- package/dist-client/modeller/figure-side.js +5 -5
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +38 -23
- package/dist-client/modeller/figure-source.js +40 -21
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
- package/dist-client/modeller/figure-thumbnail.js +3 -3
- package/dist-client/modeller/figure-thumbnail.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +45 -0
- package/dist-client/modeller/figure-view.js +27 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/part-edits.d.ts +16 -16
- package/dist-client/modeller/part-edits.js +35 -35
- package/dist-client/modeller/part-edits.js.map +1 -1
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +15 -3
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +44 -9
- package/dist-client/pages/figure-modeller-page.js +134 -92
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/ai-proposal-contract.test.ts +393 -12
- package/test/expect.ts +55 -0
- package/test/figure-source.test.ts +562 -0
- package/test/i18n-prefix-guard.test.ts +1 -1
- package/{client/modeller → test}/part-edits.test.ts +50 -45
- package/translations/en.json +31 -28
- package/translations/ja.json +16 -21
- package/translations/ko.json +41 -38
- package/translations/ms.json +16 -21
- package/translations/zh.json +16 -21
- package/client/modeller/figure-ask.ts +0 -265
- package/client/modeller/figure-source.test.ts +0 -352
- package/dist-client/modeller/figure-ask.d.ts +0 -40
- package/dist-client/modeller/figure-ask.js +0 -282
- package/dist-client/modeller/figure-ask.js.map +0 -1
|
@@ -6,6 +6,9 @@ import { compile, validate } from '@hatiolab/figure-model'
|
|
|
6
6
|
import type { FigureSource } from '@hatiolab/figure-model'
|
|
7
7
|
import { create, registerFigure } from '@hatiolab/things-scene'
|
|
8
8
|
|
|
9
|
+
import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
|
|
10
|
+
import type { ViewSettings } from './figure-view.js'
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* 저작 중인 것을 **보드에 놓았을 때**의 모습.
|
|
11
14
|
*
|
|
@@ -29,9 +32,6 @@ import { create, registerFigure } from '@hatiolab/things-scene'
|
|
|
29
32
|
* 뷰 모드로 세운다.
|
|
30
33
|
*/
|
|
31
34
|
|
|
32
|
-
/** 배경. 편집 캔버스와 같은 값을 쓴다 — 갈리면 두 화면이 다른 도구처럼 보인다. */
|
|
33
|
-
const WORKBENCH = '#7c7c7c'
|
|
34
|
-
|
|
35
35
|
/** 얼마나 키워 보나. */
|
|
36
36
|
export const PREVIEW_FACTOR = 2
|
|
37
37
|
|
|
@@ -57,6 +57,47 @@ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' |
|
|
|
57
57
|
{ name: 'x·y·z', axes: ['x', 'y', 'z'] }
|
|
58
58
|
]
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 이 도형의 상자 **밑면**이 씬의 어느 높이에 오나.
|
|
62
|
+
*
|
|
63
|
+
* 배치 기준이 하는 일이 이것 하나다 — 좌표를 뒤집는 것이 아니라 **높이를 정하는 것.**
|
|
64
|
+
* 바닥 기반은 땅에 서고, 천정 기반은 천장에서 제 키만큼 내려온 자리에 매달리고,
|
|
65
|
+
* 중심 기반은 그 사이에 뜬다. `operato-scene` 의 archetype 이 같은 말을 한다
|
|
66
|
+
* (`archetypes.ts`: ceiling — *zPos derived from ceiling minus depth*).
|
|
67
|
+
*/
|
|
68
|
+
function bottomOf(placement: FigureSource['placement'], height: number, ceiling: number): number {
|
|
69
|
+
switch (placement) {
|
|
70
|
+
case 'ceiling':
|
|
71
|
+
return ceiling - height
|
|
72
|
+
case 'center':
|
|
73
|
+
return (ceiling - height) / 2
|
|
74
|
+
case 'floor':
|
|
75
|
+
default:
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 밑면 높이를 씬이 받는 `zPos` 로 옮긴다.
|
|
82
|
+
*
|
|
83
|
+
* 씬은 부품 중심을 `zPos + geometricOffsetY` 에 놓고, 그 오프셋이 좌표 기준마다 다르다
|
|
84
|
+
* (`real-object.ts`). 그래서 같은 밑면을 말하는 `zPos` 가 기준마다 다른 수다 — 바닥
|
|
85
|
+
* 기준이면 밑면 그대로, 중심 기준이면 중심, 천장 기준이면 윗면.
|
|
86
|
+
*
|
|
87
|
+
* **여기가 유일하게 좌표 기준을 아는 자리다.** 저작한 값에는 이것이 안 들어간다.
|
|
88
|
+
*/
|
|
89
|
+
function zPosOf(bottom: number, height: number, reference: ViewSettings['placement']): number {
|
|
90
|
+
switch (reference) {
|
|
91
|
+
case 'space':
|
|
92
|
+
return bottom + height / 2
|
|
93
|
+
case 'inverted':
|
|
94
|
+
return bottom + height
|
|
95
|
+
case 'floor':
|
|
96
|
+
default:
|
|
97
|
+
return bottom
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
60
101
|
interface SceneHandle {
|
|
61
102
|
dispose?: () => void
|
|
62
103
|
resize?: () => void
|
|
@@ -64,6 +105,8 @@ interface SceneHandle {
|
|
|
64
105
|
root?: {
|
|
65
106
|
invalidate?: () => void
|
|
66
107
|
fitCameraToModel?: (margin?: number) => void
|
|
108
|
+
/** 보기 설정을 넣는 자리. 씬이 스스로 반응한다. */
|
|
109
|
+
set?: (patch: Record<string, unknown>) => void
|
|
67
110
|
/** 세워 둔 인스턴스들. clip 을 몰 때 전부에 같은 값을 준다. */
|
|
68
111
|
components?: { set?: (property: string, value: unknown) => void }[]
|
|
69
112
|
}
|
|
@@ -162,6 +205,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
162
205
|
/** 저작 중인 것. 저장 형식으로 받는다 — 보드가 소비하는 것이 이 형식이다. */
|
|
163
206
|
@property({ type: Object }) source?: FigureSource
|
|
164
207
|
|
|
208
|
+
/**
|
|
209
|
+
* 보기 설정. 저장되지 않는다(`figure-view.ts`).
|
|
210
|
+
*
|
|
211
|
+
* 한동안 이 화면만 조명과 환경을 **하드코딩**했다. 저작자가 환경 탭에서 무엇을 바꿔도
|
|
212
|
+
* 편집 캔버스만 달라지고 여기는 그대로였다 — 두 화면이 같은 것을 다르게 보여 주었다.
|
|
213
|
+
*/
|
|
214
|
+
@property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
|
|
215
|
+
|
|
165
216
|
@query('div[stage]') private stage!: HTMLDivElement
|
|
166
217
|
|
|
167
218
|
/** 세울 수 없는 사유. 없으면 세운 것이다. */
|
|
@@ -236,9 +287,33 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
236
287
|
}
|
|
237
288
|
}
|
|
238
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 보기 설정이 바뀌면 **다시 세우지 않고 씬에 넣는다** — 두 개만 빼고.
|
|
292
|
+
*
|
|
293
|
+
* 조명·환경·바탕은 씬이 스스로 반응한다(`three-capability` 의 `onchange`). 슬라이더를
|
|
294
|
+
* 끄는 동안 다시 세우면 카메라가 매 프레임 처음으로 돌아간다.
|
|
295
|
+
*
|
|
296
|
+
* 좌표 기준과 천장 높이는 다르다. 그 둘은 컴포넌트의 `zPos` 를 만들 때 쓰였으므로,
|
|
297
|
+
* 이미 세워 둔 것에 넣어 봐야 자리가 안 바뀐다. 그것만 다시 세운다.
|
|
298
|
+
*/
|
|
239
299
|
willUpdate(changed: Map<string, unknown>) {
|
|
240
300
|
if (!this.hasUpdated) return
|
|
241
|
-
|
|
301
|
+
|
|
302
|
+
if (changed.has('source')) {
|
|
303
|
+
this.standUp()
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!changed.has('view')) return
|
|
308
|
+
|
|
309
|
+
const before = changed.get('view') as ViewSettings | undefined
|
|
310
|
+
const now = this.view ?? DEFAULT_VIEW
|
|
311
|
+
if (before?.placement !== now.placement || before?.ceilingHeight !== now.ceilingHeight) {
|
|
312
|
+
this.standUp()
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.scene?.root?.set?.(sceneLook(now))
|
|
242
317
|
}
|
|
243
318
|
|
|
244
319
|
firstUpdated() {
|
|
@@ -268,7 +343,9 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
268
343
|
return
|
|
269
344
|
}
|
|
270
345
|
|
|
271
|
-
const
|
|
346
|
+
const view = this.view ?? DEFAULT_VIEW
|
|
347
|
+
/* 좌표 기준과 천장 높이가 컴포넌트의 `zPos` 를 만든다 — 서명에 안 넣으면 다시 세워도 그대로 선다. */
|
|
348
|
+
const signature = JSON.stringify([source, view.placement, view.ceilingHeight])
|
|
272
349
|
if (this.scene && this.standing === signature) return
|
|
273
350
|
|
|
274
351
|
const { errors } = validate(source)
|
|
@@ -304,24 +381,16 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
304
381
|
const COLUMNS = 4
|
|
305
382
|
const cell = { x: base.x * biggest * 1.25, z: base.z * biggest * 1.25 }
|
|
306
383
|
const rows = Math.ceil(PREVIEW_WAYS.length / COLUMNS)
|
|
307
|
-
const board = {
|
|
308
|
-
width: Math.round(cell.x * COLUMNS),
|
|
309
|
-
height: Math.round(cell.z * rows),
|
|
310
|
-
/*
|
|
311
|
-
판의 높이도 가장 큰 인스턴스를 담아야 한다.
|
|
312
|
-
|
|
313
|
-
안 주면 카메라가 평면만 보고 자리를 잡아서, 키가 큰 모델은 밑동만 보인다.
|
|
314
|
-
시그널타워를 2 배로 놓으면 2200 인데 판이 그것을 모르면 받침만 화면에 든다.
|
|
315
|
-
*/
|
|
316
|
-
depth: Math.round(base.y * biggest * 1.1)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
384
|
/*
|
|
320
385
|
씬의 낱말이 형식과 다르다 — `width` 가 형식의 x, `height` 가 z, `depth` 가 y 다.
|
|
321
386
|
옮기는 일을 여기 한 줄에 모아 둔다.
|
|
322
387
|
|
|
323
388
|
`depth` 를 늘 적는 이유가 있다. 안 적으면 평면 두 축의 기하평균으로 잡혀서,
|
|
324
389
|
「y 만 키운다」가 x·z 를 안 건드리는데도 높이가 안 오른다.
|
|
390
|
+
|
|
391
|
+
높이는 **배치 기준이 정한다.** 바닥 기반은 땅에서 올라오고, 천정 기반은 천장에
|
|
392
|
+
붙어 내려온다. 여덟을 나란히 세우는 자리라 이것이 눈에 바로 보인다 — 바닥 기반이면
|
|
393
|
+
밑면이, 천정 기반이면 윗면이 한 줄이 된다.
|
|
325
394
|
*/
|
|
326
395
|
const components = PREVIEW_WAYS.map((way, at) => {
|
|
327
396
|
const grow = growth(way)
|
|
@@ -329,6 +398,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
329
398
|
const row = Math.floor(at / COLUMNS)
|
|
330
399
|
const width = Math.round(base.x * grow.x)
|
|
331
400
|
const height = Math.round(base.z * grow.z)
|
|
401
|
+
const tall = Math.round(base.y * grow.y)
|
|
402
|
+
const bottom = bottomOf(source.placement, tall, view.ceilingHeight)
|
|
332
403
|
|
|
333
404
|
return {
|
|
334
405
|
type: source.type,
|
|
@@ -338,23 +409,49 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
338
409
|
top: Math.round(row * cell.z + (cell.z - height) / 2),
|
|
339
410
|
width,
|
|
340
411
|
height,
|
|
341
|
-
depth:
|
|
412
|
+
depth: tall,
|
|
413
|
+
zPos: Math.round(zPosOf(bottom, tall, view.placement))
|
|
342
414
|
}
|
|
343
415
|
})
|
|
344
416
|
|
|
417
|
+
const draft = {
|
|
418
|
+
width: Math.round(cell.x * COLUMNS),
|
|
419
|
+
height: Math.round(cell.z * rows),
|
|
420
|
+
/*
|
|
421
|
+
판의 높이도 가장 큰 인스턴스를 담아야 한다.
|
|
422
|
+
|
|
423
|
+
안 주면 카메라가 평면만 보고 자리를 잡아서, 키가 큰 모델은 밑동만 보인다.
|
|
424
|
+
시그널타워를 2 배로 놓으면 2200 인데 판이 그것을 모르면 받침만 화면에 든다.
|
|
425
|
+
|
|
426
|
+
가장 높은 **윗면**으로 잡는다. 천정 기반은 천장 높이에 매달리므로 제 키보다
|
|
427
|
+
훨씬 위에 있다 — 키만 보고 잡으면 천장에 걸린 것이 화면 밖으로 나간다.
|
|
428
|
+
*/
|
|
429
|
+
depth: Math.round(
|
|
430
|
+
Math.max(...components.map(c => bottomOf(source.placement, c.depth, view.ceilingHeight) + c.depth)) * 1.1
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
345
434
|
this.scene = create({
|
|
346
435
|
target: this.stage,
|
|
347
436
|
model: {
|
|
348
437
|
type: 'model-layer',
|
|
349
|
-
width:
|
|
350
|
-
height:
|
|
351
|
-
depth:
|
|
438
|
+
width: draft.width,
|
|
439
|
+
height: draft.height,
|
|
440
|
+
depth: draft.depth,
|
|
352
441
|
threed: true,
|
|
353
|
-
environment: 'studio',
|
|
354
|
-
grid: true,
|
|
355
442
|
sky: 'color',
|
|
356
|
-
|
|
357
|
-
|
|
443
|
+
/*
|
|
444
|
+
보기 설정을 그대로 싣는다.
|
|
445
|
+
|
|
446
|
+
여기만 조명·환경·바탕을 하드코딩하고 있었다. 저작자가 환경 탭에서 조명을
|
|
447
|
+
올려도 편집 캔버스만 밝아지고 이 화면은 그대로여서, 같은 것을 두 화면이
|
|
448
|
+
다르게 보여 주었다.
|
|
449
|
+
|
|
450
|
+
`sceneLook` 이 좌표 기준과 천장 높이를 빼고 준다 — 그 둘은 씬에 그대로 넣을
|
|
451
|
+
값이 아니라 위에서 `zPos` 를 정하는 데 쓴다. 좌표 기준만 여기 따로 싣는다.
|
|
452
|
+
*/
|
|
453
|
+
...sceneLook(view),
|
|
454
|
+
placement: view.placement,
|
|
358
455
|
pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
|
|
359
456
|
components
|
|
360
457
|
},
|
|
@@ -6,7 +6,7 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
|
|
6
6
|
|
|
7
7
|
import { i18next, localize } from '@operato/i18n'
|
|
8
8
|
import { ScrollbarStyles } from '@operato/styles'
|
|
9
|
-
import { compile, costCurveSvg, costOf, scoreOf, sizingReport, validate } from '@hatiolab/figure-model'
|
|
9
|
+
import { blocksRelease, compile, costCurveSvg, costOf, scoreOf, sizingReport, validate } from '@hatiolab/figure-model'
|
|
10
10
|
import type { FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
12
|
import { capacityOf } from './scene-budget.js'
|
|
@@ -439,7 +439,16 @@ export class FigureReport extends localize(i18next)(LitElement) {
|
|
|
439
439
|
/** 추이 그림을 제 크기로 펼쳤나. */
|
|
440
440
|
@state() private expanded = false
|
|
441
441
|
@state() private errors: { code: string; message: string; at?: string }[] = []
|
|
442
|
-
|
|
442
|
+
/**
|
|
443
|
+
* 형식이 권하지 않는 것 — **그중 둘은 발행을 막는다.**
|
|
444
|
+
*
|
|
445
|
+
* 등급을 형식에서 받는다(`blocksRelease`). 화면이 제 목록을 들면 형식에 코드가 하나 늘 때
|
|
446
|
+
* 조용히 「알림」으로 그려지고, 저작자는 발행이 막힌 것을 **눌러 보고 나서야** 안다.
|
|
447
|
+
*
|
|
448
|
+
* `actual` 은 얼마나 넘쳤나다. 「상자를 벗어난다」만으로는 2mm 인지 250mm 인지 모르고,
|
|
449
|
+
* 그 둘은 고치는 품이 다르다.
|
|
450
|
+
*/
|
|
451
|
+
@state() private violations: { code: string; message: string; blocking: boolean; actual?: number }[] = []
|
|
443
452
|
/**
|
|
444
453
|
* 크기를 바꿔 봤을 때 갈라지는가 — **발행을 막는 것**이다.
|
|
445
454
|
*
|
|
@@ -864,9 +873,13 @@ export class FigureReport extends localize(i18next)(LitElement) {
|
|
|
864
873
|
)}
|
|
865
874
|
${this.violations.map(
|
|
866
875
|
v => html`
|
|
867
|
-
<li violation>
|
|
868
|
-
<md-icon
|
|
869
|
-
<span
|
|
876
|
+
<li ?violation=${!v.blocking}>
|
|
877
|
+
<md-icon>${v.blocking ? 'block' : 'info'}</md-icon>
|
|
878
|
+
<span>
|
|
879
|
+
${v.message}${v.blocking && v.actual !== undefined
|
|
880
|
+
? html` <code at>${v.actual}</code>`
|
|
881
|
+
: ''}
|
|
882
|
+
</span>
|
|
870
883
|
</li>
|
|
871
884
|
`
|
|
872
885
|
)}
|
|
@@ -909,7 +922,12 @@ export class FigureReport extends localize(i18next)(LitElement) {
|
|
|
909
922
|
|
|
910
923
|
const result = validate(source)
|
|
911
924
|
this.errors = result.errors.map(e => ({ code: e.code, message: e.message, at: e.path }))
|
|
912
|
-
this.violations = result.violations.map(v => ({
|
|
925
|
+
this.violations = result.violations.map(v => ({
|
|
926
|
+
code: v.code,
|
|
927
|
+
message: v.message,
|
|
928
|
+
blocking: blocksRelease(v.code),
|
|
929
|
+
actual: v.actual
|
|
930
|
+
}))
|
|
913
931
|
|
|
914
932
|
if (this.errors.length > 0) {
|
|
915
933
|
// 형식을 어겼으면 컴파일이 던진다. 셈을 비우고 오류만 보여 준다.
|
|
@@ -4,7 +4,7 @@ import { customElement, property } from 'lit/decorators.js'
|
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { ScrollbarStyles } from '@operato/styles'
|
|
6
6
|
|
|
7
|
-
import { DEFAULT_VIEW, ENVIRONMENTS } from './figure-view.js'
|
|
7
|
+
import { DEFAULT_VIEW, ENVIRONMENTS, SCENE_REFERENCES } from './figure-view.js'
|
|
8
8
|
import type { EnvironmentName, ViewSettings } from './figure-view.js'
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -99,6 +99,7 @@ export class FigureSettings extends localize(i18next)(LitElement) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
select,
|
|
102
|
+
input[type='number'],
|
|
102
103
|
input[type='color'] {
|
|
103
104
|
width: 100%;
|
|
104
105
|
padding: 5px 6px;
|
|
@@ -158,10 +159,52 @@ export class FigureSettings extends localize(i18next)(LitElement) {
|
|
|
158
159
|
const view = this.view ?? DEFAULT_VIEW
|
|
159
160
|
|
|
160
161
|
return html`
|
|
162
|
+
<!--
|
|
163
|
+
놓이는 보드.
|
|
164
|
+
|
|
165
|
+
이 묶음이 맨 위인 것은, 나머지가 「어떻게 보이나」인데 이것은 「무엇으로 보고
|
|
166
|
+
있나」이기 때문이다. 미리보기가 보드에 놓인 모습을 말하는 자리이므로, 어떤 보드를
|
|
167
|
+
가정하는지가 먼저 정해져야 그 다음이 뜻을 갖는다.
|
|
168
|
+
|
|
169
|
+
도형 탭의 「배치 기준」과 짝이지 같은 것이 아니다. 저쪽은 이 도형이 어느 면에
|
|
170
|
+
붙는가(저장된다), 이쪽은 그 면을 가진 보드가 어느 쪽을 기준으로 재는가(저장 안 된다).
|
|
171
|
+
-->
|
|
161
172
|
<section>
|
|
162
|
-
<h3>${i18next.t('figure.label.
|
|
173
|
+
<h3>${i18next.t('figure.label.assumed-board')}</h3>
|
|
163
174
|
<p quiet>${i18next.t('figure.text.view-settings-are-not-saved')}</p>
|
|
164
175
|
|
|
176
|
+
<label field>
|
|
177
|
+
<span>${i18next.t('figure.label.scene-reference')}</span>
|
|
178
|
+
<select
|
|
179
|
+
.value=${view.placement}
|
|
180
|
+
@change=${(e: Event) =>
|
|
181
|
+
this.put({ placement: (e.target as HTMLSelectElement).value as ViewSettings['placement'] })}
|
|
182
|
+
>
|
|
183
|
+
${SCENE_REFERENCES.map(
|
|
184
|
+
name => html`<option value=${name} ?selected=${view.placement === name}>
|
|
185
|
+
${i18next.t(`figure.label.scene-reference-${name}`)}
|
|
186
|
+
</option>`
|
|
187
|
+
)}
|
|
188
|
+
</select>
|
|
189
|
+
</label>
|
|
190
|
+
<p quiet>${i18next.t('figure.text.scene-reference-explained')}</p>
|
|
191
|
+
|
|
192
|
+
<label field>
|
|
193
|
+
<span>${i18next.t('figure.label.ceiling-height')}</span>
|
|
194
|
+
<input
|
|
195
|
+
type="number"
|
|
196
|
+
min="0"
|
|
197
|
+
step="100"
|
|
198
|
+
.value=${String(view.ceilingHeight)}
|
|
199
|
+
@change=${(e: Event) => this.put({ ceilingHeight: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
200
|
+
/>
|
|
201
|
+
</label>
|
|
202
|
+
<p quiet>${i18next.t('figure.text.ceiling-height-explained')}</p>
|
|
203
|
+
</section>
|
|
204
|
+
|
|
205
|
+
<section>
|
|
206
|
+
<h3>${i18next.t('figure.label.guides')}</h3>
|
|
207
|
+
|
|
165
208
|
${this.toggle('grid', view.grid, i18next.t('figure.label.coordinate-ground'), i18next.t('figure.text.coordinate-ground-explained'))}
|
|
166
209
|
${this.toggle('gridLabels', view.gridLabels, i18next.t('figure.label.axis-labels'))}
|
|
167
210
|
${this.toggle('floor', view.floor, i18next.t('figure.label.scene-floor'), i18next.t('figure.text.floor-explained'))}
|
|
@@ -12,7 +12,7 @@ import { customElement, property, state } from 'lit/decorators.js'
|
|
|
12
12
|
import { i18next, localize } from '@operato/i18n'
|
|
13
13
|
import type { FigureSource } from '@hatiolab/figure-model'
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
16
16
|
import { DEFAULT_VIEW } from './figure-view.js'
|
|
17
17
|
import type { ViewSettings } from './figure-view.js'
|
|
18
18
|
|
|
@@ -156,7 +156,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
|
|
|
156
156
|
@property({ type: Number }) figureVersion = 0
|
|
157
157
|
@property({ type: Boolean }) dirty = false
|
|
158
158
|
|
|
159
|
-
@property({ type: Object })
|
|
159
|
+
@property({ type: Object }) draft?: FigureDraft
|
|
160
160
|
@property({ type: Array }) parts: PartModel[] = []
|
|
161
161
|
@property({ type: Object }) source?: FigureSource
|
|
162
162
|
@property({ type: Number }) selected = -1
|
|
@@ -175,7 +175,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
|
|
|
175
175
|
* 0 이면 아무것도 안 붙인다 — 「0」을 붙이면 없는 것이 있는 것처럼 자리를 차지한다.
|
|
176
176
|
*/
|
|
177
177
|
private get clipCount(): number | undefined {
|
|
178
|
-
const n = this.
|
|
178
|
+
const n = this.draft?.animations?.length ?? 0
|
|
179
179
|
return n === 0 ? undefined : n
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -196,20 +196,20 @@ export class FigureSide extends localize(i18next)(LitElement) {
|
|
|
196
196
|
.figureName=${this.figureName}
|
|
197
197
|
.figureCategory=${this.figureCategory}
|
|
198
198
|
.figureTags=${this.figureTags}
|
|
199
|
-
.
|
|
199
|
+
.draft=${this.draft}
|
|
200
200
|
.parts=${this.parts}
|
|
201
201
|
.selected=${this.selected}
|
|
202
202
|
></figure-inspector>
|
|
203
203
|
|
|
204
204
|
<figure-capabilities
|
|
205
205
|
?off=${this.tab !== 'capability'}
|
|
206
|
-
.
|
|
206
|
+
.draft=${this.draft}
|
|
207
207
|
.parts=${this.parts}
|
|
208
208
|
></figure-capabilities>
|
|
209
209
|
|
|
210
210
|
<figure-animations
|
|
211
211
|
?off=${this.tab !== 'animation'}
|
|
212
|
-
.
|
|
212
|
+
.draft=${this.draft}
|
|
213
213
|
.parts=${this.parts}
|
|
214
214
|
></figure-animations>
|
|
215
215
|
|
|
@@ -6,7 +6,7 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
|
|
|
6
6
|
* ## Why this is the only place that converts
|
|
7
7
|
*
|
|
8
8
|
* While authoring, the source of truth is the **scene model** -- part
|
|
9
|
-
* components sitting on a
|
|
9
|
+
* components sitting on a draft. `FigureSource` is produced from it when the
|
|
10
10
|
* asset is saved or scored.
|
|
11
11
|
*
|
|
12
12
|
* The two use different vocabularies. The scene inherited 2D canvas terms
|
|
@@ -27,21 +27,21 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
|
|
|
27
27
|
*
|
|
28
28
|
* ## The mapping
|
|
29
29
|
*
|
|
30
|
-
* The
|
|
30
|
+
* The draft is the base box; its centre is the format's origin.
|
|
31
31
|
*
|
|
32
32
|
* format scene
|
|
33
33
|
* ──────────────── ──────────────────────────────
|
|
34
|
-
* base.x
|
|
35
|
-
* base.y
|
|
36
|
-
* base.z
|
|
34
|
+
* base.x draft width
|
|
35
|
+
* base.y draft depth <- height
|
|
36
|
+
* base.z draft height
|
|
37
37
|
*
|
|
38
38
|
* size.x width
|
|
39
39
|
* size.y depth
|
|
40
40
|
* size.z height
|
|
41
41
|
*
|
|
42
|
-
* position.x left + width/2 -
|
|
43
|
-
* position.y zPos + depth/2 -
|
|
44
|
-
* position.z top + height/2 -
|
|
42
|
+
* position.x left + width/2 - draft width/2
|
|
43
|
+
* position.y zPos + depth/2 - draft depth/2 <- zPos is the underside
|
|
44
|
+
* position.z top + height/2 - draft height/2
|
|
45
45
|
*
|
|
46
46
|
* rotation.x rotationX (scene radians, format degrees)
|
|
47
47
|
* rotation.y -rotation
|
|
@@ -62,16 +62,32 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
|
|
|
62
62
|
* is the guard below actually earning its keep. The third showed up as a conveyor motor that stayed on the
|
|
63
63
|
* floor no matter what the sample said; the fourth as a hollowed tray that
|
|
64
64
|
* came out a solid block.
|
|
65
|
+
*
|
|
66
|
+
* All four passed the round-trip test, because the fixture it uses did not
|
|
67
|
+
* carry the field. So the test now reads the **field list the format declares**
|
|
68
|
+
* rather than trusting the fixture.
|
|
69
|
+
*
|
|
70
|
+
* `shape` is unpacked into `round`, `path` and `hollow` rather than carried
|
|
71
|
+
* whole, because the editor holds those as separate state. Every field added
|
|
72
|
+
* under `shape` costs a line here, and forgetting that line was the fourth
|
|
73
|
+
* of the four.
|
|
65
74
|
*/
|
|
66
75
|
/** 판 컴포넌트의 상태. 씬의 용어 그대로다. */
|
|
67
|
-
export interface
|
|
76
|
+
export interface FigureDraft {
|
|
68
77
|
/** 형식 버전. */
|
|
69
78
|
version?: number
|
|
70
|
-
|
|
79
|
+
/*
|
|
80
|
+
The three sizes keep the scene component's names, and the scene assigns its axes differently
|
|
81
|
+
from the format: here `depth` is the vertical one. Naming the axis alone ("the y axis") does
|
|
82
|
+
not say which of the two conventions is meant, and this interface sits on the boundary between
|
|
83
|
+
them — so each field names the `FigureSource` field it becomes. `toFigureSource` and
|
|
84
|
+
`fromFigureSource` below are the only places that may disagree with these three lines.
|
|
85
|
+
*/
|
|
86
|
+
/** 가로 너비 (mm) → `base.x`. */
|
|
71
87
|
width: number
|
|
72
|
-
/**
|
|
88
|
+
/** 앞뒤 깊이 (mm) → `base.z`. 저작자가 위에서 내려다볼 때의 세로다. */
|
|
73
89
|
height: number
|
|
74
|
-
/** 높이 (mm).
|
|
90
|
+
/** 높이 (mm) → `base.y`. 서 있는 방향이다. */
|
|
75
91
|
depth: number
|
|
76
92
|
/** 컴포넌트 타입 이름. **저장되는 식별자다.** */
|
|
77
93
|
figureType?: string
|
|
@@ -136,11 +152,21 @@ export interface PartModel {
|
|
|
136
152
|
|
|
137
153
|
/** 색 토큰 이름 (`palette.primary` 등). */
|
|
138
154
|
token?: string
|
|
139
|
-
|
|
155
|
+
/*
|
|
156
|
+
`FigurePart['material']` is required in the format, so these index into it directly. The
|
|
157
|
+
`NonNullable` on `shape` above is not the same case — `shape` is optional there.
|
|
158
|
+
*/
|
|
159
|
+
preset?: FigurePart['material']['preset']
|
|
140
160
|
flatShading?: boolean
|
|
141
161
|
transparent?: boolean
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
/**
|
|
163
|
+
* 자체발광. 없으면 안 빛난다.
|
|
164
|
+
*
|
|
165
|
+
* 바깥의 `token` 이 **꺼진 모습**이고 이 안의 `token` 이 **켜진 모습**이다 — 꺼진
|
|
166
|
+
* 전구는 빨갛지 않고 어두운 플라스틱이다.
|
|
167
|
+
*/
|
|
168
|
+
emissive?: FigurePart['material']['emissive']
|
|
169
|
+
surface?: FigurePart['material']['surface']
|
|
144
170
|
|
|
145
171
|
/* 아직 아무 일도 안 하지만 잃어버리지 않는다. */
|
|
146
172
|
materialSlot?: FigurePart['materialSlot']
|
|
@@ -173,7 +199,7 @@ function withoutEmpty<T extends object>(value: T): T {
|
|
|
173
199
|
}
|
|
174
200
|
|
|
175
201
|
/** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */
|
|
176
|
-
function round4(value: number): number {
|
|
202
|
+
export function round4(value: number): number {
|
|
177
203
|
return Math.round(value * 1e4) / 1e4
|
|
178
204
|
}
|
|
179
205
|
|
|
@@ -182,10 +208,30 @@ function round4(value: number): number {
|
|
|
182
208
|
*
|
|
183
209
|
* 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.
|
|
184
210
|
*/
|
|
185
|
-
export function halfOf(
|
|
186
|
-
return { x:
|
|
211
|
+
export function halfOf(draft: FigureDraft): Vec3 {
|
|
212
|
+
return { x: draft.width / 2, y: draft.depth / 2, z: draft.height / 2 }
|
|
187
213
|
}
|
|
188
214
|
|
|
215
|
+
/*
|
|
216
|
+
* ── The placement is not in these numbers ────────────────────────────────────
|
|
217
|
+
*
|
|
218
|
+
* `placement` says which reference surface a figure of this kind attaches to — a conveyor stands
|
|
219
|
+
* on the floor, an OHT hangs from the ceiling. That is a fact about the figure *type*, and the
|
|
220
|
+
* scene acts on it when an instance is placed: `floor` puts the box on the ground, `ceiling` at
|
|
221
|
+
* the ceiling height less the figure's own height. Nothing about the authored shape changes.
|
|
222
|
+
*
|
|
223
|
+
* So the conversion below does not read it, and the editing canvas does not either. The box is
|
|
224
|
+
* always `0 … base.y` there and `zPos` is always the part's underside. Two figures with identical
|
|
225
|
+
* parts and different placements have identical `parts` arrays, which is what the format says.
|
|
226
|
+
*
|
|
227
|
+
* This used to be wired the other way: the canvas fed the figure's placement to the model layer's
|
|
228
|
+
* `state.placement`, which is a scene-wide coordinate mode ('floor' | 'space' | 'inverted') that
|
|
229
|
+
* flips what `zPos` names for everything in that layer. It stood in for the archetype because the
|
|
230
|
+
* modeller holds exactly one figure — and it cost a whole day of defects, because every number the
|
|
231
|
+
* author typed then meant a different face depending on a dropdown. That coordinate mode is now
|
|
232
|
+
* where it belongs: a workbench setting in `figure-view.ts`, applied to the preview alone.
|
|
233
|
+
*/
|
|
234
|
+
|
|
189
235
|
/**
|
|
190
236
|
* 저작자가 보는 축이 씬의 어느 용어인가.
|
|
191
237
|
*
|
|
@@ -201,34 +247,34 @@ export const AXIS_KEYS = {
|
|
|
201
247
|
export type PlaneAxis = keyof typeof AXIS_KEYS
|
|
202
248
|
|
|
203
249
|
/** 판이 그 축으로 얼마나 넓은가. */
|
|
204
|
-
export function
|
|
205
|
-
return axis === 'x' ?
|
|
250
|
+
export function draftExtent(draft: FigureDraft, axis: PlaneAxis): number {
|
|
251
|
+
return axis === 'x' ? draft.width : axis === 'y' ? draft.depth : draft.height
|
|
206
252
|
}
|
|
207
253
|
|
|
208
254
|
/** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */
|
|
209
|
-
export function partToFigure(part: PartModel,
|
|
210
|
-
return partTo(part, halfOf(
|
|
255
|
+
export function partToFigure(part: PartModel, draft: FigureDraft): FigurePart {
|
|
256
|
+
return partTo(part, halfOf(draft))
|
|
211
257
|
}
|
|
212
258
|
|
|
213
259
|
/** 부품 하나를 씬 용어로 되돌린다. */
|
|
214
|
-
export function partFromFigure(part: FigurePart,
|
|
215
|
-
return partFrom(part, halfOf(
|
|
260
|
+
export function partFromFigure(part: FigurePart, draft: FigureDraft): PartModel {
|
|
261
|
+
return partFrom(part, halfOf(draft))
|
|
216
262
|
}
|
|
217
263
|
|
|
218
264
|
/** 씬 모델을 저장 형식으로 변환한다. */
|
|
219
|
-
export function toFigureSource(
|
|
220
|
-
const half = halfOf(
|
|
265
|
+
export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {
|
|
266
|
+
const half = halfOf(draft)
|
|
221
267
|
|
|
222
268
|
return withoutEmpty({
|
|
223
|
-
version:
|
|
224
|
-
type:
|
|
225
|
-
base: { x:
|
|
226
|
-
placement:
|
|
227
|
-
detailLevel:
|
|
228
|
-
styleKit:
|
|
269
|
+
version: draft.version ?? FIGURE_SOURCE_VERSION,
|
|
270
|
+
type: draft.figureType ?? '',
|
|
271
|
+
base: { x: draft.width, y: draft.depth, z: draft.height },
|
|
272
|
+
placement: draft.placement,
|
|
273
|
+
detailLevel: draft.detailLevel,
|
|
274
|
+
styleKit: draft.styleKit,
|
|
229
275
|
parts: parts.map(part => partTo(part, half)),
|
|
230
|
-
animations:
|
|
231
|
-
capabilities:
|
|
276
|
+
animations: draft.animations,
|
|
277
|
+
capabilities: draft.capabilities
|
|
232
278
|
}) as FigureSource
|
|
233
279
|
}
|
|
234
280
|
|
|
@@ -282,10 +328,10 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
|
|
|
282
328
|
}
|
|
283
329
|
|
|
284
330
|
/** 형식을 씬 모델로 편다. */
|
|
285
|
-
export function fromFigureSource(source: FigureSource): {
|
|
331
|
+
export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
|
|
286
332
|
const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
|
|
287
333
|
|
|
288
|
-
const
|
|
334
|
+
const draft = withoutEmpty({
|
|
289
335
|
version: source.version,
|
|
290
336
|
width: source.base.x,
|
|
291
337
|
height: source.base.z,
|
|
@@ -296,9 +342,9 @@ export function fromFigureSource(source: FigureSource): { board: BoardModel; par
|
|
|
296
342
|
styleKit: source.styleKit,
|
|
297
343
|
animations: source.animations,
|
|
298
344
|
capabilities: source.capabilities
|
|
299
|
-
}) as
|
|
345
|
+
}) as FigureDraft
|
|
300
346
|
|
|
301
|
-
return {
|
|
347
|
+
return { draft, parts: source.parts.map(part => partFrom(part, half)) }
|
|
302
348
|
}
|
|
303
349
|
|
|
304
350
|
function partFrom(part: FigurePart, half: Vec3): PartModel {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './figure-canvas.js'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 카드에 쓸 그림을 찍는다 — **숨은 자리에 저작 캔버스를 하나 더 세워서.**
|
|
@@ -28,7 +28,7 @@ const WIDTH = 480
|
|
|
28
28
|
const HEIGHT = 360
|
|
29
29
|
|
|
30
30
|
interface Shooter extends HTMLElement {
|
|
31
|
-
|
|
31
|
+
draft?: FigureDraft
|
|
32
32
|
parts?: PartModel[]
|
|
33
33
|
viewOnly?: boolean
|
|
34
34
|
snapshot?: (width?: number, height?: number) => Promise<string | undefined>
|
|
@@ -71,10 +71,10 @@ function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export async function captureThumbnail(
|
|
74
|
-
|
|
74
|
+
draft: FigureDraft | undefined,
|
|
75
75
|
parts: PartModel[]
|
|
76
76
|
): Promise<string | undefined> {
|
|
77
|
-
if (!
|
|
77
|
+
if (!draft || !parts.length) {
|
|
78
78
|
return undefined
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -89,7 +89,7 @@ export async function captureThumbnail(
|
|
|
89
89
|
|
|
90
90
|
const shooter = document.createElement('figure-canvas') as Shooter
|
|
91
91
|
shooter.viewOnly = true
|
|
92
|
-
shooter.
|
|
92
|
+
shooter.draft = draft
|
|
93
93
|
shooter.parts = parts
|
|
94
94
|
shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
|
|
95
95
|
|