@things-factory/figure-ui 10.1.29 → 10.1.32

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 (29) hide show
  1. package/client/{themes → harness-theme}/brand.css +28 -11
  2. package/{dist-client/themes → client/harness-theme}/dark.css +4 -4
  3. package/client/{themes → harness-theme}/light.css +6 -6
  4. package/client/modeller/base-box.ts +74 -3
  5. package/client/modeller/figure-history-panel.ts +30 -0
  6. package/client/modeller/figure-preview.ts +1 -1
  7. package/client/pages/figure-modeller-page.ts +26 -0
  8. package/dist-client/{themes → harness-theme}/brand.css +28 -11
  9. package/{client/themes → dist-client/harness-theme}/dark.css +4 -4
  10. package/dist-client/{themes → harness-theme}/light.css +6 -6
  11. package/dist-client/modeller/base-box.d.ts +24 -0
  12. package/dist-client/modeller/base-box.js +61 -3
  13. package/dist-client/modeller/base-box.js.map +1 -1
  14. package/dist-client/modeller/figure-history-panel.d.ts +14 -0
  15. package/dist-client/modeller/figure-history-panel.js +28 -0
  16. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  17. package/dist-client/modeller/figure-preview.js.map +1 -1
  18. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  19. package/dist-client/pages/figure-modeller-page.js +27 -0
  20. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  21. package/dist-client/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +3 -3
  23. package/test/base-box.test.ts +58 -1
  24. package/test/brand-palette.test.ts +99 -18
  25. package/translations/en.json +1 -0
  26. package/translations/ja.json +1 -0
  27. package/translations/ko.json +1 -0
  28. package/translations/ms.json +1 -0
  29. package/translations/zh.json +1 -0
@@ -9,13 +9,24 @@
9
9
  * operato-plant 가 같은 문제를 겪고 같은 방식으로 풀었다(`operato-plant/assets/brand.css`).
10
10
  * 그 구조를 그대로 따른다 — 값은 여기 한 번만 적고, 앱 팔레트와 하네스가 이 파일을 읽는다.
11
11
  *
12
+ * ── 왜 `themes/` 가 아닌가 ─────────────────────────────────────────────────
13
+ * `@things-factory/builder` 의 `ThemeOverridePlugin` 이 모든 모듈의 `client/themes/*.css` 를
14
+ * **파일 이름 하나당 하나만** 골라 테마 묶음에 싣는다(앱이 맨 앞, 나머지는 건너뜀). 그래서
15
+ * 모듈이 `themes/light.css` 를 두면 앱이 같은 이름을 갖는 순간 통째로 빠지고, 이름을 비켜서
16
+ * 두면 앞의 것과 **같은 선택자로 md-sys 칸을 덮어** 묶음 순서가 색을 정하게 된다. md-sys 슬롯은
17
+ * 모듈이 선언하지 않는다 — shell 이 열고 앱이 채운다(ADR-0060 결정 2).
18
+ *
19
+ * 이 폴더의 셋은 **하네스가 쓰는 것**이고, `themes` 밖에 있으므로 플러그인이 아예 보지 않는다.
20
+ * 앱 화면의 색은 앱의 테마 파일이 정한다. 여기 값을 앱 색으로 알고 고치지 말 것 — 지금 이 셋은
21
+ * navy 이고 앱은 teal 이다(수석 아키텍트가 사용자께 올려 둔 미결이다).
22
+ *
12
23
  * ── 브랜드 색만 여기 둔다 ──────────────────────────────────────────────────
13
- * 중성색은 여기 없다. 그것은 `client/themes/light.css` 가 갖는다. 여기 있는 것은 **제품을
24
+ * 중성색은 여기 없다. 그것은 `client/harness-theme/light.css` 가 갖는다. 여기 있는 것은 **제품을
14
25
  * 가리키는 색**뿐이고, 그것만 절대 갈라지면 안 된다.
15
26
  *
16
27
  * ── 밝은 것과 어두운 것을 함께 든다 ────────────────────────────────────────
17
- * 어두운 바탕에서 `#154473` 은 가라앉고(대비 1.6), 밝은 바탕에서 `#64a9f3` 은 글자에 못 쓴다
18
- * (대비 2.4). 그래서 같은 이름이 밝기마다 다른 값을 갖는다 — 이름이 뜻이고 값은 자리에 따라
28
+ * 어두운 바탕에서 `#00727e` 는 가라앉고(대비 3.21), 밝은 바탕에서 `#22c3cf` 는 글자에 못 쓴다
29
+ * (대비 2.15). 그래서 같은 이름이 밝기마다 다른 값을 갖는다 — 이름이 뜻이고 값은 자리에 따라
19
30
  * 다르다. plant · twin 과 같은 규약이다.
20
31
  *
21
32
  * ── 색은 하나뿐이다 ────────────────────────────────────────────────────────
@@ -29,23 +40,29 @@
29
40
 
