@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 환경 프리셋 — **`scene-manager` 의 `SkyPreset` 과 같은 여덟이다.**
3
+ *
4
+ * 한동안 다섯만 적었다. 씬은 여덟을 다 받는데 이쪽이 셋을 몰라서 고를 수 없었다.
5
+ * operato-board 의 3D 씬 속성 패널도 같은 여덟을 보인다.
6
+ */
7
+ export const ENVIRONMENTS = [
8
+ 'studio',
9
+ 'warehouse',
10
+ 'factory',
11
+ 'office',
12
+ 'home',
13
+ 'sunny',
14
+ 'cloudy',
15
+ 'rainy'
16
+ ];
17
+ /** 배경색 기본값. 회색이라 부품 색이 제 색으로 보인다. */
18
+ export const WORKBENCH = '#7c7c7c';
19
+ /**
20
+ * 처음 값.
21
+ *
22
+ * **지금 저작 화면이 실제로 쓰던 값 그대로다.** 설정을 만들면서 화면이 달라지면
23
+ * 무엇이 설정 때문이고 무엇이 원래 그랬는지 갈리지 않는다.
24
+ */
25
+ export const DEFAULT_VIEW = {
26
+ grid: true,
27
+ gridLabels: true,
28
+ floor: false,
29
+ environment: 'studio',
30
+ skyColor: WORKBENCH,
31
+ dirLightIntensity: 2.0,
32
+ dirLightFollowCamera: true,
33
+ dirShadowEnabled: true,
34
+ hemiIntensity: 4.0
35
+ };
36
+ //# sourceMappingURL=figure-view.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-view.js","sourceRoot":"","sources":["../../client/modeller/figure-view.ts"],"names":[],"mappings":"AAyCA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,QAAQ;IACR,WAAW;IACX,SAAS;IACT,QAAQ;IACR,MAAM;IACN,OAAO;IACP,QAAQ;IACR,OAAO;CACC,CAAA;AAGV,qCAAqC;AACrC,MAAM,CAAC,MAAM,SAAS,GAAG,SAAS,CAAA;AAElC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,YAAY,GAAiB;IACxC,IAAI,EAAE,IAAI;IACV,UAAU,EAAE,IAAI;IAChB,KAAK,EAAE,KAAK;IACZ,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,SAAS;IACnB,iBAAiB,EAAE,GAAG;IACtB,oBAAoB,EAAE,IAAI;IAC1B,gBAAgB,EAAE,IAAI;IACtB,aAAa,EAAE,GAAG;CACnB,CAAA","sourcesContent":["/**\n * 저작 화면의 **보기 설정**.\n *\n * ## 저장되지 않는다\n *\n * 이것은 자산이 아니라 **작업대**다. 조명을 밝게 하고 바닥을 켠 것은 그 사람이 지금\n * 보기 편한 상태일 뿐, 그 자산의 성질이 아니다. 그래서 `FigureSource` 에 안 들어간다 —\n * `toFigureSource` 는 판에서 `width` · `height` · `depth` 와 몇 가지만 집어 가고 나머지는\n * 버린다.\n *\n * 다른 사람이 같은 자산을 열면 자기 설정으로 본다. 그것이 맞다.\n *\n * ## 씬이 아는 이름을 그대로 쓴다\n *\n * 여기 이름은 전부 `model-layer` 의 상태 이름이다. 옮겨 적는 표를 두지 않는다 — 표를\n * 두면 씬이 새 설정을 받아도 이쪽이 모른다.\n *\n * 값이 바뀌면 씬이 스스로 반영한다(`three-capability` 의 `onchange`). 다만 `grid` 는\n * 한동안 3D 를 세울 때만 읽혔다 — things-scene 10.1.5 에서 고쳤다.\n */\nexport interface ViewSettings {\n /** 좌표 바탕(축 가이드). 원점과 눈금을 보인다. */\n grid: boolean\n /** 축 이름표(X · Y · Z). 눈금만 두고 글자를 끌 수 있다. */\n gridLabels: boolean\n /** 판의 바닥면. 좌표 바탕과 겹치므로 기본은 끈다. */\n floor: boolean\n /** Environment — 반사(IBL). 금속 재질이 무엇을 비출지 정한다. */\n environment: EnvironmentName\n /** Sky Color — 배경색. */\n skyColor: string\n /** Key Light Intensity. */\n dirLightIntensity: number\n /** Key Light — Follow Camera. 끄면 그림자 방향이 고정된다. */\n dirLightFollowCamera: boolean\n /** Key Light — Cast Shadow. */\n dirShadowEnabled: boolean\n /** Hemisphere Light Intensity — 그늘진 면을 들어올린다. */\n hemiIntensity: number\n}\n\n/**\n * 환경 프리셋 — **`scene-manager` 의 `SkyPreset` 과 같은 여덟이다.**\n *\n * 한동안 다섯만 적었다. 씬은 여덟을 다 받는데 이쪽이 셋을 몰라서 고를 수 없었다.\n * operato-board 의 3D 씬 속성 패널도 같은 여덟을 보인다.\n */\nexport const ENVIRONMENTS = [\n 'studio',\n 'warehouse',\n 'factory',\n 'office',\n 'home',\n 'sunny',\n 'cloudy',\n 'rainy'\n] as const\nexport type EnvironmentName = (typeof ENVIRONMENTS)[number]\n\n/** 배경색 기본값. 회색이라 부품 색이 제 색으로 보인다. */\nexport const WORKBENCH = '#7c7c7c'\n\n/**\n * 처음 값.\n *\n * **지금 저작 화면이 실제로 쓰던 값 그대로다.** 설정을 만들면서 화면이 달라지면\n * 무엇이 설정 때문이고 무엇이 원래 그랬는지 갈리지 않는다.\n */\nexport const DEFAULT_VIEW: ViewSettings = {\n grid: true,\n gridLabels: true,\n floor: false,\n environment: 'studio',\n skyColor: WORKBENCH,\n dirLightIntensity: 2.0,\n dirLightFollowCamera: true,\n dirShadowEnabled: true,\n hemiIntensity: 4.0\n}\n"]}
@@ -0,0 +1,55 @@
1
+ import type { FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model';
2
+ /**
3
+ * 성숙도 — 이 형상이 얼마나 잘 만들어졌나.
4
+ *
5
+ * ## 축이 여럿이어야 하는 이유
6
+ *
7
+ * 「몇 개까지 편한가」 하나로는 좋은 형상과 나쁜 형상이 구분되지 않는다. 부품 하나짜리
8
+ * 상자도 그 축에서는 만점이다. 잘 만들어졌다는 것은 **여러 물음에 각각 답하는 것**이고,
9
+ * 그것을 한 숫자로 뭉개면 무엇을 고쳐야 할지 알 수 없다.
10
+ *
11
+ * ## 축을 지어내지 않았다
12
+ *
13
+ * `docs/design/component-authoring-tool.md` 의 「인정받을 수 있는 최소 품질 — 기준선」
14
+ * 다섯이 이미 있다. 그것을 잴 수 있는 형태로 옮기고, 이미 셈이 있는 것(규모·되쓰기)을
15
+ * 더했다.
16
+ *
17
+ * 기준선 1 실루엣으로 알아본다 → readable **아직 측정하지 못한다**
18
+ * 기준선 2 상태가 색으로 구분된다 → reactive **아직 측정하지 못한다**
19
+ * 기준선 3 다른 자산과 한 세트로 보인다 → consistency
20
+ * 기준선 4 확대해도 안 깨진다 → consistency (텍스처 0)
21
+ * 기준선 5 라벨이 읽힌다 → reactive
22
+ *
23
+ * ## 못 재는 축을 지어내지 않는다
24
+ *
25
+ * 두 축은 지금 잴 수 없다. 하나는 그리기가 필요하고(실루엣), 하나는 형식에 길이
26
+ * 없다(상태 바인딩 — figure-model#2). **그것을 0점으로도 만점으로도 두지 않는다.**
27
+ * 「아직 측정하지 못한다」고 알리고 이유를 붙인다. 측정하지 못한 것을 점수로 만들면 그 점수가 거짓말을
28
+ * 한다.
29
+ *
30
+ * ## 이 파일은 임시다
31
+ *
32
+ * 셈은 figure-model 것이어야 한다 — 거기가 `costOf` · `scoreOf` 를 갖고 있다.
33
+ * 형식 쪽 결정(#2 · #4)이 서면 이 파일을 지우고 그쪽으로 옮긴다.
34
+ */
35
+ export type AxisKey = 'scale' | 'consistency' | 'reuse' | 'detail' | 'reactive' | 'readable';
36
+ export interface MaturityAxis {
37
+ key: AxisKey;
38
+ /** 0~1. 못 재면 없다. */
39
+ ratio?: number;
40
+ /** 지금 이 축의 값. 그대로 보여 준다. */
41
+ reading?: string;
42
+ /** 값이 숫자가 아니라 말일 때의 문구 키. `reading` 과 함께 쓰지 않는다. */
43
+ readingKey?: string;
44
+ /** 왜 이 값인가. */
45
+ because?: string;
46
+ /** 못 재면 그 이유. 있으면 `ratio` 가 없다. */
47
+ blocked?: string;
48
+ }
49
+ /**
50
+ * 여섯 축을 측정한다.
51
+ *
52
+ * 한 숫자로 합치지 않는다. 합치면 「무엇을 고쳐야 하나」가 사라진다 — 그것이 이
53
+ * 리포트의 존재 이유다.
54
+ */
55
+ export declare function maturityOf(source: FigureSource, cost: FigureCost, score: FigureScore): MaturityAxis[];
@@ -0,0 +1,212 @@
1
+ import { LIMITS, PART_LIMIT, SEGMENT_PRESETS, materialKeyOf } from '@hatiolab/figure-model';
2
+ import { capacityFrom } from './scene-budget.js';
3
+ /** 한 도면에 이만큼 놓는 것을 본다. 설계 문서 §4.15 의 「자산 500 개」. */
4
+ const BOARD_ASSETS = 500;
5
+ /**
6
+ * 여섯 축을 측정한다.
7
+ *
8
+ * 한 숫자로 합치지 않는다. 합치면 「무엇을 고쳐야 하나」가 사라진다 — 그것이 이
9
+ * 리포트의 존재 이유다.
10
+ */
11
+ export function maturityOf(source, cost, score) {
12
+ return [scale(cost), consistency(source, cost), reuse(score), detail(source, cost), reactive(source), readable(source, cost)];
13
+ }
14
+ /** 규모 — 한 도면에 얼마나 놓을 수 있나. */
15
+ function scale(cost) {
16
+ const capacity = capacityFrom(cost.groups, cost.triangles);
17
+ if (!capacity) {
18
+ return { key: 'scale', blocked: 'text.maturity-blocked-no-parts' };
19
+ }
20
+ return {
21
+ key: 'scale',
22
+ // 한 도면 목표치(500)를 채우면 1 이다. 그보다 많이 놓을 수 있어도 1 을 넘기지 않는다 —
23
+ // 목표를 넘긴 여유는 다른 축을 고치는 데 쓰는 편이 낫다.
24
+ ratio: Math.min(1, capacity.instances / BOARD_ASSETS),
25
+ reading: String(capacity.instances),
26
+ because: 'text.maturity-scale-because'
27
+ };
28
+ }
29
+ /**
30
+ * 한 세트로 보이나. 기준선 3·4.
31
+ *
32
+ * 셋을 본다. 팔레트 밖 표면이 없나(텍스처 0 — 확대해도 안 깨진다), 분할 수가
33
+ * 프리셋인가, 투명 재질을 아끼나. 셋 다 이미 형식이 위반으로 세고 있는 것들이다.
34
+ */
35
+ function consistency(source, cost) {
36
+ const offPreset = source.parts.filter(part => part.segments !== undefined && !SEGMENT_PRESETS.includes(part.segments)).length;
37
+ const faults = (cost.surfaceTextures > 0 ? 1 : 0) +
38
+ (offPreset > 0 ? 1 : 0) +
39
+ (cost.distinctMaterials > LIMITS.materialGroups ? 1 : 0);
40
+ return {
41
+ key: 'consistency',
42
+ ratio: (3 - faults) / 3,
43
+ readingKey: faults === 0 ? 'text.maturity-consistency-clean' : 'text.maturity-consistency-faults',
44
+ because: 'text.maturity-consistency-because'
45
+ };
46
+ }
47
+ /**
48
+ * 되쓰나 — 같은 형상을 몇 번 다시 쓰나.
49
+ *
50
+ * `scoreOf` 가 이미 재고 있으나 **점수에는 넣지 않는다**고 선언돼 있다(한도가 아니라
51
+ * 덤이다). 축으로는 뜻이 있다 — 되쓰기가 많은 형상이 잘 짜인 형상이다. 다만 부품마다
52
+ * 형상이 달라도 잘못이 아니므로 **낮다고 나무라지 않는다.**
53
+ */
54
+ function reuse(score) {
55
+ const { distinct, parts, saved } = score.reuse;
56
+ if (parts === 0) {
57
+ return { key: 'reuse', blocked: 'text.maturity-blocked-no-parts' };
58
+ }
59
+ return {
60
+ key: 'reuse',
61
+ // ratio 가 낮을수록 많이 되쓴다 — 뒤집어서 높은 쪽이 좋게 만든다
62
+ ratio: 1 - distinct / parts,
63
+ reading: String(saved),
64
+ because: 'text.maturity-reuse-because'
65
+ };
66
+ }
67
+ /**
68
+ * 정밀도 — 밝힌 등급에 걸맞게 만들어졌나.
69
+ *
70
+ * 등급(S·M·L)은 「어느 거리에서 읽히도록 만든 것인가」를 밝히는 것이다. 그런데 등급만
71
+ * 올려 놓고 부품 셋으로 끝내면 그 약속을 안 지킨 것이다. **한도를 얼마나 썼나**로 본다.
72
+ *
73
+ * 이 축은 「많이 쓸수록 좋다」가 아니다. 등급에 맞게 채웠나를 본다 — 그래서 넘치면
74
+ * 다시 내려간다.
75
+ */
76
+ function detail(source, cost) {
77
+ const level = (source.detailLevel ?? 'M');
78
+ const limit = PART_LIMIT[level];
79
+ const used = cost.parts / limit;
80
+ return {
81
+ key: 'detail',
82
+ // 절반쯤 쓴 것을 제일 좋게 본다. 텅 비어도, 넘쳐도 등급과 안 맞는다.
83
+ ratio: used > 1 ? Math.max(0, 2 - used) : Math.min(1, used * 2),
84
+ reading: `${cost.parts} / ${limit}`,
85
+ because: 'text.maturity-detail-because'
86
+ };
87
+ }
88
+ /**
89
+ * 살아 있나 — 기준선 2·5. 상태가 색으로 구분되고 라벨이 읽히나.
90
+ *
91
+ * **지금 잴 수 없다.** 형식에 `slot` 이 있지만 그리는 쪽이 읽지 않고(선언 한 줄이
92
+ * 전부다), 상태를 부품에 잇는 길 자체가 아직 정해지지 않았다(figure-model#2).
93
+ *
94
+ * 0 점으로 두지 않는다. 0 은 「이 형상이 상태를 안 보여 준다」는 뜻이 되는데, 사실은
95
+ * **아무 형상도 보여 줄 수 없는 상태**다. 축 자체가 아직 없는 것이다.
96
+ */
97
+ function reactive(source) {
98
+ return { key: 'reactive', blocked: 'text.maturity-blocked-no-binding' };
99
+ }
100
+ /**
101
+ * 식별성 — 기준선 1. 위에서 볼 때 무엇인지 알아보나.
102
+ *
103
+ * ## 시점 엔트로피를 빌린다
104
+ *
105
+ * 「어느 시점에서 얼마나 알아볼 수 있나」는 이미 연구된 물음이다. Vázquez 등의
106
+ * **viewpoint entropy** 는 시점에서 보이는 각 면의 투영 면적 비율로 섀넌 엔트로피를
107
+ * 낸다. 모든 면이 같은 면적으로 보일 때가 최대이고, 한 면이 화면을 다 덮으면 0 이다.
108
+ *
109
+ * H = -Σ (Ai / At) · log(Ai / At)
110
+ *
111
+ * ## 다만 면으로 세지 않는다
112
+ *
113
+ * 그 문헌이 스스로 밝히는 한계가 **폴리곤 분할에 좌우된다**는 것이다 — 같은 형상도
114
+ * 잘게 쪼개면 엔트로피가 올라간다. 우리에게는 그 한계가 치명적이다. 분할 수를 8 에서
115
+ * 16 으로 올리는 것은 형상을 알아보기 쉽게 만드는 일이 아닌데, 면으로 세면 점수가
116
+ * 오른다.
117
+ *
118
+ * 그래서 **면이 아니라 색 구역으로 센다.** 눈이 구별하는 것은 삼각형이 아니라 색이
119
+ * 다른 덩어리다. 같은 토큰을 쓰는 부품 둘이 붙어 있으면 눈에는 한 덩어리다.
120
+ * 이렇게 하면 분할 수에 좌우되지 않으면서 원래 물음에 답한다.
121
+ *
122
+ * ## 기준값도 지어내지 않는다
123
+ *
124
+ * 정규화 분모를 `log(재질 group 한도 + 1)` 로 둔다. 이 형식이 허용하는 재질 group 이
125
+ * 셋이고 거기에 배경을 더하면 넷이므로, **이 형식에서 도달 가능한 최대**가 곧
126
+ * 기준이다. 임의로 고른 수가 아니다.
127
+ *
128
+ * ## 그리지 않고 셈으로 낸다
129
+ *
130
+ * 저작 중에 매번 렌더링할 수 없다. 대신 부품의 발자국(x·y·너비·높이)을 위에서
131
+ * 겹쳐 본다 — 위에 있는 것이 아래를 가린다. 축에 정렬된 상자이므로 격자에 찍어
132
+ * 세면 된다.
133
+ *
134
+ * 이 근사가 실제 렌더링과 맞는지는 하네스가 따로 확인한다
135
+ * (`test/browser/check-silhouette.mjs`).
136
+ */
137
+ function readable(source, cost) {
138
+ const regions = topViewRegions(source);
139
+ if (regions.total === 0) {
140
+ return { key: 'readable', blocked: 'text.maturity-blocked-no-parts' };
141
+ }
142
+ // 배경도 한 구역으로 센다 — 발자국을 얼마나 채우나가 식별성의 일부다.
143
+ // Vázquez 의 「열린 장면」 형태와 같다(최대가 log(면 수 + 1)).
144
+ const shares = [...regions.byMaterial.values(), regions.background]
145
+ .map(area => area / regions.footprint)
146
+ .filter(share => share > 0);
147
+ const entropy = -shares.reduce((sum, p) => sum + p * Math.log(p), 0);
148
+ const ceiling = Math.log(LIMITS.materialGroups + 1);
149
+ return {
150
+ key: 'readable',
151
+ ratio: Math.min(1, entropy / ceiling),
152
+ reading: `${regions.byMaterial.size}`,
153
+ because: 'text.maturity-readable-because'
154
+ };
155
+ }
156
+ /**
157
+ * 위에서 내려다볼 때 어느 재질이 얼마나 보이나.
158
+ *
159
+ * 부품 발자국을 격자에 찍되 **높은 것이 이긴다.** 축에 정렬된 상자만 다루므로
160
+ * 이것으로 충분하다 — 회전한 부품은 발자국을 크게 잡아 보수적으로 본다.
161
+ */
162
+ function topViewRegions(source) {
163
+ const GRID = 64;
164
+ const cells = new Array(GRID * GRID).fill(null);
165
+ const height = new Float32Array(GRID * GRID);
166
+ // 톱뷰의 두 축은 x 와 z 다. y 는 높이라 위에서 보면 보이지 않는다.
167
+ // 원점은 기준 상자의 중심이므로 격자의 가운데가 0 이다.
168
+ const sx = GRID / source.base.x;
169
+ const sz = GRID / source.base.z;
170
+ for (const part of source.parts) {
171
+ const { position, size } = part.transform;
172
+ const top = position.y + size.y / 2;
173
+ const key = materialKeyOf(part);
174
+ const x0 = Math.max(0, Math.floor((position.x - size.x / 2 + source.base.x / 2) * sx));
175
+ const x1 = Math.min(GRID, Math.ceil((position.x + size.x / 2 + source.base.x / 2) * sx));
176
+ const y0 = Math.max(0, Math.floor((position.z - size.z / 2 + source.base.z / 2) * sz));
177
+ const y1 = Math.min(GRID, Math.ceil((position.z + size.z / 2 + source.base.z / 2) * sz));
178
+ for (let y = y0; y < y1; y++) {
179
+ for (let x = x0; x < x1; x++) {
180
+ // 원기둥·구는 발자국 상자를 다 채우지 않는다. 모서리를 깎아 근사한다.
181
+ if (ROUND_FOOTPRINT.includes(part.primitive)) {
182
+ const cxp = (x + 0.5 - (x0 + x1) / 2) / ((x1 - x0) / 2 || 1);
183
+ const cyp = (y + 0.5 - (y0 + y1) / 2) / ((y1 - y0) / 2 || 1);
184
+ if (cxp * cxp + cyp * cyp > 1)
185
+ continue;
186
+ }
187
+ const i = y * GRID + x;
188
+ if (cells[i] === null || top > height[i]) {
189
+ cells[i] = key;
190
+ height[i] = top;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ const byMaterial = new Map();
196
+ let covered = 0;
197
+ for (const key of cells) {
198
+ if (key === null)
199
+ continue;
200
+ covered++;
201
+ byMaterial.set(key, (byMaterial.get(key) ?? 0) + 1);
202
+ }
203
+ return {
204
+ byMaterial,
205
+ total: covered,
206
+ background: GRID * GRID - covered,
207
+ footprint: GRID * GRID
208
+ };
209
+ }
210
+ /** 위에서 보면 발자국 상자를 다 채우지 않는 도형. */
211
+ const ROUND_FOOTPRINT = ['cylinder', 'sphere'];
212
+ //# sourceMappingURL=maturity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"maturity.js","sourceRoot":"","sources":["../../client/modeller/maturity.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS, PART_LIMIT, SEGMENT_PRESETS, materialKeyOf } from '@hatiolab/figure-model'\nimport type { DetailLevel, FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'\n\nimport { capacityFrom } from './scene-budget.js'\n\n/**\n * 성숙도 — 이 형상이 얼마나 잘 만들어졌나.\n *\n * ## 축이 여럿이어야 하는 이유\n *\n * 「몇 개까지 편한가」 하나로는 좋은 형상과 나쁜 형상이 구분되지 않는다. 부품 하나짜리\n * 상자도 그 축에서는 만점이다. 잘 만들어졌다는 것은 **여러 물음에 각각 답하는 것**이고,\n * 그것을 한 숫자로 뭉개면 무엇을 고쳐야 할지 알 수 없다.\n *\n * ## 축을 지어내지 않았다\n *\n * `docs/design/component-authoring-tool.md` 의 「인정받을 수 있는 최소 품질 — 기준선」\n * 다섯이 이미 있다. 그것을 잴 수 있는 형태로 옮기고, 이미 셈이 있는 것(규모·되쓰기)을\n * 더했다.\n *\n * 기준선 1 실루엣으로 알아본다 → readable **아직 측정하지 못한다**\n * 기준선 2 상태가 색으로 구분된다 → reactive **아직 측정하지 못한다**\n * 기준선 3 다른 자산과 한 세트로 보인다 → consistency\n * 기준선 4 확대해도 안 깨진다 → consistency (텍스처 0)\n * 기준선 5 라벨이 읽힌다 → reactive\n *\n * ## 못 재는 축을 지어내지 않는다\n *\n * 두 축은 지금 잴 수 없다. 하나는 그리기가 필요하고(실루엣), 하나는 형식에 길이\n * 없다(상태 바인딩 — figure-model#2). **그것을 0점으로도 만점으로도 두지 않는다.**\n * 「아직 측정하지 못한다」고 알리고 이유를 붙인다. 측정하지 못한 것을 점수로 만들면 그 점수가 거짓말을\n * 한다.\n *\n * ## 이 파일은 임시다\n *\n * 셈은 figure-model 것이어야 한다 — 거기가 `costOf` · `scoreOf` 를 갖고 있다.\n * 형식 쪽 결정(#2 · #4)이 서면 이 파일을 지우고 그쪽으로 옮긴다.\n */\n\nexport type AxisKey = 'scale' | 'consistency' | 'reuse' | 'detail' | 'reactive' | 'readable'\n\nexport interface MaturityAxis {\n key: AxisKey\n /** 0~1. 못 재면 없다. */\n ratio?: number\n /** 지금 이 축의 값. 그대로 보여 준다. */\n reading?: string\n /** 값이 숫자가 아니라 말일 때의 문구 키. `reading` 과 함께 쓰지 않는다. */\n readingKey?: string\n /** 왜 이 값인가. */\n because?: string\n /** 못 재면 그 이유. 있으면 `ratio` 가 없다. */\n blocked?: string\n}\n\n/** 한 도면에 이만큼 놓는 것을 본다. 설계 문서 §4.15 의 「자산 500 개」. */\nconst BOARD_ASSETS = 500\n\n/**\n * 여섯 축을 측정한다.\n *\n * 한 숫자로 합치지 않는다. 합치면 「무엇을 고쳐야 하나」가 사라진다 — 그것이 이\n * 리포트의 존재 이유다.\n */\nexport function maturityOf(source: FigureSource, cost: FigureCost, score: FigureScore): MaturityAxis[] {\n return [scale(cost), consistency(source, cost), reuse(score), detail(source, cost), reactive(source), readable(source, cost)]\n}\n\n/** 규모 — 한 도면에 얼마나 놓을 수 있나. */\nfunction scale(cost: FigureCost): MaturityAxis {\n const capacity = capacityFrom(cost.groups, cost.triangles)\n if (!capacity) {\n return { key: 'scale', blocked: 'text.maturity-blocked-no-parts' }\n }\n\n return {\n key: 'scale',\n // 한 도면 목표치(500)를 채우면 1 이다. 그보다 많이 놓을 수 있어도 1 을 넘기지 않는다 —\n // 목표를 넘긴 여유는 다른 축을 고치는 데 쓰는 편이 낫다.\n ratio: Math.min(1, capacity.instances / BOARD_ASSETS),\n reading: String(capacity.instances),\n because: 'text.maturity-scale-because'\n }\n}\n\n/**\n * 한 세트로 보이나. 기준선 3·4.\n *\n * 셋을 본다. 팔레트 밖 표면이 없나(텍스처 0 — 확대해도 안 깨진다), 분할 수가\n * 프리셋인가, 투명 재질을 아끼나. 셋 다 이미 형식이 위반으로 세고 있는 것들이다.\n */\nfunction consistency(source: FigureSource, cost: FigureCost): MaturityAxis {\n const offPreset = source.parts.filter(\n part => part.segments !== undefined && !SEGMENT_PRESETS.includes(part.segments)\n ).length\n\n const faults =\n (cost.surfaceTextures > 0 ? 1 : 0) +\n (offPreset > 0 ? 1 : 0) +\n (cost.distinctMaterials > LIMITS.materialGroups ? 1 : 0)\n\n return {\n key: 'consistency',\n ratio: (3 - faults) / 3,\n readingKey: faults === 0 ? 'text.maturity-consistency-clean' : 'text.maturity-consistency-faults',\n because: 'text.maturity-consistency-because'\n }\n}\n\n/**\n * 되쓰나 — 같은 형상을 몇 번 다시 쓰나.\n *\n * `scoreOf` 가 이미 재고 있으나 **점수에는 넣지 않는다**고 선언돼 있다(한도가 아니라\n * 덤이다). 축으로는 뜻이 있다 — 되쓰기가 많은 형상이 잘 짜인 형상이다. 다만 부품마다\n * 형상이 달라도 잘못이 아니므로 **낮다고 나무라지 않는다.**\n */\nfunction reuse(score: FigureScore): MaturityAxis {\n const { distinct, parts, saved } = score.reuse\n if (parts === 0) {\n return { key: 'reuse', blocked: 'text.maturity-blocked-no-parts' }\n }\n\n return {\n key: 'reuse',\n // ratio 가 낮을수록 많이 되쓴다 — 뒤집어서 높은 쪽이 좋게 만든다\n ratio: 1 - distinct / parts,\n reading: String(saved),\n because: 'text.maturity-reuse-because'\n }\n}\n\n/**\n * 정밀도 — 밝힌 등급에 걸맞게 만들어졌나.\n *\n * 등급(S·M·L)은 「어느 거리에서 읽히도록 만든 것인가」를 밝히는 것이다. 그런데 등급만\n * 올려 놓고 부품 셋으로 끝내면 그 약속을 안 지킨 것이다. **한도를 얼마나 썼나**로 본다.\n *\n * 이 축은 「많이 쓸수록 좋다」가 아니다. 등급에 맞게 채웠나를 본다 — 그래서 넘치면\n * 다시 내려간다.\n */\nfunction detail(source: FigureSource, cost: FigureCost): MaturityAxis {\n const level = (source.detailLevel ?? 'M') as DetailLevel\n const limit = PART_LIMIT[level]\n const used = cost.parts / limit\n\n return {\n key: 'detail',\n // 절반쯤 쓴 것을 제일 좋게 본다. 텅 비어도, 넘쳐도 등급과 안 맞는다.\n ratio: used > 1 ? Math.max(0, 2 - used) : Math.min(1, used * 2),\n reading: `${cost.parts} / ${limit}`,\n because: 'text.maturity-detail-because'\n }\n}\n\n/**\n * 살아 있나 — 기준선 2·5. 상태가 색으로 구분되고 라벨이 읽히나.\n *\n * **지금 잴 수 없다.** 형식에 `slot` 이 있지만 그리는 쪽이 읽지 않고(선언 한 줄이\n * 전부다), 상태를 부품에 잇는 길 자체가 아직 정해지지 않았다(figure-model#2).\n *\n * 0 점으로 두지 않는다. 0 은 「이 형상이 상태를 안 보여 준다」는 뜻이 되는데, 사실은\n * **아무 형상도 보여 줄 수 없는 상태**다. 축 자체가 아직 없는 것이다.\n */\nfunction reactive(source: FigureSource): MaturityAxis {\n return { key: 'reactive', blocked: 'text.maturity-blocked-no-binding' }\n}\n\n/**\n * 식별성 — 기준선 1. 위에서 볼 때 무엇인지 알아보나.\n *\n * ## 시점 엔트로피를 빌린다\n *\n * 「어느 시점에서 얼마나 알아볼 수 있나」는 이미 연구된 물음이다. Vázquez 등의\n * **viewpoint entropy** 는 시점에서 보이는 각 면의 투영 면적 비율로 섀넌 엔트로피를\n * 낸다. 모든 면이 같은 면적으로 보일 때가 최대이고, 한 면이 화면을 다 덮으면 0 이다.\n *\n * H = -Σ (Ai / At) · log(Ai / At)\n *\n * ## 다만 면으로 세지 않는다\n *\n * 그 문헌이 스스로 밝히는 한계가 **폴리곤 분할에 좌우된다**는 것이다 — 같은 형상도\n * 잘게 쪼개면 엔트로피가 올라간다. 우리에게는 그 한계가 치명적이다. 분할 수를 8 에서\n * 16 으로 올리는 것은 형상을 알아보기 쉽게 만드는 일이 아닌데, 면으로 세면 점수가\n * 오른다.\n *\n * 그래서 **면이 아니라 색 구역으로 센다.** 눈이 구별하는 것은 삼각형이 아니라 색이\n * 다른 덩어리다. 같은 토큰을 쓰는 부품 둘이 붙어 있으면 눈에는 한 덩어리다.\n * 이렇게 하면 분할 수에 좌우되지 않으면서 원래 물음에 답한다.\n *\n * ## 기준값도 지어내지 않는다\n *\n * 정규화 분모를 `log(재질 group 한도 + 1)` 로 둔다. 이 형식이 허용하는 재질 group 이\n * 셋이고 거기에 배경을 더하면 넷이므로, **이 형식에서 도달 가능한 최대**가 곧\n * 기준이다. 임의로 고른 수가 아니다.\n *\n * ## 그리지 않고 셈으로 낸다\n *\n * 저작 중에 매번 렌더링할 수 없다. 대신 부품의 발자국(x·y·너비·높이)을 위에서\n * 겹쳐 본다 — 위에 있는 것이 아래를 가린다. 축에 정렬된 상자이므로 격자에 찍어\n * 세면 된다.\n *\n * 이 근사가 실제 렌더링과 맞는지는 하네스가 따로 확인한다\n * (`test/browser/check-silhouette.mjs`).\n */\nfunction readable(source: FigureSource, cost: FigureCost): MaturityAxis {\n const regions = topViewRegions(source)\n if (regions.total === 0) {\n return { key: 'readable', blocked: 'text.maturity-blocked-no-parts' }\n }\n\n // 배경도 한 구역으로 센다 — 발자국을 얼마나 채우나가 식별성의 일부다.\n // Vázquez 의 「열린 장면」 형태와 같다(최대가 log(면 수 + 1)).\n const shares = [...regions.byMaterial.values(), regions.background]\n .map(area => area / regions.footprint)\n .filter(share => share > 0)\n\n const entropy = -shares.reduce((sum, p) => sum + p * Math.log(p), 0)\n const ceiling = Math.log(LIMITS.materialGroups + 1)\n\n return {\n key: 'readable',\n ratio: Math.min(1, entropy / ceiling),\n reading: `${regions.byMaterial.size}`,\n because: 'text.maturity-readable-because'\n }\n}\n\n/**\n * 위에서 내려다볼 때 어느 재질이 얼마나 보이나.\n *\n * 부품 발자국을 격자에 찍되 **높은 것이 이긴다.** 축에 정렬된 상자만 다루므로\n * 이것으로 충분하다 — 회전한 부품은 발자국을 크게 잡아 보수적으로 본다.\n */\nfunction topViewRegions(source: FigureSource) {\n const GRID = 64\n const cells = new Array<string | null>(GRID * GRID).fill(null)\n const height = new Float32Array(GRID * GRID)\n\n // 톱뷰의 두 축은 x 와 z 다. y 는 높이라 위에서 보면 보이지 않는다.\n // 원점은 기준 상자의 중심이므로 격자의 가운데가 0 이다.\n const sx = GRID / source.base.x\n const sz = GRID / source.base.z\n\n for (const part of source.parts) {\n const { position, size } = part.transform\n const top = position.y + size.y / 2\n const key = materialKeyOf(part)\n\n const x0 = Math.max(0, Math.floor((position.x - size.x / 2 + source.base.x / 2) * sx))\n const x1 = Math.min(GRID, Math.ceil((position.x + size.x / 2 + source.base.x / 2) * sx))\n const y0 = Math.max(0, Math.floor((position.z - size.z / 2 + source.base.z / 2) * sz))\n const y1 = Math.min(GRID, Math.ceil((position.z + size.z / 2 + source.base.z / 2) * sz))\n\n for (let y = y0; y < y1; y++) {\n for (let x = x0; x < x1; x++) {\n // 원기둥·구는 발자국 상자를 다 채우지 않는다. 모서리를 깎아 근사한다.\n if (ROUND_FOOTPRINT.includes(part.primitive)) {\n const cxp = (x + 0.5 - (x0 + x1) / 2) / ((x1 - x0) / 2 || 1)\n const cyp = (y + 0.5 - (y0 + y1) / 2) / ((y1 - y0) / 2 || 1)\n if (cxp * cxp + cyp * cyp > 1) continue\n }\n const i = y * GRID + x\n if (cells[i] === null || top > height[i]) {\n cells[i] = key\n height[i] = top\n }\n }\n }\n }\n\n const byMaterial = new Map<string, number>()\n let covered = 0\n for (const key of cells) {\n if (key === null) continue\n covered++\n byMaterial.set(key, (byMaterial.get(key) ?? 0) + 1)\n }\n\n return {\n byMaterial,\n total: covered,\n background: GRID * GRID - covered,\n footprint: GRID * GRID\n }\n}\n\n/** 위에서 보면 발자국 상자를 다 채우지 않는 도형. */\nconst ROUND_FOOTPRINT: string[] = ['cylinder', 'sphere']\n"]}
@@ -0,0 +1,64 @@
1
+ import type { PrimitiveKind } from '@hatiolab/figure-model';
2
+ import type { BoardModel, PartModel, PlaneAxis } from './figure-source.js';
3
+ /** 이 보드에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
4
+ export declare function partLimit(board: BoardModel): number;
5
+ /**
6
+ * 새 부품 하나를 만든다.
7
+ *
8
+ * 보드의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
9
+ * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
10
+ */
11
+ export declare function newPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel;
12
+ export declare function addPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel[];
13
+ export declare function removePart(parts: PartModel[], index: number): PartModel[];
14
+ /** 부품을 복제한다. 이름만 새로 붙인다 — 중복되면 검증에서 걸린다. */
15
+ export declare function duplicatePart(parts: PartModel[], index: number): PartModel[];
16
+ /** 목록에서 순서를 바꾼다. 범위를 벗어나면 그대로 둔다. */
17
+ export declare function movePart(parts: PartModel[], index: number, to: number): PartModel[];
18
+ export declare function updatePart(parts: PartModel[], index: number, part: PartModel): PartModel[];
19
+ /** 부품을 보드의 해당 축 가운데로 옮긴다. */
20
+ export declare function centerPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[];
21
+ /** 부품을 보드 바닥(또는 해당 축의 시작)에 붙인다. */
22
+ export declare function alignPartToStart(parts: PartModel[], index: number, axis: PlaneAxis): PartModel[];
23
+ /** 부품을 보드의 끝에 붙인다. */
24
+ export declare function alignPartToEnd(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[];
25
+ /**
26
+ * 부품을 축 기준으로 대칭 복제한다.
27
+ *
28
+ * 다리 넷, 팔 둘 같은 형상을 이것 없이 만들면 고생한다. 이름은 **어느 쪽인지 알 수
29
+ * 있게** 붙인다 — `leg` 를 x 축으로 대칭하면 `leg-x`.
30
+ */
31
+ export declare function mirrorPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[];
32
+ /**
33
+ * 대칭이 의미가 있는지.
34
+ *
35
+ * 이미 그 축의 가운데에 있는 부품을 대칭하면 **같은 자리에 사본이 생긴다.** 화면에는
36
+ * 아무 변화가 없어 보이는데 부품 수만 늘고, 겹친 면에서 z-fighting 이 일어난다.
37
+ * 만들어 놓고 알리는 대신 미리 막는다.
38
+ */
39
+ export declare function canMirror(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): boolean;
40
+ /**
41
+ * 부품의 위치와 크기를 격자에 맞춘다.
42
+ *
43
+ * 격자 간격은 보드 크기에서 계산한다 — 고정 값이면 작은 형상에서는 너무 크고 큰
44
+ * 형상에서는 너무 잘다. 3D 바닥 격자와 같은 계산이라 화면에서 보이는 것과 일치한다.
45
+ */
46
+ export declare function snapPart(board: BoardModel, parts: PartModel[], index: number): PartModel[];
47
+ /**
48
+ * 격자 간격. 3D 바닥에 그려지는 격자와 같은 값이다.
49
+ *
50
+ * **세 축을 다 본다.** 평면만 보면 키가 큰 모델에서 격자가 지나치게 잘아진다. 시그널타워
51
+ * (바닥 160 x 160, 높이 1100)는 평면 기준으로 간격이 20 이 나와서, 옆에서 보면 높이가
52
+ * 격자 55 칸이 된다. 격자는 크기를 가늠하는 자이므로 모델 전체에 대해 읽혀야 한다.
53
+ */
54
+ export declare function gridStep(board: BoardModel): number;
55
+ /**
56
+ * 주어진 길이에 어울리는 **읽기 좋은** 격자 간격. 다섯 칸 정도가 되게 잡는다.
57
+ *
58
+ * 그냥 나누면 27·33 같은 값이 나온다. 그 격자에 맞추면 좌표가 54·81 이 되어 읽기
59
+ * 어렵다. 1·2·5 배수로 올림한다 — 자와 차트 눈금이 쓰는 방식이다.
60
+ *
61
+ * 격자가 성겨야 형상이 눈에 먼저 들어온다. 열 칸으로 잡았더니 격자가 촘촘해 배경이
62
+ * 먼저 보였다.
63
+ */
64
+ export declare function niceStep(span: number): number;
@@ -0,0 +1,218 @@
1
+ import { EXTRUDE_PRIMITIVES, SEGMENTED_PRIMITIVES, SEGMENT_PRESETS, PART_LIMIT } from '@hatiolab/figure-model';
2
+ import { AXIS_KEYS, boardExtent } from './figure-source.js';
3
+ /**
4
+ * 부품 편집 규칙. **씬 모델의 용어로** 다룬다.
5
+ *
6
+ * 편집 중에 기준이 되는 데이터는 씬 모델이다 — 보드 위에 부품 컴포넌트가 놓인 상태.
7
+ * 그래서 편집도 씬의 용어(`left`·`top`·`zPos`·`width`·`height`·`depth`)로 한다.
8
+ * FigureSource 의 좌표계로 바꾸는 변환은 `figure-source.ts` 한 곳에서만 한다.
9
+ *
10
+ * 사용자가 보는 축(X·Y·Z)이 씬의 어느 속성인지도 `figure-source.ts` 의 `AXIS_KEYS` 가 기준이다.
11
+ * 여기서 다시 정의하지 않는다.
12
+ *
13
+ * 모든 함수가 **새 배열·새 객체를 반환한다** — 원본을 수정하지 않는다. Lit 이 변경을
14
+ * 감지하려면 참조가 달라져야 하고, 실행 취소를 붙일 때도 이전 상태가 남아 있어야 한다.
15
+ */
16
+ /** 중복되지 않는 이름을 만든다. 부품 이름은 저장되는 식별자라 중복되면 검증에서 걸린다. */
17
+ function freeName(taken, base) {
18
+ if (!taken.has(base))
19
+ return base;
20
+ for (let n = 2;; n++) {
21
+ const candidate = `${base}-${n}`;
22
+ if (!taken.has(candidate))
23
+ return candidate;
24
+ }
25
+ }
26
+ /** 이 보드에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
27
+ export function partLimit(board) {
28
+ return PART_LIMIT[(board.detailLevel ?? 'M')];
29
+ }
30
+ /**
31
+ * 새 부품 하나를 만든다.
32
+ *
33
+ * 보드의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
34
+ * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
35
+ */
36
+ export function newPart(board, parts, primitive) {
37
+ const taken = new Set(parts.map(part => part.name));
38
+ const width = Math.max(1, Math.round(board.width / 3));
39
+ const height = Math.max(1, Math.round(board.height / 3));
40
+ const depth = Math.max(1, Math.round(board.depth / 3));
41
+ const part = {
42
+ type: 'figure-part',
43
+ name: freeName(taken, primitive),
44
+ primitive,
45
+ left: Math.round((board.width - width) / 2),
46
+ top: Math.round((board.height - height) / 2),
47
+ width,
48
+ height,
49
+ // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 보드 바닥이다.
50
+ zPos: 0,
51
+ depth,
52
+ // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
53
+ token: parts[0]?.token ?? 'palette.primary'
54
+ };
55
+ if (SEGMENTED_PRIMITIVES.includes(primitive)) {
56
+ part.segments = SEGMENT_PRESETS[1];
57
+ }
58
+ if (EXTRUDE_PRIMITIVES.includes(primitive)) {
59
+ part.round = 0;
60
+ }
61
+ if (primitive === 'polygon') {
62
+ // polygon 은 점이 3개 이상이어야 한다. 없으면 검증에서 막히므로 삼각형으로 시작한다.
63
+ // 단면의 점은 부품 중심을 기준으로 적는다.
64
+ part.path = [
65
+ { x: -Math.round(width / 2), y: -Math.round(height / 2) },
66
+ { x: Math.round(width / 2), y: -Math.round(height / 2) },
67
+ { x: 0, y: Math.round(height / 2) }
68
+ ];
69
+ }
70
+ return part;
71
+ }
72
+ export function addPart(board, parts, primitive) {
73
+ return [...parts, newPart(board, parts, primitive)];
74
+ }
75
+ export function removePart(parts, index) {
76
+ return parts.filter((_, at) => at !== index);
77
+ }
78
+ /** 부품을 복제한다. 이름만 새로 붙인다 — 중복되면 검증에서 걸린다. */
79
+ export function duplicatePart(parts, index) {
80
+ const origin = parts[index];
81
+ if (!origin)
82
+ return parts;
83
+ const copy = JSON.parse(JSON.stringify(origin));
84
+ copy.name = freeName(new Set(parts.map(part => part.name)), `${origin.name}-copy`);
85
+ const next = [...parts];
86
+ next.splice(index + 1, 0, copy);
87
+ return next;
88
+ }
89
+ /** 목록에서 순서를 바꾼다. 범위를 벗어나면 그대로 둔다. */
90
+ export function movePart(parts, index, to) {
91
+ if (to < 0 || to >= parts.length || index === to)
92
+ return parts;
93
+ const next = [...parts];
94
+ const [moved] = next.splice(index, 1);
95
+ next.splice(to, 0, moved);
96
+ return next;
97
+ }
98
+ export function updatePart(parts, index, part) {
99
+ return parts.map((old, at) => (at === index ? part : old));
100
+ }
101
+ /**
102
+ * 정밀 배치 도구 — 좌표를 손으로 계산하지 않게 한다.
103
+ *
104
+ * 보드 편집기는 자산을 **배치**하는 도구라 대략적인 위치로도 충분하다. 여기는 자산을
105
+ * **만드는** 도구라 다르다. 모터를 몸통 정중앙에 놓거나, 다리를 네 귀퉁이에 대칭으로
106
+ * 놓거나, 부품을 바닥에 정확히 붙이는 작업은 손으로 계산하면 늘 조금씩 어긋난다.
107
+ */
108
+ function put(parts, index, fields) {
109
+ const part = parts[index];
110
+ if (!part)
111
+ return parts;
112
+ return updatePart(parts, index, { ...part, ...fields });
113
+ }
114
+ /** 위치의 한 축만 바꾼다. 어느 속성인지는 `AXIS_KEYS` 가 결정한다. */
115
+ function putAt(parts, index, axis, value) {
116
+ return put(parts, index, { [AXIS_KEYS[axis].at]: value });
117
+ }
118
+ /** 부품을 보드의 해당 축 가운데로 옮긴다. */
119
+ export function centerPart(board, parts, index, axis) {
120
+ const part = parts[index];
121
+ if (!part)
122
+ return parts;
123
+ return putAt(parts, index, axis, Math.round((boardExtent(board, axis) - part[AXIS_KEYS[axis].size]) / 2));
124
+ }
125
+ /** 부품을 보드 바닥(또는 해당 축의 시작)에 붙인다. */
126
+ export function alignPartToStart(parts, index, axis) {
127
+ return putAt(parts, index, axis, 0);
128
+ }
129
+ /** 부품을 보드의 끝에 붙인다. */
130
+ export function alignPartToEnd(board, parts, index, axis) {
131
+ const part = parts[index];
132
+ if (!part)
133
+ return parts;
134
+ return putAt(parts, index, axis, Math.round(boardExtent(board, axis) - part[AXIS_KEYS[axis].size]));
135
+ }
136
+ /**
137
+ * 부품을 축 기준으로 대칭 복제한다.
138
+ *
139
+ * 다리 넷, 팔 둘 같은 형상을 이것 없이 만들면 고생한다. 이름은 **어느 쪽인지 알 수
140
+ * 있게** 붙인다 — `leg` 를 x 축으로 대칭하면 `leg-x`.
141
+ */
142
+ export function mirrorPart(board, parts, index, axis) {
143
+ const part = parts[index];
144
+ if (!part || !canMirror(board, parts, index, axis))
145
+ return parts;
146
+ const copy = JSON.parse(JSON.stringify(part));
147
+ copy.name = freeName(new Set(parts.map(p => p.name)), `${part.name}-${axis}`);
148
+ const next = [...parts];
149
+ next.splice(index + 1, 0, copy);
150
+ return putAt(next, index + 1, axis, mirroredAt(board, part, axis));
151
+ }
152
+ /** 대칭했을 때의 위치. 보드의 해당 축 안에서 뒤집는다. */
153
+ function mirroredAt(board, part, axis) {
154
+ const { at, size } = AXIS_KEYS[axis];
155
+ return Math.round(boardExtent(board, axis) - part[at] - part[size]);
156
+ }
157
+ /**
158
+ * 대칭이 의미가 있는지.
159
+ *
160
+ * 이미 그 축의 가운데에 있는 부품을 대칭하면 **같은 자리에 사본이 생긴다.** 화면에는
161
+ * 아무 변화가 없어 보이는데 부품 수만 늘고, 겹친 면에서 z-fighting 이 일어난다.
162
+ * 만들어 놓고 알리는 대신 미리 막는다.
163
+ */
164
+ export function canMirror(board, parts, index, axis) {
165
+ const part = parts[index];
166
+ if (!part)
167
+ return false;
168
+ return mirroredAt(board, part, axis) !== Math.round(part[AXIS_KEYS[axis].at]);
169
+ }
170
+ /**
171
+ * 부품의 위치와 크기를 격자에 맞춘다.
172
+ *
173
+ * 격자 간격은 보드 크기에서 계산한다 — 고정 값이면 작은 형상에서는 너무 크고 큰
174
+ * 형상에서는 너무 잘다. 3D 바닥 격자와 같은 계산이라 화면에서 보이는 것과 일치한다.
175
+ */
176
+ export function snapPart(board, parts, index) {
177
+ const part = parts[index];
178
+ if (!part)
179
+ return parts;
180
+ const step = gridStep(board);
181
+ const round = (value) => Math.round(value / step) * step;
182
+ return put(parts, index, {
183
+ left: round(part.left),
184
+ top: round(part.top),
185
+ zPos: round(part.zPos),
186
+ // 크기는 0 이 되면 안 된다 — 부품이 사라진 것처럼 보인다
187
+ width: Math.max(step, round(part.width)),
188
+ height: Math.max(step, round(part.height)),
189
+ depth: Math.max(step, round(part.depth))
190
+ });
191
+ }
192
+ /**
193
+ * 격자 간격. 3D 바닥에 그려지는 격자와 같은 값이다.
194
+ *
195
+ * **세 축을 다 본다.** 평면만 보면 키가 큰 모델에서 격자가 지나치게 잘아진다. 시그널타워
196
+ * (바닥 160 x 160, 높이 1100)는 평면 기준으로 간격이 20 이 나와서, 옆에서 보면 높이가
197
+ * 격자 55 칸이 된다. 격자는 크기를 가늠하는 자이므로 모델 전체에 대해 읽혀야 한다.
198
+ */
199
+ export function gridStep(board) {
200
+ return niceStep(Math.max(board.width, board.height, board.depth));
201
+ }
202
+ /**
203
+ * 주어진 길이에 어울리는 **읽기 좋은** 격자 간격. 다섯 칸 정도가 되게 잡는다.
204
+ *
205
+ * 그냥 나누면 27·33 같은 값이 나온다. 그 격자에 맞추면 좌표가 54·81 이 되어 읽기
206
+ * 어렵다. 1·2·5 배수로 올림한다 — 자와 차트 눈금이 쓰는 방식이다.
207
+ *
208
+ * 격자가 성겨야 형상이 눈에 먼저 들어온다. 열 칸으로 잡았더니 격자가 촘촘해 배경이
209
+ * 먼저 보였다.
210
+ */
211
+ export function niceStep(span) {
212
+ const raw = Math.max(span, 1) / 5;
213
+ const magnitude = Math.pow(10, Math.floor(Math.log10(raw)));
214
+ const n = raw / magnitude;
215
+ const multiple = n < 1.5 ? 1 : n < 3.5 ? 2 : n < 7.5 ? 5 : 10;
216
+ return Math.max(1, multiple * magnitude);
217
+ }
218
+ //# sourceMappingURL=part-edits.js.map