@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.
Files changed (78) hide show
  1. package/client/modeller/figure-animations.ts +12 -12
  2. package/client/modeller/figure-canvas.ts +32 -48
  3. package/client/modeller/figure-capabilities.ts +10 -10
  4. package/client/modeller/figure-inspector.ts +39 -28
  5. package/client/modeller/figure-parts.ts +1 -1
  6. package/client/modeller/figure-preview.ts +122 -25
  7. package/client/modeller/figure-report.ts +24 -6
  8. package/client/modeller/figure-settings.ts +45 -2
  9. package/client/modeller/figure-side.ts +6 -6
  10. package/client/modeller/figure-source.ts +84 -38
  11. package/client/modeller/figure-thumbnail.ts +5 -5
  12. package/client/modeller/figure-view.ts +53 -0
  13. package/client/modeller/part-edits.ts +36 -36
  14. package/client/modeller/proposal.ts +25 -4
  15. package/client/pages/figure-modeller-page.ts +142 -98
  16. package/dist-client/modeller/figure-animations.d.ts +4 -4
  17. package/dist-client/modeller/figure-animations.js +10 -10
  18. package/dist-client/modeller/figure-animations.js.map +1 -1
  19. package/dist-client/modeller/figure-canvas.d.ts +7 -7
  20. package/dist-client/modeller/figure-canvas.js +27 -40
  21. package/dist-client/modeller/figure-canvas.js.map +1 -1
  22. package/dist-client/modeller/figure-capabilities.d.ts +2 -2
  23. package/dist-client/modeller/figure-capabilities.js +9 -9
  24. package/dist-client/modeller/figure-capabilities.js.map +1 -1
  25. package/dist-client/modeller/figure-inspector.d.ts +16 -5
  26. package/dist-client/modeller/figure-inspector.js +38 -27
  27. package/dist-client/modeller/figure-inspector.js.map +1 -1
  28. package/dist-client/modeller/figure-parts.d.ts +1 -1
  29. package/dist-client/modeller/figure-parts.js +1 -1
  30. package/dist-client/modeller/figure-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-preview.d.ts +39 -0
  32. package/dist-client/modeller/figure-preview.js +112 -23
  33. package/dist-client/modeller/figure-preview.js.map +1 -1
  34. package/dist-client/modeller/figure-report.d.ts +9 -0
  35. package/dist-client/modeller/figure-report.js +23 -5
  36. package/dist-client/modeller/figure-report.js.map +1 -1
  37. package/dist-client/modeller/figure-settings.js +42 -2
  38. package/dist-client/modeller/figure-settings.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +2 -2
  40. package/dist-client/modeller/figure-side.js +5 -5
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +38 -23
  43. package/dist-client/modeller/figure-source.js +40 -21
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
  46. package/dist-client/modeller/figure-thumbnail.js +3 -3
  47. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  48. package/dist-client/modeller/figure-view.d.ts +45 -0
  49. package/dist-client/modeller/figure-view.js +27 -0
  50. package/dist-client/modeller/figure-view.js.map +1 -1
  51. package/dist-client/modeller/part-edits.d.ts +16 -16
  52. package/dist-client/modeller/part-edits.js +35 -35
  53. package/dist-client/modeller/part-edits.js.map +1 -1
  54. package/dist-client/modeller/proposal.d.ts +1 -1
  55. package/dist-client/modeller/proposal.js +15 -3
  56. package/dist-client/modeller/proposal.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.d.ts +44 -9
  58. package/dist-client/pages/figure-modeller-page.js +134 -92
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/route.d.ts +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-server/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +4 -4
  64. package/test/ai-proposal-contract.test.ts +393 -12
  65. package/test/expect.ts +55 -0
  66. package/test/figure-source.test.ts +562 -0
  67. package/test/i18n-prefix-guard.test.ts +1 -1
  68. package/{client/modeller → test}/part-edits.test.ts +50 -45
  69. package/translations/en.json +31 -28
  70. package/translations/ja.json +16 -21
  71. package/translations/ko.json +41 -38
  72. package/translations/ms.json +16 -21
  73. package/translations/zh.json +16 -21
  74. package/client/modeller/figure-ask.ts +0 -265
  75. package/client/modeller/figure-source.test.ts +0 -352
  76. package/dist-client/modeller/figure-ask.d.ts +0 -40
  77. package/dist-client/modeller/figure-ask.js +0 -282
  78. 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