30
41
  :root {
31
42
  /*
32
- * figure — 네이비 252°.
43
+ * figure — 딥 틸 186°.
44
+ *
45
+ * 흰 글자 대비 5.66 으로 작은 글자 기준(4.5)을 넘는다. [측정 2026-09-19]
33
46
  *
34
- * OKLCH L* 38 · C 0.095 · H 252. 흰 글자 대비 9.97 로 작은 글자 기준(4.5)에 여유가 크다.
47
+ * **이 값은 앱이 화면에 쓰는 것과 같다**(`operato-figure` 의 팔레트 · `assets/brand.css` 의
48
+ * `--brand-figure`). 한동안 이 파일은 네이비 `#154473` 이었고 앱은 틸이었다 — 이 파일이 테마
49
+ * 묶음에 실리지 않고 하네스만 읽었으므로 아무 오류 없이 두 얼굴이었다. 그것을 막으려고 생긴
50
+ * 파일이 그것을 못 막고 있었다. 2026-09-19 사용자 결정으로 앱 쪽 값에 맞췄다.
35
51
  *
36
52
  * 채도를 낮게 잡은 것이 이 색의 요점이다. 이 도구의 화면에서 색이 말해야 하는 것은 **저작자가
37
53
  * 고른 부품 색**이다. 틀이 진한 색으로 소리를 내면 캔버스 안의 색을 판단할 수 없다. 그래서
38
54
  * 브랜드 색은 깊고 조용하게 두고, 고른 것의 밑줄 · 기본 단추 · 링크에만 좁게 쓴다.
39
55
  */
40
- --brand-figure: #154473;
56
+ --figure-brand: #00727e;
41
57
 
42
58
  /*
43
- * 어두운 면 위에서 쓰는 같은 색. L* 72 · C 0.130.
59
+ * 어두운 면 위에서 쓰는 같은 색. 어두운 면(`#12161b`) 대비 8.45. [측정 2026-09-19]
44
60
  *
45
- * 3D 캔버스의 선택 테두리와 어두운 배경의 강조가 이 값이다. 어두운 면(#12161b) 대비 7.02.
61
+ * 3D 캔버스의 선택 테두리와 어두운 배경의 강조가 이 값이다. 앱 팔레트의 어두운 쪽 브랜드 색과
62
+ * 같다.
46
63
  */
47
- --brand-figure-2: #64a9f3;
64
+ --figure-brand-2: #22c3cf;
48
65
 
49
- /** 아주 옅게 깔 때 — 고른 줄의 바탕. 위 색과 같은 색상각이다. */
50
- --brand-figure-wash: #eaf3fd;
66
+ /** 아주 옅게 깔 때 — 고른 줄의 바탕. 위 색을 흰 바탕에 8% 로 깐 값이다. */
67
+ --figure-brand-wash: #ebf4f5;
51
68
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * ── 브랜드 색이 밝은 쪽으로 바뀐다 ────────────────────────────────────────
10
10
  * `#154473` 은 어두운 바탕에서 대비 1.6 이라 보이지 않는다. 그래서 어두운 면에서는
11
- * `--brand-figure-2`(#64a9f3)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
11
+ * `--figure-brand-2`(#22c3cf)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
12
12
  * plant · twin 과 같은 규약이다.
13
13
  *
14
14
  * [측정] 어두운 면(#12161b) 대비 — #154473 1.60 · #64a9f3 7.02
@@ -40,12 +40,12 @@
40
40
  --md-sys-color-on-surface-dim: #7d848d;
41
41
 
42
42
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
43
- --md-sys-color-primary: var(--brand-figure-2);
43
+ --md-sys-color-primary: var(--figure-brand-2);
44
44
  --md-sys-color-on-primary: #0d1116;
45
- --md-sys-color-inverse-primary: var(--brand-figure);
45
+ --md-sys-color-inverse-primary: var(--figure-brand);
46
46
 
47
47
  --md-sys-color-primary-container: #1c2b3d;
48
- --md-sys-color-on-primary-container: var(--brand-figure-2);
48
+ --md-sys-color-on-primary-container: var(--figure-brand-2);
49
49
 
50
50
  --md-sys-color-secondary-container: #1a1f25;
51
51
  --md-sys-color-on-secondary-container: #a8afb8;
@@ -3,8 +3,8 @@
3
3
  /*
4
4
  * figure 라이트 팔레트 — 이 파일이 이 도구 색의 **정본**이다.
5
5
  *
6
- * 브랜드 색은 `client/themes/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--brand-figure` ·
7
- * `--brand-figure-2` · `--brand-figure-wash` 셋이다. 값을 여기 다시 적지 않는다.
6
+ * 브랜드 색은 `client/harness-theme/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--figure-brand` ·
7
+ * `--figure-brand-2` · `--figure-brand-wash` 셋이다. 값을 여기 다시 적지 않는다.
8
8
  *
9
9
  * ── 이 도구에서 색이 말해야 하는 것 ────────────────────────────────────────
10
10
  * **저작자가 고른 부품 색**이다. 화면 한가운데 캔버스가 있고, 거기 놓인 부품의 색을 보고
@@ -52,16 +52,16 @@
52
52
  --md-sys-color-on-surface-dim: #6a6f76;
53
53
 
54
54
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
55
- --md-sys-color-primary: var(--brand-figure);
55
+ --md-sys-color-primary: var(--figure-brand);
56
56
  --md-sys-color-on-primary: #ffffff;
57
- --md-sys-color-inverse-primary: var(--brand-figure-2);
57
+ --md-sys-color-inverse-primary: var(--figure-brand-2);
58
58
 
59
59
  /*
60
60
  * 고른 줄의 바탕. 이것만 유일하게 색이 든 면이고, 그것도 거의 흰색이다(L* 96).
61
61
  * 그 위에 오는 글자는 브랜드 색이며 대비 8.90 이다.
62
62
  */
63
- --md-sys-color-primary-container: var(--brand-figure-wash);
64
- --md-sys-color-on-primary-container: var(--brand-figure);
63
+ --md-sys-color-primary-container: var(--figure-brand-wash);
64
+ --md-sys-color-on-primary-container: var(--figure-brand);
65
65
 
66
66
  /*
67
67
  * 두 번째 면 — 파스텔을 쓰지 않으므로 무채색이다. 칩과 배지가 이것을 깐다.
@@ -2,6 +2,8 @@
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
4
 
5
+ import { rotatedExtentOf } from '@hatiolab/figure-model'
6
+
5
7
  import type { FigureDraft, PartModel } from './figure-source.js'
6
8
 
7
9
  /**
@@ -29,6 +31,13 @@ import type { FigureDraft, PartModel } from './figure-source.js'
29
31
  * 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에
30
32
  * 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미
31
33
  * `part-outside-base` 로 말한다.
34
+ *
35
+ * ## 돌아간 부품은 돌아간 부피로 잰다
36
+ *
37
+ * 45 도 돌린 판은 제 `size` 보다 넓게 자리를 차지한다. 발행 관문은 그것을 `rotatedExtentOf` 로
38
+ * 재는데 여기서는 `width`·`height` 를 그대로 썼다. 그래서 **회전한 부품이 있는 도형은 「부품에
39
+ * 맞추기」를 눌러도 발행이 막힌 채로 남았다** — 표본에서 CONVEYOR(부품 17 중 4 가 회전)와
40
+ * ROLL_CRADLE(20 중 13)이 그랬다(2026-09-19 확인). 같은 함수로 재서 둘이 어긋나지 않게 한다.
32
41
  */
33
42
 
34
43
  export interface BaseSize {
@@ -51,6 +60,60 @@ export const baseOf = (draft: FigureDraft): BaseSize => ({
51
60
  depth: draft.depth
52
61
  })
53
62
 
63
+ const DEG = 180 / Math.PI
64
+
65
+ /**
66
+ * 이 부품이 차지하는 부피 — **형식의 축으로**.
67
+ *
68
+ * 저작면과 형식의 축 이름이 엇갈린다(`figure-source.ts` 의 표): 저작면의 `depth` 가 형식의 y 이고
69
+ * `height` 가 형식의 z 다. 회전 이름도 엇갈린다(`rotationY` → 형식의 z). 그 표를 여기서 다시
70
+ * 적지 않고 같은 모양으로 옮겨, `rotatedExtentOf` 가 관문에서와 같은 답을 내게 한다.
71
+ */
72
+ function extentOf(part: PartModel): { x: number; y: number; z: number } {
73
+ return rotatedExtentOf({
74
+ transform: {
75
+ position: { x: 0, y: 0, z: 0 },
76
+ size: { x: part.width, y: part.depth, z: part.height },
77
+ rotation: {
78
+ x: part.rotationX ? part.rotationX * DEG : 0,
79
+ y: part.rotation ? -part.rotation * DEG : 0,
80
+ z: part.rotationY ? part.rotationY * DEG : 0
81
+ }
82
+ }
83
+ } as never)
84
+ }
85
+
86
+ /**
87
+ * 부품 전부를 **바닥에 앉힌다** — 가장 낮은 것이 상자 밑면에 닿도록 전부를 같은 값만큼 옮긴다.
88
+ *
89
+ * ## 왜 전부를 옮기나
90
+ *
91
+ * 관문이 막는 것은 「가장 낮은 부품이 밑면에 안 닿는다」다(`parts-off-placement-face`). 그 부품
92
+ * 하나만 내리면 관문은 통과하지만 도형이 달라진다 — 프레임에 붙어 있던 발이 떨어진다. 부품 사이의
93
+ * 자리는 저작자가 정한 것이므로 그것을 지키고 전체를 옮긴다.
94
+ *
95
+ * ## 위로도 옮긴다
96
+ *
97
+ * 바닥 **아래로** 내려간 부품이 있으면 올린다. 그때는 위쪽이 상자를 넘을 수 있으므로 저작자가
98
+ * 「부품에 맞추기」를 이어서 누르게 된다 — 두 조치가 각자 한 가지만 한다.
99
+ *
100
+ * 앉힐 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.
101
+ */
102
+ export function partsSettledOnFloor(parts: readonly PartModel[]): readonly PartModel[] {
103
+ if (parts.length === 0) return parts
104
+
105
+ let lowest = Infinity
106
+ for (const part of parts) {
107
+ const extent = extentOf(part)
108
+ lowest = Math.min(lowest, part.zPos + part.depth / 2 - extent.y / 2)
109
+ }
110
+
111
+ const shift = round(lowest)
112
+ if (shift === 0) return parts
113
+
114
+ return parts.map(part => ({ ...part, zPos: round(part.zPos - shift) }))
115
+ }
116
+
54
117
  /** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */
55
118
  export function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined {
56
119
  if (parts.length === 0) return undefined
@@ -60,11 +123,19 @@ export function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]
60
123
  let tallest = 0
61
124
 
62
125
  for (const part of parts) {
126
+ /* 돌아간 부피. 관문이 재는 것과 같은 함수로 잰다 — 다르게 재면 눌러도 막힌 채로 남는다. */
127
+ const extent = extentOf(part)
128
+ const centre = {
129
+ x: part.left + part.width / 2,
130
+ y: part.top + part.height / 2,
131
+ z: part.zPos + part.depth / 2
132
+ }
133
+
63
134
  half = {
64
- x: Math.max(half.x, Math.abs(part.left - middle.x), Math.abs(part.left + part.width - middle.x)),
65
- y: Math.max(half.y, Math.abs(part.top - middle.y), Math.abs(part.top + part.height - middle.y))
135
+ x: Math.max(half.x, Math.abs(centre.x - extent.x / 2 - middle.x), Math.abs(centre.x + extent.x / 2 - middle.x)),
136
+ y: Math.max(half.y, Math.abs(centre.y - extent.z / 2 - middle.y), Math.abs(centre.y + extent.z / 2 - middle.y))
66
137
  }
67
- tallest = Math.max(tallest, part.zPos + part.depth)
138
+ tallest = Math.max(tallest, centre.z + extent.y / 2)
68
139
  }
69
140
 
70
141
  return {
@@ -435,6 +435,16 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
435
435
  ${finding.at ? html`<code where>${finding.at}</code>` : nothing}
436
436
  ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
437
437
  ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
438
+ ${finding.code === 'part-outside-base'
439
+ ? html`<button fit-base @click=${() => this.fitBase()}>
440
+ ${i18next.t('figure.button.fit-base-to-parts')}
441
+ </button>`
442
+ : nothing}
443
+ ${finding.code === 'parts-off-placement-face'
444
+ ? html`<button fit-base @click=${() => this.settleOnFloor()}>
445
+ ${i18next.t('figure.button.settle-on-floor')}
446
+ </button>`
447
+ : nothing}
438
448
  ${(finding.fixes ?? []).map(fix => html`
439
449
  ${fix.safe ? html`
440
450
  <button apply-fix @click=${() => this.applyFix(fix)}>
@@ -463,6 +473,26 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
463
473
  this.dispatchEvent(new CustomEvent('apply-sizing-fix', { detail: fix, bubbles: true, composed: true }))
464
474
  }
465
475
 
476
+ /**
477
+ * 넘친 것을 담는 길 하나를 여기서도 내민다 — **같은 동작이 속성 패널에도 있다**(「부품에 맞추기」).
478
+ *
479
+ * 그 단추를 아는 저작자는 거기서 누른다. 발행이 막혔다는 말을 여기서 읽은 사람은 그 단추가 있는
480
+ * 것을 모르고, 문구는 그것을 가리킬 수 없다 — 문장은 형식이 쓰고 형식은 화면의 단추를 모른다.
481
+ * 그래서 화면이 자기 자리에서 같은 동작을 낸다. 누르면 상자만 커지고 부품은 제자리이며, 저장은
482
+ * 여전히 저작자가 따로 한다.
483
+ */
484
+ private fitBase() {
485
+ this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
486
+ }
487
+
488
+ /**
489
+ * 떠 있는 것을 앉힌다. 부품 하나를 고르고 「바닥에」를 누르는 길은 전부터 있었으나, 그 길은
490
+ * 고른 것 하나만 옮기고 **여기서는 닿지 않는다.** 막혔다는 말을 읽은 자리에서 조치가 되게 한다.
491
+ */
492
+ private settleOnFloor() {
493
+ this.dispatchEvent(new CustomEvent('settle-parts-on-floor', { bubbles: true, composed: true }))
494
+ }
495
+
466
496
  private renderVersion(version: FigureVersion) {
467
497
  return html`
468
498
  <li>
@@ -66,7 +66,7 @@ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' |
66
66
  *
67
67
  * 한동안 배치 기준을 높이로 옮기는 계산을 이 파일이 들고 있었다. 이제 씬이 한다 —
68
68
  * `FigureRealObject.effectiveZPos` 가 청사진의 `placement` 와 레이어의 `heights` 로 정한다
69
- * (ADR-0045, `operato-twin/design/04-decisions.md`). 도면이 그렇게 세우므로 「도면에 놓았을
69
+ * (ADR-0045, `operato-application/adr/ADR-0045.md`). 도면이 그렇게 세우므로 「도면에 놓았을
70
70
  * 때의 모습」을 말하는 이 화면도 그렇게 세워야 한다 — 여기서 `zPos` 를 적으면 **적은 값이
71
71
  * 이겨서**, 씬이 무엇을 할지가 이 화면에서만 안 보인다.
72
72
  *
@@ -1,6 +1,7 @@
1
1
  import '@material/web/icon/icon.js'
2
2
  import '../modeller/figure-side.js'
3
3
  import '../modeller/figure-canvas.js'
4
+ import { baseFittingParts, partsSettledOnFloor, resizeBase as resizedToFit } from '../modeller/base-box.js'
4
5
  import { captureThumbnail } from '../modeller/figure-thumbnail.js'
5
6
  import '../modeller/figure-preview.js'
6
7
  import { shouldStartNewFigure } from '../modeller/new-figure-entry.js'
@@ -916,6 +917,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
916
917
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
917
918
  @preview-sizing-fix=${(e: CustomEvent) => this.previewSizingFix(e.detail)}
918
919
  @apply-sizing-fix=${(e: CustomEvent) => this.applySizingFix(e.detail)}
920
+ @fit-base-to-parts=${() => this.fitBaseToParts()}
921
+ @settle-parts-on-floor=${() => this.settlePartsOnFloor()}
919
922
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
920
923
  @select-part=${(e: CustomEvent) => (this.selected = e.detail.index)}
921
924
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.index)}
@@ -1653,6 +1656,29 @@ export class FigureModellerPage extends FigureModellerPageBase {
1653
1656
  * 상자가 커지면 한가운데가 옮겨 가므로 부품을 그만큼 되밀어야 저장되는 자리가 유지된다
1654
1657
  * (`base-box.ts`). 그 둘을 따로 기록하면 한 번 되돌렸을 때 부품이 반쯤 밀린 화면이 남는다.
1655
1658
  */
1659
+ /**
1660
+ * 기준 상자를 부품에 맞춘다. 속성 패널의 단추와 **같은 함수**를 쓴다 — 두 자리가 다른 크기를
1661
+ * 내면 어느 쪽을 눌렀는지에 따라 발행이 갈린다.
1662
+ */
1663
+ private fitBaseToParts() {
1664
+ if (!this.draft) return
1665
+ const fitted = baseFittingParts(this.draft, this.parts)
1666
+ if (!fitted) return
1667
+ const next = resizedToFit(this.draft, this.parts, { ...fitted })
1668
+ if (next.draft === this.draft) return
1669
+ this.resizeBase(next.draft, [...next.parts])
1670
+ }
1671
+
1672
+ /** 떠 있는 부품 전부를 바닥에 앉힌다. 상자는 건드리지 않는다 — 조치 하나가 한 가지만 한다. */
1673
+ private settlePartsOnFloor() {
1674
+ const settled = partsSettledOnFloor(this.parts)
1675
+ if (settled === this.parts) return
1676
+
1677
+ this.recordHistory('base')
1678
+ this.parts = [...settled]
1679
+ this.dirty = true
1680
+ }
1681
+
1656
1682
  private resizeBase(draft: FigureDraft, parts: PartModel[]) {
1657
1683
  this.recordHistory('base')
1658
1684
  this.draft = draft
@@ -9,13 +9,24 @@
9
9
  * operato-plant 가 같은 문제를 겪고 같은 방식으로 풀었다(`operato-plant/assets/brand.css`).
10
10
  * 그 구조를 그대로 따른다 — 값은 여기 한 번만 적고, 앱 팔레트와 하네스가 이 파일을 읽는다.
11
11
  *
12
+ * ── 왜 `themes/` 가 아닌가 ─────────────────────────────────────────────────
13
+ * `@things-factory/builder` 의 `ThemeOverridePlugin` 이 모든 모듈의 `client/themes/*.css` 를
14
+ * **파일 이름 하나당 하나만** 골라 테마 묶음에 싣는다(앱이 맨 앞, 나머지는 건너뜀). 그래서
15
+ * 모듈이 `themes/light.css` 를 두면 앱이 같은 이름을 갖는 순간 통째로 빠지고, 이름을 비켜서
16
+ * 두면 앞의 것과 **같은 선택자로 md-sys 칸을 덮어** 묶음 순서가 색을 정하게 된다. md-sys 슬롯은
17
+ * 모듈이 선언하지 않는다 — shell 이 열고 앱이 채운다(ADR-0060 결정 2).
18
+ *
19
+ * 이 폴더의 셋은 **하네스가 쓰는 것**이고, `themes` 밖에 있으므로 플러그인이 아예 보지 않는다.
20
+ * 앱 화면의 색은 앱의 테마 파일이 정한다. 여기 값을 앱 색으로 알고 고치지 말 것 — 지금 이 셋은
21
+ * navy 이고 앱은 teal 이다(수석 아키텍트가 사용자께 올려 둔 미결이다).
22
+ *
12
23
  * ── 브랜드 색만 여기 둔다 ──────────────────────────────────────────────────
13
- * 중성색은 여기 없다. 그것은 `client/themes/light.css` 가 갖는다. 여기 있는 것은 **제품을
24
+ * 중성색은 여기 없다. 그것은 `client/harness-theme/light.css` 가 갖는다. 여기 있는 것은 **제품을
14
25
  * 가리키는 색**뿐이고, 그것만 절대 갈라지면 안 된다.
15
26
  *
16
27
  * ── 밝은 것과 어두운 것을 함께 든다 ────────────────────────────────────────
17
- * 어두운 바탕에서 `#154473` 은 가라앉고(대비 1.6), 밝은 바탕에서 `#64a9f3` 은 글자에 못 쓴다
18
- * (대비 2.4). 그래서 같은 이름이 밝기마다 다른 값을 갖는다 — 이름이 뜻이고 값은 자리에 따라
28
+ * 어두운 바탕에서 `#00727e` 는 가라앉고(대비 3.21), 밝은 바탕에서 `#22c3cf` 는 글자에 못 쓴다
29
+ * (대비 2.15). 그래서 같은 이름이 밝기마다 다른 값을 갖는다 — 이름이 뜻이고 값은 자리에 따라
19
30
  * 다르다. plant · twin 과 같은 규약이다.
20
31
  *
21
32
  * ── 색은 하나뿐이다 ────────────────────────────────────────────────────────
@@ -29,23 +40,29 @@
29
40
 
30
41
  :root {
31
42
  /*
32
- * figure — 네이비 252°.
43
+ * figure — 딥 틸 186°.
44
+ *
45
+ * 흰 글자 대비 5.66 으로 작은 글자 기준(4.5)을 넘는다. [측정 2026-09-19]
33
46
  *
34
- * OKLCH L* 38 · C 0.095 · H 252. 흰 글자 대비 9.97 로 작은 글자 기준(4.5)에 여유가 크다.
47
+ * **이 값은 앱이 화면에 쓰는 것과 같다**(`operato-figure` 의 팔레트 · `assets/brand.css` 의
48
+ * `--brand-figure`). 한동안 이 파일은 네이비 `#154473` 이었고 앱은 틸이었다 — 이 파일이 테마
49
+ * 묶음에 실리지 않고 하네스만 읽었으므로 아무 오류 없이 두 얼굴이었다. 그것을 막으려고 생긴
50
+ * 파일이 그것을 못 막고 있었다. 2026-09-19 사용자 결정으로 앱 쪽 값에 맞췄다.
35
51
  *
36
52
  * 채도를 낮게 잡은 것이 이 색의 요점이다. 이 도구의 화면에서 색이 말해야 하는 것은 **저작자가
37
53
  * 고른 부품 색**이다. 틀이 진한 색으로 소리를 내면 캔버스 안의 색을 판단할 수 없다. 그래서
38
54
  * 브랜드 색은 깊고 조용하게 두고, 고른 것의 밑줄 · 기본 단추 · 링크에만 좁게 쓴다.
39
55
  */
40
- --brand-figure: #154473;
56
+ --figure-brand: #00727e;
41
57
 
42
58
  /*
43
- * 어두운 면 위에서 쓰는 같은 색. L* 72 · C 0.130.
59
+ * 어두운 면 위에서 쓰는 같은 색. 어두운 면(`#12161b`) 대비 8.45. [측정 2026-09-19]
44
60
  *
45
- * 3D 캔버스의 선택 테두리와 어두운 배경의 강조가 이 값이다. 어두운 면(#12161b) 대비 7.02.
61
+ * 3D 캔버스의 선택 테두리와 어두운 배경의 강조가 이 값이다. 앱 팔레트의 어두운 쪽 브랜드 색과
62
+ * 같다.
46
63
  */
47
- --brand-figure-2: #64a9f3;
64
+ --figure-brand-2: #22c3cf;
48
65
 
49
- /** 아주 옅게 깔 때 — 고른 줄의 바탕. 위 색과 같은 색상각이다. */
50
- --brand-figure-wash: #eaf3fd;
66
+ /** 아주 옅게 깔 때 — 고른 줄의 바탕. 위 색을 흰 바탕에 8% 로 깐 값이다. */
67
+ --figure-brand-wash: #ebf4f5;
51
68
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * ── 브랜드 색이 밝은 쪽으로 바뀐다 ────────────────────────────────────────
10
10
  * `#154473` 은 어두운 바탕에서 대비 1.6 이라 보이지 않는다. 그래서 어두운 면에서는
11
- * `--brand-figure-2`(#64a9f3)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
11
+ * `--figure-brand-2`(#22c3cf)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
12
12
  * plant · twin 과 같은 규약이다.
13
13
  *
14
14
  * [측정] 어두운 면(#12161b) 대비 — #154473 1.60 · #64a9f3 7.02
@@ -40,12 +40,12 @@
40
40
  --md-sys-color-on-surface-dim: #7d848d;
41
41
 
42
42
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
43
- --md-sys-color-primary: var(--brand-figure-2);
43
+ --md-sys-color-primary: var(--figure-brand-2);
44
44
  --md-sys-color-on-primary: #0d1116;
45
- --md-sys-color-inverse-primary: var(--brand-figure);
45
+ --md-sys-color-inverse-primary: var(--figure-brand);
46
46
 
47
47
  --md-sys-color-primary-container: #1c2b3d;
48
- --md-sys-color-on-primary-container: var(--brand-figure-2);
48
+ --md-sys-color-on-primary-container: var(--figure-brand-2);
49
49
 
50
50
  --md-sys-color-secondary-container: #1a1f25;
51
51
  --md-sys-color-on-secondary-container: #a8afb8;
@@ -3,8 +3,8 @@
3
3
  /*
4
4
  * figure 라이트 팔레트 — 이 파일이 이 도구 색의 **정본**이다.
5
5
  *
6
- * 브랜드 색은 `client/themes/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--brand-figure` ·
7
- * `--brand-figure-2` · `--brand-figure-wash` 셋이다. 값을 여기 다시 적지 않는다.
6
+ * 브랜드 색은 `client/harness-theme/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--figure-brand` ·
7
+ * `--figure-brand-2` · `--figure-brand-wash` 셋이다. 값을 여기 다시 적지 않는다.
8
8
  *
9
9
  * ── 이 도구에서 색이 말해야 하는 것 ────────────────────────────────────────
10
10
  * **저작자가 고른 부품 색**이다. 화면 한가운데 캔버스가 있고, 거기 놓인 부품의 색을 보고
@@ -52,16 +52,16 @@
52
52
  --md-sys-color-on-surface-dim: #6a6f76;
53
53
 
54
54
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
55
- --md-sys-color-primary: var(--brand-figure);
55
+ --md-sys-color-primary: var(--figure-brand);
56
56
  --md-sys-color-on-primary: #ffffff;
57
- --md-sys-color-inverse-primary: var(--brand-figure-2);
57
+ --md-sys-color-inverse-primary: var(--figure-brand-2);
58
58
 
59
59
  /*
60
60
  * 고른 줄의 바탕. 이것만 유일하게 색이 든 면이고, 그것도 거의 흰색이다(L* 96).
61
61
  * 그 위에 오는 글자는 브랜드 색이며 대비 8.90 이다.
62
62
  */
63
- --md-sys-color-primary-container: var(--brand-figure-wash);
64
- --md-sys-color-on-primary-container: var(--brand-figure);
63
+ --md-sys-color-primary-container: var(--figure-brand-wash);
64
+ --md-sys-color-on-primary-container: var(--figure-brand);
65
65
 
66
66
  /*
67
67
  * 두 번째 면 — 파스텔을 쓰지 않으므로 무채색이다. 칩과 배지가 이것을 깐다.
@@ -24,6 +24,13 @@ import type { FigureDraft, PartModel } from './figure-source.js';
24
24
  * 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에
25
25
  * 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미
26
26
  * `part-outside-base` 로 말한다.
27
+ *
28
+ * ## 돌아간 부품은 돌아간 부피로 잰다
29
+ *
30
+ * 45 도 돌린 판은 제 `size` 보다 넓게 자리를 차지한다. 발행 관문은 그것을 `rotatedExtentOf` 로
31
+ * 재는데 여기서는 `width`·`height` 를 그대로 썼다. 그래서 **회전한 부품이 있는 도형은 「부품에
32
+ * 맞추기」를 눌러도 발행이 막힌 채로 남았다** — 표본에서 CONVEYOR(부품 17 중 4 가 회전)와
33
+ * ROLL_CRADLE(20 중 13)이 그랬다(2026-09-19 확인). 같은 함수로 재서 둘이 어긋나지 않게 한다.
27
34
  */
28
35
  export interface BaseSize {
29
36
  /** 저작면의 가로 — 형식의 `base.x`. */
@@ -34,6 +41,23 @@ export interface BaseSize {
34
41
  depth: number;
35
42
  }
36
43
  export declare const baseOf: (draft: FigureDraft) => BaseSize;
44
+ /**
45
+ * 부품 전부를 **바닥에 앉힌다** — 가장 낮은 것이 상자 밑면에 닿도록 전부를 같은 값만큼 옮긴다.
46
+ *
47
+ * ## 왜 전부를 옮기나
48
+ *
49
+ * 관문이 막는 것은 「가장 낮은 부품이 밑면에 안 닿는다」다(`parts-off-placement-face`). 그 부품
50
+ * 하나만 내리면 관문은 통과하지만 도형이 달라진다 — 프레임에 붙어 있던 발이 떨어진다. 부품 사이의
51
+ * 자리는 저작자가 정한 것이므로 그것을 지키고 전체를 옮긴다.
52
+ *
53
+ * ## 위로도 옮긴다
54
+ *
55
+ * 바닥 **아래로** 내려간 부품이 있으면 올린다. 그때는 위쪽이 상자를 넘을 수 있으므로 저작자가
56
+ * 「부품에 맞추기」를 이어서 누르게 된다 — 두 조치가 각자 한 가지만 한다.
57
+ *
58
+ * 앉힐 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.
59
+ */
60
+ export declare function partsSettledOnFloor(parts: readonly PartModel[]): readonly PartModel[];
37
61
  /** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */
38
62
  export declare function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined;
39
63
  /**
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
+ import { rotatedExtentOf } from '@hatiolab/figure-model';
4
5
  /** 상자는 밀리미터 하나보다 작아질 수 없다. 0 이 되면 배율이 0 으로 나뉜다. */
5
6
  const LEAST = 1;
6
7
  const round = (value) => Math.round(value);
@@ -9,6 +10,56 @@ export const baseOf = (draft) => ({
9
10
  height: draft.height,
10
11
  depth: draft.depth
11
12
  });
13
+ const DEG = 180 / Math.PI;
14
+ /**
15
+ * 이 부품이 차지하는 부피 — **형식의 축으로**.
16
+ *
17
+ * 저작면과 형식의 축 이름이 엇갈린다(`figure-source.ts` 의 표): 저작면의 `depth` 가 형식의 y 이고
18
+ * `height` 가 형식의 z 다. 회전 이름도 엇갈린다(`rotationY` → 형식의 z). 그 표를 여기서 다시
19
+ * 적지 않고 같은 모양으로 옮겨, `rotatedExtentOf` 가 관문에서와 같은 답을 내게 한다.
20
+ */
21
+ function extentOf(part) {
22
+ return rotatedExtentOf({
23
+ transform: {
24
+ position: { x: 0, y: 0, z: 0 },
25
+ size: { x: part.width, y: part.depth, z: part.height },
26
+ rotation: {
27
+ x: part.rotationX ? part.rotationX * DEG : 0,
28
+ y: part.rotation ? -part.rotation * DEG : 0,
29
+ z: part.rotationY ? part.rotationY * DEG : 0
30
+ }
31
+ }
32
+ });
33
+ }
34
+ /**
35
+ * 부품 전부를 **바닥에 앉힌다** — 가장 낮은 것이 상자 밑면에 닿도록 전부를 같은 값만큼 옮긴다.
36
+ *
37
+ * ## 왜 전부를 옮기나
38
+ *
39
+ * 관문이 막는 것은 「가장 낮은 부품이 밑면에 안 닿는다」다(`parts-off-placement-face`). 그 부품
40
+ * 하나만 내리면 관문은 통과하지만 도형이 달라진다 — 프레임에 붙어 있던 발이 떨어진다. 부품 사이의
41
+ * 자리는 저작자가 정한 것이므로 그것을 지키고 전체를 옮긴다.
42
+ *
43
+ * ## 위로도 옮긴다
44
+ *
45
+ * 바닥 **아래로** 내려간 부품이 있으면 올린다. 그때는 위쪽이 상자를 넘을 수 있으므로 저작자가
46
+ * 「부품에 맞추기」를 이어서 누르게 된다 — 두 조치가 각자 한 가지만 한다.
47
+ *
48
+ * 앉힐 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.
49
+ */
50
+ export function partsSettledOnFloor(parts) {
51
+ if (parts.length === 0)
52
+ return parts;
53
+ let lowest = Infinity;
54
+ for (const part of parts) {
55
+ const extent = extentOf(part);
56
+ lowest = Math.min(lowest, part.zPos + part.depth / 2 - extent.y / 2);
57
+ }
58
+ const shift = round(lowest);
59
+ if (shift === 0)
60
+ return parts;
61
+ return parts.map(part => ({ ...part, zPos: round(part.zPos - shift) }));
62
+ }
12
63
  /** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */
13
64
  export function baseFittingParts(draft, parts) {
14
65
  if (parts.length === 0)
@@ -17,11 +68,18 @@ export function baseFittingParts(draft, parts) {
17
68
  let half = { x: 0, y: 0 };
18
69
  let tallest = 0;
19
70
  for (const part of parts) {
71
+ /* 돌아간 부피. 관문이 재는 것과 같은 함수로 잰다 — 다르게 재면 눌러도 막힌 채로 남는다. */
72
+ const extent = extentOf(part);
73
+ const centre = {
74
+ x: part.left + part.width / 2,
75
+ y: part.top + part.height / 2,
76
+ z: part.zPos + part.depth / 2
77
+ };
20
78
  half = {
21
- x: Math.max(half.x, Math.abs(part.left - middle.x), Math.abs(part.left + part.width - middle.x)),
22
- y: Math.max(half.y, Math.abs(part.top - middle.y), Math.abs(part.top + part.height - middle.y))
79
+ x: Math.max(half.x, Math.abs(centre.x - extent.x / 2 - middle.x), Math.abs(centre.x + extent.x / 2 - middle.x)),
80
+ y: Math.max(half.y, Math.abs(centre.y - extent.z / 2 - middle.y), Math.abs(centre.y + extent.z / 2 - middle.y))
23
81
  };
24
- tallest = Math.max(tallest, part.zPos + part.depth);
82
+ tallest = Math.max(tallest, centre.z + extent.y / 2);
25
83
  }
26
84
  return {
27
85
  width: Math.max(LEAST, round(half.x * 2)),
@@ -1 +1 @@
1
- {"version":3,"file":"base-box.js","sourceRoot":"","sources":["../../client/modeller/base-box.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * 기준 상자를 고친다 — **부품은 제자리에 둔 채.**\n *\n * ## 왜 화면에 있어야 하나\n *\n * 기준 상자는 도면에 놓일 때의 배율을 정하는 수다(ADR-0044 ①). 부품이 그 상자를 넘으면 발행이\n * 막히고, 고치는 길은 「상자 숫자 하나를 키우는 것」인데 **화면에 그 칸이 없었다.** 저작자는\n * 넘쳤다는 말만 듣고 고칠 자리를 못 찾았다.\n *\n * ## 상자를 키우면 부품이 움직인다 — 그것을 되돌린다\n *\n * 저작면의 부품 자리는 판의 왼쪽 위에서 잰 값이고, 저장 형식의 자리는 **상자의 한가운데**에서 잰\n * 값이다(`toFigureSource` 가 절반을 뺀다). 그래서 상자만 키우면 한가운데가 옮겨 가고 부품이 전부\n * 반대로 밀린다. 여기서 늘어난 절반만큼 부품을 함께 옮겨, 저장되는 자리가 한 값도 안 바뀌게 한다.\n *\n * ## 「부품에 맞추기」는 한가운데를 지킨다\n *\n * 부품을 감싸는 가장 작은 상자를 만들 때, 부품 쪽을 **옮기지 않는다.** 한가운데에서 가장 먼\n * 부품까지의 거리를 양쪽으로 잡는다. 부품이 한쪽으로 치우쳐 있으면 반대쪽에 여유가 남는데,\n * 그것이 사실이다 — 치우친 것을 가운데로 끌어오면 저작자가 놓은 자리가 소리 없이 달라지고,\n * 발행된 도형에서는 문(port)의 자리가 함께 움직인다.\n *\n * 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에\n * 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미\n * `part-outside-base` 로 말한다.\n */\n\nexport interface BaseSize {\n /** 저작면의 가로 — 형식의 `base.x`. */\n width: number\n /** 저작면의 세로 — 형식의 `base.z`. */\n height: number\n /** 높이 — 형식의 `base.y`. */\n depth: number\n}\n\n/** 상자는 밀리미터 하나보다 작아질 수 없다. 0 이 되면 배율이 0 으로 나뉜다. */\nconst LEAST = 1\n\nconst round = (value: number) => Math.round(value)\n\nexport const baseOf = (draft: FigureDraft): BaseSize => ({\n width: draft.width,\n height: draft.height,\n depth: draft.depth\n})\n\n/** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */\nexport function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined {\n if (parts.length === 0) return undefined\n\n const middle = { x: draft.width / 2, y: draft.height / 2 }\n let half = { x: 0, y: 0 }\n let tallest = 0\n\n for (const part of parts) {\n half = {\n x: Math.max(half.x, Math.abs(part.left - middle.x), Math.abs(part.left + part.width - middle.x)),\n y: Math.max(half.y, Math.abs(part.top - middle.y), Math.abs(part.top + part.height - middle.y))\n }\n tallest = Math.max(tallest, part.zPos + part.depth)\n }\n\n return {\n width: Math.max(LEAST, round(half.x * 2)),\n height: Math.max(LEAST, round(half.y * 2)),\n depth: Math.max(LEAST, round(tallest))\n }\n}\n\n/**\n * 상자를 이 크기로. 부품은 저장되는 자리가 그대로이도록 함께 옮긴다.\n *\n * 달라질 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.\n */\nexport function resizeBase(\n draft: FigureDraft,\n parts: readonly PartModel[],\n next: BaseSize\n): { draft: FigureDraft; parts: readonly PartModel[] } {\n const size = {\n width: Math.max(LEAST, round(next.width)),\n height: Math.max(LEAST, round(next.height)),\n depth: Math.max(LEAST, round(next.depth))\n }\n\n if (size.width === draft.width && size.height === draft.height && size.depth === draft.depth) {\n return { draft, parts }\n }\n\n const shift = { x: (size.width - draft.width) / 2, y: (size.height - draft.height) / 2 }\n const moved =\n shift.x === 0 && shift.y === 0\n ? parts\n : parts.map(part => ({ ...part, left: part.left + shift.x, top: part.top + shift.y }))\n\n return { draft: { ...draft, ...size }, parts: moved }\n}\n"]}
1
+ {"version":3,"file":"base-box.js","sourceRoot":"","sources":["../../client/modeller/base-box.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport { rotatedExtentOf } from '@hatiolab/figure-model'\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * 기준 상자를 고친다 — **부품은 제자리에 둔 채.**\n *\n * ## 왜 화면에 있어야 하나\n *\n * 기준 상자는 도면에 놓일 때의 배율을 정하는 수다(ADR-0044 ①). 부품이 그 상자를 넘으면 발행이\n * 막히고, 고치는 길은 「상자 숫자 하나를 키우는 것」인데 **화면에 그 칸이 없었다.** 저작자는\n * 넘쳤다는 말만 듣고 고칠 자리를 못 찾았다.\n *\n * ## 상자를 키우면 부품이 움직인다 — 그것을 되돌린다\n *\n * 저작면의 부품 자리는 판의 왼쪽 위에서 잰 값이고, 저장 형식의 자리는 **상자의 한가운데**에서 잰\n * 값이다(`toFigureSource` 가 절반을 뺀다). 그래서 상자만 키우면 한가운데가 옮겨 가고 부품이 전부\n * 반대로 밀린다. 여기서 늘어난 절반만큼 부품을 함께 옮겨, 저장되는 자리가 한 값도 안 바뀌게 한다.\n *\n * ## 「부품에 맞추기」는 한가운데를 지킨다\n *\n * 부품을 감싸는 가장 작은 상자를 만들 때, 부품 쪽을 **옮기지 않는다.** 한가운데에서 가장 먼\n * 부품까지의 거리를 양쪽으로 잡는다. 부품이 한쪽으로 치우쳐 있으면 반대쪽에 여유가 남는데,\n * 그것이 사실이다 — 치우친 것을 가운데로 끌어오면 저작자가 놓은 자리가 소리 없이 달라지고,\n * 발행된 도형에서는 문(port)의 자리가 함께 움직인다.\n *\n * 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에\n * 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미\n * `part-outside-base` 로 말한다.\n *\n * ## 돌아간 부품은 돌아간 부피로 잰다\n *\n * 45 도 돌린 판은 제 `size` 보다 넓게 자리를 차지한다. 발행 관문은 그것을 `rotatedExtentOf` 로\n * 재는데 여기서는 `width`·`height` 를 그대로 썼다. 그래서 **회전한 부품이 있는 도형은 「부품에\n * 맞추기」를 눌러도 발행이 막힌 채로 남았다** — 표본에서 CONVEYOR(부품 17 중 4 가 회전)와\n * ROLL_CRADLE(20 중 13)이 그랬다(2026-09-19 확인). 같은 함수로 재서 둘이 어긋나지 않게 한다.\n */\n\nexport interface BaseSize {\n /** 저작면의 가로 — 형식의 `base.x`. */\n width: number\n /** 저작면의 세로 — 형식의 `base.z`. */\n height: number\n /** 높이 — 형식의 `base.y`. */\n depth: number\n}\n\n/** 상자는 밀리미터 하나보다 작아질 수 없다. 0 이 되면 배율이 0 으로 나뉜다. */\nconst LEAST = 1\n\nconst round = (value: number) => Math.round(value)\n\nexport const baseOf = (draft: FigureDraft): BaseSize => ({\n width: draft.width,\n height: draft.height,\n depth: draft.depth\n})\n\nconst DEG = 180 / Math.PI\n\n/**\n * 이 부품이 차지하는 부피 — **형식의 축으로**.\n *\n * 저작면과 형식의 축 이름이 엇갈린다(`figure-source.ts` 의 표): 저작면의 `depth` 가 형식의 y 이고\n * `height` 가 형식의 z 다. 회전 이름도 엇갈린다(`rotationY` → 형식의 z). 그 표를 여기서 다시\n * 적지 않고 같은 모양으로 옮겨, `rotatedExtentOf` 가 관문에서와 같은 답을 내게 한다.\n */\nfunction extentOf(part: PartModel): { x: number; y: number; z: number } {\n return rotatedExtentOf({\n transform: {\n position: { x: 0, y: 0, z: 0 },\n size: { x: part.width, y: part.depth, z: part.height },\n rotation: {\n x: part.rotationX ? part.rotationX * DEG : 0,\n y: part.rotation ? -part.rotation * DEG : 0,\n z: part.rotationY ? part.rotationY * DEG : 0\n }\n }\n } as never)\n}\n\n/**\n * 부품 전부를 **바닥에 앉힌다** — 가장 낮은 것이 상자 밑면에 닿도록 전부를 같은 값만큼 옮긴다.\n *\n * ## 왜 전부를 옮기나\n *\n * 관문이 막는 것은 「가장 낮은 부품이 밑면에 안 닿는다」다(`parts-off-placement-face`). 그 부품\n * 하나만 내리면 관문은 통과하지만 도형이 달라진다 — 프레임에 붙어 있던 발이 떨어진다. 부품 사이의\n * 자리는 저작자가 정한 것이므로 그것을 지키고 전체를 옮긴다.\n *\n * ## 위로도 옮긴다\n *\n * 바닥 **아래로** 내려간 부품이 있으면 올린다. 그때는 위쪽이 상자를 넘을 수 있으므로 저작자가\n * 「부품에 맞추기」를 이어서 누르게 된다 — 두 조치가 각자 한 가지만 한다.\n *\n * 앉힐 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.\n */\nexport function partsSettledOnFloor(parts: readonly PartModel[]): readonly PartModel[] {\n if (parts.length === 0) return parts\n\n let lowest = Infinity\n for (const part of parts) {\n const extent = extentOf(part)\n lowest = Math.min(lowest, part.zPos + part.depth / 2 - extent.y / 2)\n }\n\n const shift = round(lowest)\n if (shift === 0) return parts\n\n return parts.map(part => ({ ...part, zPos: round(part.zPos - shift) }))\n}\n\n/** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */\nexport function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined {\n if (parts.length === 0) return undefined\n\n const middle = { x: draft.width / 2, y: draft.height / 2 }\n let half = { x: 0, y: 0 }\n let tallest = 0\n\n for (const part of parts) {\n /* 돌아간 부피. 관문이 재는 것과 같은 함수로 잰다 — 다르게 재면 눌러도 막힌 채로 남는다. */\n const extent = extentOf(part)\n const centre = {\n x: part.left + part.width / 2,\n y: part.top + part.height / 2,\n z: part.zPos + part.depth / 2\n }\n\n half = {\n x: Math.max(half.x, Math.abs(centre.x - extent.x / 2 - middle.x), Math.abs(centre.x + extent.x / 2 - middle.x)),\n y: Math.max(half.y, Math.abs(centre.y - extent.z / 2 - middle.y), Math.abs(centre.y + extent.z / 2 - middle.y))\n }\n tallest = Math.max(tallest, centre.z + extent.y / 2)\n }\n\n return {\n width: Math.max(LEAST, round(half.x * 2)),\n height: Math.max(LEAST, round(half.y * 2)),\n depth: Math.max(LEAST, round(tallest))\n }\n}\n\n/**\n * 상자를 이 크기로. 부품은 저장되는 자리가 그대로이도록 함께 옮긴다.\n *\n * 달라질 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.\n */\nexport function resizeBase(\n draft: FigureDraft,\n parts: readonly PartModel[],\n next: BaseSize\n): { draft: FigureDraft; parts: readonly PartModel[] } {\n const size = {\n width: Math.max(LEAST, round(next.width)),\n height: Math.max(LEAST, round(next.height)),\n depth: Math.max(LEAST, round(next.depth))\n }\n\n if (size.width === draft.width && size.height === draft.height && size.depth === draft.depth) {\n return { draft, parts }\n }\n\n const shift = { x: (size.width - draft.width) / 2, y: (size.height - draft.height) / 2 }\n const moved =\n shift.x === 0 && shift.y === 0\n ? parts\n : parts.map(part => ({ ...part, left: part.left + shift.x, top: part.top + shift.y }))\n\n return { draft: { ...draft, ...size }, parts: moved }\n}\n"]}