@things-factory/figure-ui 10.1.16 → 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 (77) 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 +52 -32
  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 +64 -58
  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 +17 -15
  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 +9 -10
  58. package/dist-client/pages/figure-modeller-page.js +60 -54
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/tsconfig.tsbuildinfo +1 -1
  61. package/dist-server/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +4 -4
  63. package/test/ai-proposal-contract.test.ts +393 -12
  64. package/test/expect.ts +55 -0
  65. package/test/figure-source.test.ts +562 -0
  66. package/test/i18n-prefix-guard.test.ts +1 -1
  67. package/{client/modeller → test}/part-edits.test.ts +50 -45
  68. package/translations/en.json +31 -28
  69. package/translations/ja.json +16 -21
  70. package/translations/ko.json +41 -38
  71. package/translations/ms.json +16 -21
  72. package/translations/zh.json +16 -21
  73. package/client/modeller/figure-ask.ts +0 -265
  74. package/client/modeller/figure-source.test.ts +0 -352
  75. package/dist-client/modeller/figure-ask.d.ts +0 -40
  76. package/dist-client/modeller/figure-ask.js +0 -282
  77. 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
@@ -73,7 +73,7 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
73
73
  * of the four.
74
74
  */
75
75
  /** 판 컴포넌트의 상태. 씬의 용어 그대로다. */
76
- export interface BoardModel {
76
+ export interface FigureDraft {
77
77
  /** 형식 버전. */
78
78
  version?: number
79
79
  /*
@@ -199,7 +199,7 @@ function withoutEmpty<T extends object>(value: T): T {
199
199
  }
200
200
 
201
201
  /** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */
202
- function round4(value: number): number {
202
+ export function round4(value: number): number {
203
203
  return Math.round(value * 1e4) / 1e4
204
204
  }
205
205
 
@@ -208,10 +208,30 @@ function round4(value: number): number {
208
208
  *
209
209
  * 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.
210
210
  */
211
- export function halfOf(board: BoardModel): Vec3 {
212
- 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 }
213
213
  }
214
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
+
215
235
  /**
216
236
  * 저작자가 보는 축이 씬의 어느 용어인가.
217
237
  *
@@ -227,34 +247,34 @@ export const AXIS_KEYS = {
227
247
  export type PlaneAxis = keyof typeof AXIS_KEYS
228
248
 
229
249
  /** 판이 그 축으로 얼마나 넓은가. */
230
- export function boardExtent(board: BoardModel, axis: PlaneAxis): number {
231
- 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
232
252
  }
233
253
 
234
254
  /** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */
235
- export function partToFigure(part: PartModel, board: BoardModel): FigurePart {
236
- return partTo(part, halfOf(board))
255
+ export function partToFigure(part: PartModel, draft: FigureDraft): FigurePart {
256
+ return partTo(part, halfOf(draft))
237
257
  }
238
258
 
239
259
  /** 부품 하나를 씬 용어로 되돌린다. */
240
- export function partFromFigure(part: FigurePart, board: BoardModel): PartModel {
241
- return partFrom(part, halfOf(board))
260
+ export function partFromFigure(part: FigurePart, draft: FigureDraft): PartModel {
261
+ return partFrom(part, halfOf(draft))
242
262
  }
243
263
 
244
264
  /** 씬 모델을 저장 형식으로 변환한다. */
245
- export function toFigureSource(board: BoardModel, parts: PartModel[]): FigureSource {
246
- const half = halfOf(board)
265
+ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {
266
+ const half = halfOf(draft)
247
267
 
248
268
  return withoutEmpty({
249
- version: board.version ?? FIGURE_SOURCE_VERSION,
250
- type: board.figureType ?? '',
251
- base: { x: board.width, y: board.depth, z: board.height },
252
- placement: board.placement,
253
- detailLevel: board.detailLevel,
254
- 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,
255
275
  parts: parts.map(part => partTo(part, half)),
256
- animations: board.animations,
257
- capabilities: board.capabilities
276
+ animations: draft.animations,
277
+ capabilities: draft.capabilities
258
278
  }) as FigureSource
259
279
  }
260
280
 
@@ -308,10 +328,10 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
308
328
  }
309
329
 
310
330
  /** 형식을 씬 모델로 편다. */
311
- export function fromFigureSource(source: FigureSource): { board: BoardModel; parts: PartModel[] } {
331
+ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
312
332
  const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
313
333
 
314
- const board = withoutEmpty({
334
+ const draft = withoutEmpty({
315
335
  version: source.version,
316
336
  width: source.base.x,
317
337
  height: source.base.z,
@@ -322,9 +342,9 @@ export function fromFigureSource(source: FigureSource): { board: BoardModel; par
322
342
  styleKit: source.styleKit,
323
343
  animations: source.animations,
324
344
  capabilities: source.capabilities
325
- }) as BoardModel
345
+ }) as FigureDraft
326
346
 
327
- return { board, parts: source.parts.map(part => partFrom(part, half)) }
347
+ return { draft, parts: source.parts.map(part => partFrom(part, half)) }
328
348
  }
329
349
 
330
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