- if (changed.has('source')) this.standUp()
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 signature = JSON.stringify(source)
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: Math.round(base.y * grow.y)
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: board.width,
350
- height: board.height,
351
- depth: board.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
- skyColor: WORKBENCH,
357
- floor: false,
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
- @state() private violations: { code: string; message: string }[] = []
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>info</md-icon>
869
- <span>${v.message}</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 => ({ code: v.code, message: v.message }))
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.guides')}</h3>
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 { BoardModel, PartModel } from './figure-source.js'
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 }) board?: BoardModel
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.board?.animations?.length ?? 0
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
- .board=${this.board}
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
- .board=${this.board}
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
- .board=${this.board}
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 board. `FigureSource` is produced from it when the
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 board is the base box; its centre is the format's origin.
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 board width
35
- * base.y board depth <- height
36
- * base.z board height
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 - board width/2
43
- * position.y zPos + depth/2 - board depth/2 <- zPos is the underside
44
- * position.z top + height/2 - board 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 BoardModel {
76
+ export interface FigureDraft {
68
77
  /** 형식 버전. */
69
78
  version?: number
70
- /** 가로 너비 (mm). 씬의 x 축. */
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
- /** 세로 깊이 (mm). 씬의 y 축 (평면에서는 세로, 저작자가 위에서 내려다보는 세로). */
88
+ /** 앞뒤 깊이 (mm) `base.z`. 저작자가 위에서 내려다볼 때의 세로다. */
73
89
  height: number
74
- /** 높이 (mm). 씬의 z 축. */
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
- preset?: FigurePart['material'] extends infer M ? (M extends { preset?: infer P } ? P : never) : never
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
- emissive?: NonNullable<FigurePart['material']>['emissive']
143
- surface?: NonNullable<FigurePart['material']>['surface']
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(board: BoardModel): Vec3 {
186
- return { x: board.width / 2, y: board.depth / 2, z: board.height / 2 }
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 boardExtent(board: BoardModel, axis: PlaneAxis): number {
205
- return axis === 'x' ? board.width : axis === 'y' ? board.depth : board.height
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, board: BoardModel): FigurePart {
210
- return partTo(part, halfOf(board))
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, board: BoardModel): PartModel {
215
- return partFrom(part, halfOf(board))
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(board: BoardModel, parts: PartModel[]): FigureSource {
220
- const half = halfOf(board)
265
+ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {
266
+ const half = halfOf(draft)
221
267
 
222
268
  return withoutEmpty({
223
- version: board.version ?? FIGURE_SOURCE_VERSION,
224
- type: board.figureType ?? '',
225
- base: { x: board.width, y: board.depth, z: board.height },
226
- placement: board.placement,
227
- detailLevel: board.detailLevel,
228
- styleKit: board.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: board.animations,
231
- capabilities: board.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): { board: BoardModel; parts: PartModel[] } {
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 board = withoutEmpty({
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 BoardModel
345
+ }) as FigureDraft
300
346
 
301
- return { board, parts: source.parts.map(part => partFrom(part, half)) }
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 { BoardModel, PartModel } from './figure-source.js'
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
- board?: BoardModel
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
- board: BoardModel | undefined,
74
+ draft: FigureDraft | undefined,
75
75
  parts: PartModel[]
76
76
  ): Promise<string | undefined> {
77
- if (!board || !parts.length) {
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.board = board
92
+ shooter.draft = draft
93
93
  shooter.parts = parts
94
94
  shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
95
95