@hatiolab/figure-model 0.1.76 → 0.1.78

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 (135) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +60 -138
  3. package/dist/index.d.ts +1 -13
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -26
  6. package/dist/index.js.map +1 -1
  7. package/dist/v3-asset-types.d.ts +1 -2
  8. package/dist/v3-asset-types.d.ts.map +1 -1
  9. package/dist/v3-asset.js +1 -1
  10. package/dist/v3-asset.js.map +1 -1
  11. package/dist/v3-authoring-actions.d.ts +9 -12
  12. package/dist/v3-authoring-actions.d.ts.map +1 -1
  13. package/dist/v3-authoring-actions.js +4 -5
  14. package/dist/v3-authoring-actions.js.map +1 -1
  15. package/dist/v3-capabilities.js +4 -1
  16. package/dist/v3-capabilities.js.map +1 -1
  17. package/dist/v3-cost.d.ts.map +1 -1
  18. package/dist/v3-cost.js +3 -5
  19. package/dist/v3-cost.js.map +1 -1
  20. package/dist/v3-create.d.ts +1 -1
  21. package/dist/v3-create.d.ts.map +1 -1
  22. package/dist/v3-examples.js +1 -1
  23. package/dist/v3-gate.d.ts +7 -8
  24. package/dist/v3-gate.d.ts.map +1 -1
  25. package/dist/v3-gate.js +11 -14
  26. package/dist/v3-gate.js.map +1 -1
  27. package/dist/v3-graph.d.ts +1 -2
  28. package/dist/v3-graph.d.ts.map +1 -1
  29. package/dist/v3-graph.js +5 -7
  30. package/dist/v3-graph.js.map +1 -1
  31. package/dist/v3-instance-size.d.ts +3 -5
  32. package/dist/v3-instance-size.d.ts.map +1 -1
  33. package/dist/v3-instance-size.js +2 -4
  34. package/dist/v3-instance-size.js.map +1 -1
  35. package/dist/v3-kernel-version.d.ts +1 -1
  36. package/dist/v3-kernel-version.js +1 -1
  37. package/dist/v3-part-edit.d.ts +4 -5
  38. package/dist/v3-part-edit.d.ts.map +1 -1
  39. package/dist/v3-part-edit.js +2 -2
  40. package/dist/v3-part-edit.js.map +1 -1
  41. package/dist/v3-proposal.js +1 -1
  42. package/dist/v3-proposal.js.map +1 -1
  43. package/dist/v3-release-contract.js +1 -1
  44. package/dist/v3-release-contract.js.map +1 -1
  45. package/dist/v3-score.d.ts +4 -5
  46. package/dist/v3-score.d.ts.map +1 -1
  47. package/dist/v3-score.js +7 -8
  48. package/dist/v3-score.js.map +1 -1
  49. package/dist/v3-words.d.ts +105 -0
  50. package/dist/v3-words.d.ts.map +1 -0
  51. package/dist/v3-words.js +107 -0
  52. package/dist/v3-words.js.map +1 -0
  53. package/docs/assembly-constraints-v3.md +13 -23
  54. package/docs/v3-asset-persistence.md +4 -4
  55. package/docs/v3-common-kernel-experiment.md +1 -1
  56. package/docs/v3-core-semantics.md +3 -3
  57. package/docs/v3-cutover.md +5 -3
  58. package/docs/v3-design.md +13 -26
  59. package/docs/v3-editor-integration.md +2 -2
  60. package/docs/v3-full-conveyor-validation.md +3 -3
  61. package/docs/v3-layout-system.md +1 -1
  62. package/docs/v3-minimal-model.md +0 -1
  63. package/docs/v3-motion-contract.md +21 -56
  64. package/docs/v3-runtime-extraction.md +7 -7
  65. package/docs/v3-sample-validation-expanded.md +1 -1
  66. package/docs/v3-sample-validation.md +1 -1
  67. package/docs/v3-shape-dimension-contract.md +23 -57
  68. package/docs/v3-status.md +5 -5
  69. package/docs/v3-storage-integration.md +4 -4
  70. package/package.json +2 -20
  71. package/dist/blueprint-shape.d.ts +0 -10
  72. package/dist/blueprint-shape.d.ts.map +0 -1
  73. package/dist/blueprint-shape.js +0 -194
  74. package/dist/blueprint-shape.js.map +0 -1
  75. package/dist/blueprint.d.ts +0 -73
  76. package/dist/blueprint.d.ts.map +0 -1
  77. package/dist/blueprint.js +0 -388
  78. package/dist/blueprint.js.map +0 -1
  79. package/dist/cost-chart.d.ts +0 -35
  80. package/dist/cost-chart.d.ts.map +0 -1
  81. package/dist/cost-chart.js +0 -150
  82. package/dist/cost-chart.js.map +0 -1
  83. package/dist/cost.d.ts +0 -162
  84. package/dist/cost.d.ts.map +0 -1
  85. package/dist/cost.js +0 -194
  86. package/dist/cost.js.map +0 -1
  87. package/dist/gate.d.ts +0 -65
  88. package/dist/gate.d.ts.map +0 -1
  89. package/dist/gate.js +0 -284
  90. package/dist/gate.js.map +0 -1
  91. package/dist/grouping.d.ts +0 -121
  92. package/dist/grouping.d.ts.map +0 -1
  93. package/dist/grouping.js +0 -224
  94. package/dist/grouping.js.map +0 -1
  95. package/dist/keys.d.ts +0 -45
  96. package/dist/keys.d.ts.map +0 -1
  97. package/dist/keys.js +0 -84
  98. package/dist/keys.js.map +0 -1
  99. package/dist/origin.d.ts +0 -33
  100. package/dist/origin.d.ts.map +0 -1
  101. package/dist/origin.js +0 -46
  102. package/dist/origin.js.map +0 -1
  103. package/dist/release-contract.d.ts +0 -38
  104. package/dist/release-contract.d.ts.map +0 -1
  105. package/dist/release-contract.js +0 -246
  106. package/dist/release-contract.js.map +0 -1
  107. package/dist/sizing.d.ts +0 -231
  108. package/dist/sizing.d.ts.map +0 -1
  109. package/dist/sizing.js +0 -550
  110. package/dist/sizing.js.map +0 -1
  111. package/dist/types.d.ts +0 -1233
  112. package/dist/types.d.ts.map +0 -1
  113. package/dist/types.js +0 -547
  114. package/dist/types.js.map +0 -1
  115. package/dist/v3-from-v2.d.ts +0 -203
  116. package/dist/v3-from-v2.d.ts.map +0 -1
  117. package/dist/v3-from-v2.js +0 -2053
  118. package/dist/v3-from-v2.js.map +0 -1
  119. package/dist/v3-mesh-compare.d.ts +0 -24
  120. package/dist/v3-mesh-compare.d.ts.map +0 -1
  121. package/dist/v3-mesh-compare.js +0 -55
  122. package/dist/v3-mesh-compare.js.map +0 -1
  123. package/dist/validate.d.ts +0 -7
  124. package/dist/validate.d.ts.map +0 -1
  125. package/dist/validate.js +0 -1119
  126. package/dist/validate.js.map +0 -1
  127. package/dist/visual-evidence.d.ts +0 -21
  128. package/dist/visual-evidence.d.ts.map +0 -1
  129. package/dist/visual-evidence.js +0 -52
  130. package/dist/visual-evidence.js.map +0 -1
  131. package/docs/design.md +0 -193
  132. package/docs/format-survey.md +0 -108
  133. package/docs/format.md +0 -704
  134. package/docs/sizing.md +0 -183
  135. package/docs/validation.md +0 -357
package/dist/validate.js DELETED
@@ -1,1119 +0,0 @@
1
- /*
2
- * Copyright © HatioLab Inc. All rights reserved.
3
- *
4
- * Figure 원본 데이터 검증.
5
- *
6
- * 두 갈래로 돌려준다.
7
- *
8
- * errors 형식·정합성이 깨진 것. **컴파일할 수 없다.**
9
- * violations 형식은 맞으나 정책을 넘은 것. **컴파일을 막지 않는다.**
10
- *
11
- * 둘을 가르는 기준은 「이대로 세울 수 있는가」다. 치수가 없으면 세울 수 없으므로
12
- * error 다. 재질이 넷이면 세울 수는 있고 다만 draw call 이 는다 — violation 이다.
13
- *
14
- * 정책 위반을 막지 않는 것은 뜻이 있다. 막으면 사용자가 우회하고, **숫자로 보여
15
- * 주면 아낀다.** 랜드마크 설비 하나는 정교해도 되며, 그 대가가 도면 예산에 드러나면
16
- * 된다.
17
- *
18
- * 결측을 기본값으로 갈음하지 않는다. 값이 없는 것과 0 인 것은 다르고, 갈음하면
19
- * 저작자가 빠뜨린 것을 알 수 없다.
20
- */
21
- import { ANCHOR_RULES, AXES, CAPABILITY_NEEDS, FIGURE_CAPABILITIES, CURVED_PRIMITIVES, EXTRUDE_PRIMITIVES, CHANNEL_PATHS, DEFAULT_SIZING, DETAIL_LEVELS, FIGURE_PLACEMENTS, LABEL_WHENS, LIMITS, MATERIAL_PRESETS, MATERIAL_SLOTS, INTERPOLATIONS, PART_LIMIT, PART_ROLES, PRIMITIVE_KINDS, SEGMENTED_PRIMITIVES, SEGMENT_PRESETS, SIZING_RULES, SKIN, SURFACE_KINDS, isPlaceholderType, TYPE_LENGTH, TYPE_PATTERN, FIGURE_SOURCE_VERSION, JOINT_TYPES } from "./types.js";
22
- import { drawCallsOf, isDrawn, jointFrames, movingNames } from "./grouping.js";
23
- import { rotatedExtentOf } from "./blueprint.js";
24
- import { centredSource } from "./origin.js";
25
- import { groupKeyOf } from "./keys.js";
26
- function isObject(v) {
27
- return typeof v === 'object' && v !== null && !Array.isArray(v);
28
- }
29
- function isFiniteNumber(v) {
30
- return typeof v === 'number' && Number.isFinite(v);
31
- }
32
- function isNonEmptyString(v) {
33
- return typeof v === 'string' && v.trim().length > 0;
34
- }
35
- class Collector {
36
- errors = [];
37
- violations = [];
38
- error(code, path, message) {
39
- this.errors.push({ code, path, message });
40
- }
41
- violate(code, message, actual, limit, reason) {
42
- const v = { code, message };
43
- if (actual !== undefined)
44
- v.actual = actual;
45
- if (limit !== undefined)
46
- v.limit = limit;
47
- if (reason) {
48
- v.why = reason.why;
49
- v.how = reason.how;
50
- }
51
- this.violations.push(v);
52
- }
53
- /** 유한한 수인지. 아니면 error 를 남기고 false. */
54
- finite(v, path, label) {
55
- if (isFiniteNumber(v))
56
- return true;
57
- this.error('not-finite', path, `${label}는 유한한 수여야 한다`);
58
- return false;
59
- }
60
- /** 양수인지. */
61
- positive(v, path, label) {
62
- if (!this.finite(v, path, label))
63
- return false;
64
- if (v > 0)
65
- return true;
66
- this.error('not-positive', path, `${label}는 0 보다 커야 한다`);
67
- return false;
68
- }
69
- /** 열거 값인지. */
70
- oneOf(v, allowed, path, label) {
71
- if (typeof v === 'string' && allowed.includes(v))
72
- return true;
73
- this.error('not-allowed', path, `${label}는 ${allowed.join(' · ')} 중 하나여야 한다`);
74
- return false;
75
- }
76
- }
77
- function validateVec3(c, v, path, label) {
78
- if (!isObject(v)) {
79
- c.error('not-object', path, `${label}는 { x, y, z } 여야 한다`);
80
- return;
81
- }
82
- c.finite(v.x, `${path}.x`, `${label}.x`);
83
- c.finite(v.y, `${path}.y`, `${label}.y`);
84
- c.finite(v.z, `${path}.z`, `${label}.z`);
85
- }
86
- function validateTransform(c, part, path) {
87
- const t = part.transform;
88
- if (!isObject(t)) {
89
- c.error('missing-transform', `${path}.transform`, 'transform 이 없다');
90
- return;
91
- }
92
- if (!isObject(t.position)) {
93
- c.error('missing-position', `${path}.transform.position`, 'position 이 없다');
94
- }
95
- else {
96
- validateVec3(c, t.position, `${path}.transform.position`, 'position');
97
- }
98
- const size = t.size;
99
- if (!isObject(size)) {
100
- c.error('missing-size', `${path}.transform.size`, 'size 가 없다');
101
- }
102
- else {
103
- // 바닥에 깔린 평면 단면(extrude)은 y 가 0 일 수 있으나 x·z 는 있어야 한다.
104
- c.positive(size.x, `${path}.transform.size.x`, 'size.x');
105
- c.positive(size.z, `${path}.transform.size.z`, 'size.z');
106
- if (size.y !== undefined)
107
- c.finite(size.y, `${path}.transform.size.y`, 'size.y');
108
- else
109
- c.error('missing-size', `${path}.transform.size.y`, 'size.y 가 없다 — 0 으로 갈음하지 않는다');
110
- }
111
- if (t.rotation !== undefined) {
112
- if (!isObject(t.rotation)) {
113
- c.error('not-object', `${path}.transform.rotation`, 'rotation 은 객체여야 한다');
114
- }
115
- else {
116
- for (const axis of AXES) {
117
- const value = t.rotation[axis];
118
- if (value !== undefined)
119
- c.finite(value, `${path}.transform.rotation.${axis}`, `rotation.${axis}`);
120
- }
121
- }
122
- }
123
- }
124
- /**
125
- * 볼록한가 — 모든 코너가 같은 쪽으로 돈다.
126
- *
127
- * 외적의 부호가 한 번이라도 뒤집히면 오목한 자리가 있다. 0 은 일직선이라 세지 않는다.
128
- */
129
- function isConvex(path) {
130
- const points = path.filter(p => isObject(p) && isFiniteNumber(p.x) && isFiniteNumber(p.y));
131
- if (points.length < 3)
132
- return true;
133
- let sign = 0;
134
- for (let i = 0; i < points.length; i++) {
135
- const a = points[i];
136
- const b = points[(i + 1) % points.length];
137
- const d = points[(i + 2) % points.length];
138
- const cross = (b.x - a.x) * (d.y - b.y) - (b.y - a.y) * (d.x - b.x);
139
- if (cross === 0)
140
- continue;
141
- const now = cross > 0 ? 1 : -1;
142
- if (sign === 0)
143
- sign = now;
144
- else if (sign !== now)
145
- return false;
146
- }
147
- return true;
148
- }
149
- function validatePart(c, raw, index, seen) {
150
- const path = `parts[${index}]`;
151
- if (!isObject(raw)) {
152
- c.error('not-object', path, '부품은 객체여야 한다');
153
- return;
154
- }
155
- // 이름 — 저장되는 식별자다
156
- if (!isNonEmptyString(raw.name)) {
157
- c.error('missing-name', `${path}.name`, '부품 이름이 없다 — 바인딩·애니메이션이 이 이름을 가리킨다');
158
- }
159
- else if (seen.has(raw.name)) {
160
- c.error('duplicate-name', `${path}.name`, `부품 이름이 겹친다: ${raw.name}`);
161
- }
162
- else {
163
- seen.add(raw.name);
164
- }
165
- const isPrimitive = c.oneOf(raw.primitive, PRIMITIVE_KINDS, `${path}.primitive`, 'primitive');
166
- validateTransform(c, raw, path);
167
- // 분할 수 — 곡면이 있는 프리미티브만 뜻이 있다
168
- if (raw.segments !== undefined) {
169
- if (!isFiniteNumber(raw.segments) || !Number.isInteger(raw.segments)) {
170
- c.error('not-integer', `${path}.segments`, 'segments 는 정수여야 한다');
171
- }
172
- else if (raw.segments < LIMITS.minSegments) {
173
- c.error('too-few-segments', `${path}.segments`, `segments 는 ${LIMITS.minSegments} 이상이어야 한다`);
174
- }
175
- else if (!SEGMENT_PRESETS.includes(raw.segments)) {
176
- c.violate('segments-off-preset', `${raw.name ?? path}: 분할 수 ${raw.segments} 는 프리셋(${SEGMENT_PRESETS.join(' · ')}) 이 아니다`, raw.segments);
177
- }
178
- if (isPrimitive && !SEGMENTED_PRIMITIVES.includes(raw.primitive)) {
179
- c.violate('segments-ignored', `${raw.name ?? path}: ${raw.primitive} 는 분할 수를 쓰지 않는다`);
180
- }
181
- }
182
- // extrude 계열의 단면
183
- if (raw.primitive === 'polygon') {
184
- const shape = raw.shape;
185
- const pathPoints = isObject(shape) ? shape.path : undefined;
186
- if (!Array.isArray(pathPoints) || pathPoints.length < 3) {
187
- c.error('missing-shape-path', `${path}.shape.path`, 'polygon 은 3 점 이상의 path 가 필요하다');
188
- }
189
- else {
190
- pathPoints.forEach((p, i) => {
191
- if (!isObject(p) || !isFiniteNumber(p.x) || !isFiniteNumber(p.y)) {
192
- c.error('bad-shape-point', `${path}.shape.path[${i}]`, 'path 의 점은 { x, y } 여야 한다');
193
- }
194
- });
195
- }
196
- }
197
- /*
198
- 속을 판 단면.
199
-
200
- 두께가 치수의 절반을 넘으면 안쪽이 남지 않는다. 그러면 그냥 덩어리이고, 저작자가
201
- 통으로 만들려던 것이 아니다.
202
-
203
- 오목한 `polygon` 은 아직 못 판다. 안쪽 테를 만들려면 변을 안으로 밀어 이웃과
204
- 다시 만나게 해야 하는데, 오목한 자리에서는 그 교점이 도형 밖으로 나가 테가 제
205
- 몸을 가로지른다. **되는 척하고 엉킨 형상을 내놓지 않는다** — 여기서 잡는다.
206
- */
207
- const hollow = isObject(raw.shape) ? raw.shape.hollow : undefined;
208
- if (isObject(hollow)) {
209
- if (!EXTRUDE_PRIMITIVES.includes(raw.primitive)) {
210
- c.violate('hollow-ignored', `${raw.name ?? path}: ${raw.primitive} 는 밀어 만드는 것이 아니라 속을 팔 수 없다`);
211
- }
212
- else if (c.positive(hollow.wall, `${path}.shape.hollow.wall`, 'hollow.wall')) {
213
- if (hollow.floor !== undefined)
214
- c.finite(hollow.floor, `${path}.shape.hollow.floor`, 'hollow.floor');
215
- const size = isObject(raw.transform) && isObject(raw.transform.size) ? raw.transform.size : null;
216
- const wall = hollow.wall;
217
- const floor = isFiniteNumber(hollow.floor) ? hollow.floor : wall;
218
- if (size) {
219
- for (const axis of ['x', 'z']) {
220
- if (isFiniteNumber(size[axis]) && wall * 2 >= size[axis]) {
221
- c.error('hollow-too-thick', `${path}.shape.hollow.wall`, `벽 ${wall} 둘이 ${axis} 치수 ${size[axis]} 를 다 먹는다 — 팔 안쪽이 없다`);
222
- }
223
- }
224
- if (isFiniteNumber(size.y) && floor >= size.y) {
225
- c.error('hollow-too-thick', `${path}.shape.hollow.floor`, `바닥 ${floor} 가 높이 ${size.y} 를 다 먹는다 — 팔 안쪽이 없다`);
226
- }
227
- }
228
- const outline = isObject(raw.shape) ? raw.shape.path : undefined;
229
- if (raw.primitive === 'polygon' && Array.isArray(outline) && !isConvex(outline)) {
230
- c.error('hollow-not-convex', `${path}.shape.hollow`, '오목한 단면은 아직 못 판다 — 안쪽 테가 제 몸을 가로지른다');
231
- }
232
- }
233
- }
234
- /*
235
- 재질 — 색은 토큰으로 온다.
236
-
237
- **능력의 닻에는 요구하지 않는다.** 자리(slot)와 문(port)은 그려지지 않으므로 색을 정하라는
238
- 것이 뜻이 없고, 요구하면 저작자가 아무 색이나 골라 넣게 된다 — 쓰이지 않는 값이 정본에 남는다.
239
- */
240
- validatePartCapability(c, raw, path);
241
- const isAnchor = isObject(raw.capability);
242
- if (!isObject(raw.material)) {
243
- if (!isAnchor) {
244
- c.error('missing-material', `${path}.material`, 'material 이 없다');
245
- }
246
- }
247
- else {
248
- const m = raw.material;
249
- if (!isNonEmptyString(m.token) && m.preset === undefined) {
250
- c.error('material-unresolvable', `${path}.material`, 'token 이나 preset 중 하나는 있어야 한다');
251
- }
252
- if (m.preset !== undefined)
253
- c.oneOf(m.preset, MATERIAL_PRESETS, `${path}.material.preset`, 'preset');
254
- if (m.flatShading !== undefined && typeof m.flatShading !== 'boolean') {
255
- c.error('not-boolean', `${path}.material.flatShading`, 'flatShading 은 참·거짓이어야 한다');
256
- }
257
- if (m.transparent !== undefined && typeof m.transparent !== 'boolean') {
258
- c.error('not-boolean', `${path}.material.transparent`, 'transparent 는 참·거짓이어야 한다');
259
- }
260
- /*
261
- 켜진 모습. 있으면 램프다.
262
-
263
- 세기는 0~1 이다. 위로 열어 두면 저작자가 5 를 넣어 흰 덩어리를 만든다 — 화면에서는
264
- 형상이 사라지는데 오류는 아니라서 무엇이 잘못됐는지 알 수 없다.
265
- */
266
- if (m.emissive !== undefined) {
267
- if (!isObject(m.emissive)) {
268
- c.error('not-object', `${path}.material.emissive`, 'emissive 는 객체여야 한다');
269
- }
270
- else {
271
- const spec = m.emissive;
272
- if (spec.token !== undefined && !isNonEmptyString(spec.token)) {
273
- c.error('material-unresolvable', `${path}.material.emissive.token`, 'emissive.token 이 비었다 — 없으면 아예 적지 않는다');
274
- }
275
- if (c.finite(spec.intensity, `${path}.material.emissive.intensity`, 'emissive.intensity')) {
276
- const strength = spec.intensity;
277
- if (strength < 0 || strength > 1) {
278
- c.error('out-of-range', `${path}.material.emissive.intensity`, 'emissive.intensity 는 0 과 1 사이여야 한다');
279
- }
280
- }
281
- if (spec.on !== undefined && typeof spec.on !== 'boolean') {
282
- c.error('not-boolean', `${path}.material.emissive.on`, 'emissive.on 은 참·거짓이어야 한다');
283
- }
284
- }
285
- }
286
- // 팔레트 밖 표면 — 미감의 예외다. 형태만 여기서 본다.
287
- if (m.surface !== undefined) {
288
- const u = m.surface;
289
- if (!isObject(u)) {
290
- c.error('not-object', `${path}.material.surface`, 'surface 는 객체여야 한다');
291
- }
292
- else {
293
- c.oneOf(u.kind, SURFACE_KINDS, `${path}.material.surface.kind`, 'kind');
294
- if (!isNonEmptyString(u.ref)) {
295
- c.error('missing-surface-ref', `${path}.material.surface.ref`, 'ref 가 없다 — 무엇을 쓸지 모른다');
296
- }
297
- }
298
- }
299
- }
300
- if (raw.materialSlot !== undefined) {
301
- c.oneOf(raw.materialSlot, MATERIAL_SLOTS, `${path}.materialSlot`, 'materialSlot');
302
- }
303
- if (raw.sizing !== undefined)
304
- c.oneOf(raw.sizing, SIZING_RULES, `${path}.sizing`, 'sizing');
305
- // repeat — sizing 이 repeat 면 필수
306
- if (raw.sizing === 'repeat') {
307
- if (!isObject(raw.repeat)) {
308
- c.error('missing-repeat', `${path}.repeat`, "sizing 이 'repeat' 이면 repeat 가 필요하다");
309
- }
310
- else {
311
- c.oneOf(raw.repeat.axis, AXES, `${path}.repeat.axis`, 'repeat.axis');
312
- c.positive(raw.repeat.pitch, `${path}.repeat.pitch`, 'repeat.pitch');
313
- }
314
- }
315
- else if (raw.repeat !== undefined) {
316
- c.violate('repeat-ignored', `${raw.name ?? path}: sizing 이 'repeat' 가 아니면 repeat 는 쓰이지 않는다`);
317
- }
318
- /*
319
- 단면을 원으로 지킬 것인가. 곡면만 뜻이 있다.
320
-
321
- 적어 두고 아무 일도 안 일어나면 저작자는 「꺼 뒀다」고 믿는다. 상자에 적은 것을
322
- 짚어 준다.
323
- */
324
- if (raw.keepRound !== undefined) {
325
- if (typeof raw.keepRound !== 'boolean') {
326
- c.error('not-boolean', `${path}.keepRound`, 'keepRound 는 참·거짓이어야 한다');
327
- }
328
- else if (!CURVED_PRIMITIVES.includes(raw.primitive)) {
329
- c.violate('keep-round-ignored', `${raw.name ?? path}: ${raw.primitive} 는 단면이 원이 아니다`);
330
- }
331
- }
332
- // anchor — 어느 면을 붙잡나
333
- if (raw.anchor !== undefined) {
334
- if (!isObject(raw.anchor)) {
335
- c.error('not-object', `${path}.anchor`, 'anchor 는 축별 객체다 — 예: { y: \'max\', x: \'span\' }');
336
- }
337
- else {
338
- for (const axis of AXES) {
339
- if (raw.anchor[axis] !== undefined) {
340
- c.oneOf(raw.anchor[axis], ANCHOR_RULES, `${path}.anchor.${axis}`, `anchor.${axis}`);
341
- }
342
- }
343
- /*
344
- `aspect-` 는 다른 축을 가리킨다. 자기 자신이면 뜻이 없고, 또 다른 `aspect-` 를
345
- 가리키면 서로 미룬다.
346
- */
347
- for (const axis of AXES) {
348
- const rule = raw.anchor[axis];
349
- if (typeof rule !== 'string' || !rule.startsWith('aspect-'))
350
- continue;
351
- const named = rule.slice('aspect-'.length);
352
- if (named === axis) {
353
- c.error('bad-aspect-axis', `${path}.anchor.${axis}`, `${axis} 가 자기 자신을 가리킨다`);
354
- }
355
- else if (String(raw.anchor[named] ?? '').startsWith('aspect-')) {
356
- c.error('bad-aspect-axis', `${path}.anchor.${axis}`, `${axis} 가 가리킨 ${named} 도 다른 축을 따라간다 — 따라갈 배율이 정해지지 않는다`);
357
- }
358
- }
359
- /*
360
- 여기 `anchor-ignored` 가 있었다. **틀린 경고였다.**
361
-
362
- 「`sizing` 이 `'scale'` 이면 anchor 는 쓰이지 않는다」고 알렸는데, `anchorOf` 는 적어 둔
363
- 값을 먼저 돌려준다 — `scale` 부품의 anchor.y 에 `max` 를 적으면 그 축에서는 위 면에서
364
- 잰 거리를 지킨다. 형식이 자기 동작과 반대되는 말을 저작자 화면에 내보내고 있었고,
365
- 그 말을 믿은 저작자는 **동작하는 선언을 지웠을 것이다.**
366
-
367
- `'scale'` 은 「적지 않았을 때의 축별 기본값」이지 anchor 를 막는 값이 아니다. 두 축은
368
- 상자와 함께 키우고 한 축만 면을 붙잡는 것이 정상적인 저작이다.
369
- */
370
- }
371
- }
372
- // Parent. Whether it names a part and whether the chain ends is checked once every name is known.
373
- if (raw.parent !== undefined && !isNonEmptyString(raw.parent)) {
374
- c.error('unknown-parent', `${path}.parent`, 'parent 는 부품 이름이어야 한다');
375
- }
376
- // 라벨
377
- if (raw.label !== undefined) {
378
- if (!isObject(raw.label)) {
379
- c.error('not-object', `${path}.label`, 'label 은 객체여야 한다');
380
- }
381
- else {
382
- if (!isNonEmptyString(raw.label.name)) {
383
- c.error('missing-label-name', `${path}.label.name`, 'label.name 이 없다');
384
- }
385
- if (!isNonEmptyString(raw.label.source)) {
386
- c.error('missing-label-source', `${path}.label.source`, 'label.source 가 없다 — 무엇을 보일지 모른다');
387
- }
388
- c.oneOf(raw.label.when, LABEL_WHENS, `${path}.label.when`, 'label.when');
389
- }
390
- }
391
- }
392
- /**
393
- * 움직임을 본다.
394
- *
395
- * ## 왜 시각 순서를 오류로 보나
396
- *
397
- * 키가 뒤섞이면 표본추출이 뜻을 잃는다. 런타임이 정렬해 줄 수도 있지만, 그러면 저작자가
398
- * 적은 순서와 실제로 도는 순서가 달라지고 화면에서는 그저 「이상하게 움직인다」로만
399
- * 보인다. 여기서 막는 편이 낫다.
400
- *
401
- * ## 왜 키 하나는 안 되나
402
- *
403
- * 하나짜리는 움직임이 아니라 자세 하나다. 그런 것은 `transform` 이 이미 적는 자리다.
404
- */
405
- /**
406
- * 채널 한 벌의 모양을 본다 — **애니메이션과 파라미터가 같이 쓴다.**
407
- *
408
- * 둘의 자세 표현이 같은 모양이라서다(ADR-0051 — 「포즈 데이터는 glTF 꼴 한 벌」). 여기를
409
- * 포크하면 한쪽에만 검사가 붙고, 어느 쪽이 덜 검사받는지는 아무도 모르게 된다.
410
- */
411
- function checkChannels(c, channels, path, parts, joints = NO_JOINTS) {
412
- if (!Array.isArray(channels) || channels.length === 0) {
413
- c.error('missing-channels', `${path}.channels`, 'channels 가 없다 — 무엇을 움직이는지 모른다');
414
- return;
415
- }
416
- channels.forEach((rawChannel, index) => {
417
- const where = `${path}.channels[${index}]`;
418
- if (!isObject(rawChannel)) {
419
- c.error('not-object', where, 'channel 은 객체여야 한다');
420
- return;
421
- }
422
- const channel = rawChannel;
423
- const joint = isNonEmptyString(channel.target) ? joints.get(channel.target) : undefined;
424
- if (joint !== undefined) {
425
- checkJointChannel(c, channel, where, joint);
426
- return;
427
- }
428
- if (!isNonEmptyString(channel.target)) {
429
- c.error('unknown-channel-target', `${where}.target`, 'target 이 없다 — 어느 부품인지 모른다');
430
- }
431
- else if (!parts.has(channel.target)) {
432
- c.error('unknown-channel-target', `${where}.target`, `그런 부품이나 관절이 없다: ${channel.target}`);
433
- }
434
- const pathOk = c.oneOf(channel.path, CHANNEL_PATHS, `${where}.path`, 'path');
435
- if (channel.interpolation !== undefined) {
436
- c.oneOf(channel.interpolation, INTERPOLATIONS, `${where}.interpolation`, 'interpolation');
437
- }
438
- if (channel.pivot !== undefined) {
439
- validateVec3(c, channel.pivot, `${where}.pivot`, 'pivot');
440
- // 회전 중심은 회전에서만 뜻이 있다. 다른 경로에 적으면 아무 일도 안 일어난다.
441
- if (pathOk && channel.path !== 'rotation') {
442
- c.violate('pivot-ignored', `${where}: pivot 은 rotation 에서만 쓰인다`);
443
- }
444
- }
445
- if (!Array.isArray(channel.keys) || channel.keys.length < 2) {
446
- c.error('too-few-keys', `${where}.keys`, '키가 둘은 있어야 한다 — 하나는 움직임이 아니라 자세다');
447
- return;
448
- }
449
- let previous = -Infinity;
450
- channel.keys.forEach((rawKey, k) => {
451
- const spot = `${where}.keys[${k}]`;
452
- if (!isObject(rawKey)) {
453
- c.error('not-object', spot, '키는 { at, value } 여야 한다');
454
- return;
455
- }
456
- if (c.finite(rawKey.at, `${spot}.at`, 'at')) {
457
- const now = rawKey.at;
458
- if (now < 0)
459
- c.error('not-positive', `${spot}.at`, '키의 시각은 0 이상이어야 한다');
460
- else if (now <= previous) {
461
- c.error('keys-out-of-order', `${spot}.at`, '키의 시각은 앞의 것보다 커야 한다');
462
- }
463
- previous = now;
464
- }
465
- validateVec3(c, rawKey.value, `${spot}.value`, 'value');
466
- });
467
- });
468
- }
469
- const NO_JOINTS = new Map();
470
- /**
471
- * A channel that drives a joint (ADR-0066 decision 2).
472
- *
473
- * It names no `path` or `pivot`, because the joint already says what moves and about which point. Each
474
- * key's value is the joint coordinate: degrees for revolute and continuous, millimetres for prismatic. A
475
- * value outside the joint's limits is a pose the machine cannot take.
476
- */
477
- function checkJointChannel(c, channel, where, joint) {
478
- if (channel.path !== undefined) {
479
- c.error('not-allowed', `${where}.path`, '관절을 모는 channel 에는 path 를 적지 않는다 — 관절이 이미 무엇이 움직이는지 말한다');
480
- }
481
- if (channel.pivot !== undefined) {
482
- c.error('not-allowed', `${where}.pivot`, '관절을 모는 channel 에는 pivot 을 적지 않는다 — 중심은 관절의 origin 이다');
483
- }
484
- if (channel.interpolation !== undefined) {
485
- c.oneOf(channel.interpolation, INTERPOLATIONS, `${where}.interpolation`, 'interpolation');
486
- }
487
- if (!Array.isArray(channel.keys) || channel.keys.length < 2) {
488
- c.error('too-few-keys', `${where}.keys`, '키가 둘은 있어야 한다 — 하나는 움직임이 아니라 자세다');
489
- return;
490
- }
491
- let previous = -Infinity;
492
- channel.keys.forEach((rawKey, k) => {
493
- const spot = `${where}.keys[${k}]`;
494
- if (!isObject(rawKey)) {
495
- c.error('not-object', spot, '키는 { at, value } 여야 한다');
496
- return;
497
- }
498
- if (c.finite(rawKey.at, `${spot}.at`, 'at')) {
499
- const now = rawKey.at;
500
- if (now < 0)
501
- c.error('not-positive', `${spot}.at`, '키의 시각은 0 이상이어야 한다');
502
- else if (now <= previous) {
503
- c.error('keys-out-of-order', `${spot}.at`, '키의 시각은 앞의 것보다 커야 한다');
504
- }
505
- previous = now;
506
- }
507
- if (!c.finite(rawKey.value, `${spot}.value`, '관절 값'))
508
- return;
509
- const value = rawKey.value;
510
- if (joint.limits && (value < joint.limits.min || value > joint.limits.max)) {
511
- c.error('out-of-range', `${spot}.value`, `관절 값 ${value} 이 가동 범위 ${joint.limits.min}~${joint.limits.max} 밖이다`);
512
- }
513
- });
514
- }
515
- /**
516
- * Parents (ADR-0066 decision 1). Every `parent` names a part, and following parents from any part ends at
517
- * the figure frame. A cycle is reported on each part in it.
518
- */
519
- function checkHierarchy(c, source) {
520
- const parentOf = new Map();
521
- source.parts.forEach((part, index) => {
522
- if (!isObject(part) || !isNonEmptyString(part.name) || !isNonEmptyString(part.parent))
523
- return;
524
- if (!source.parts.some(other => isObject(other) && other.name === part.parent)) {
525
- c.error('unknown-parent', `parts[${index}].parent`, `그런 부품이 없다: ${part.parent}`);
526
- return;
527
- }
528
- parentOf.set(part.name, part.parent);
529
- });
530
- source.parts.forEach((part, index) => {
531
- if (!isObject(part) || !parentOf.has(part.name))
532
- return;
533
- const seen = new Set([part.name]);
534
- let at = parentOf.get(part.name);
535
- while (at !== undefined) {
536
- if (at === part.name) {
537
- c.error('parent-cycle', `parts[${index}].parent`, `부모를 따라가면 '${part.name}' 으로 돌아온다 — 틀에 닿지 않는다`);
538
- return;
539
- }
540
- if (seen.has(at))
541
- return;
542
- seen.add(at);
543
- at = parentOf.get(at);
544
- }
545
- });
546
- }
547
- /**
548
- * Joints (ADR-0066). Returns the joints channels may target, keyed by name.
549
- *
550
- * A joint name shares the name space with parts, so a channel target never means two things. A part is
551
- * the child of at most one joint. `limits` must contain 0, because the rest pose every `transform`
552
- * describes is joint value 0.
553
- */
554
- function checkJoints(c, source) {
555
- const said = source.joints;
556
- const seen = new Map();
557
- if (said === undefined)
558
- return seen;
559
- if (!Array.isArray(said)) {
560
- c.error('not-object', 'joints', 'joints 는 배열이어야 한다');
561
- return seen;
562
- }
563
- const parts = new Set(source.parts.map(part => part?.name).filter(isNonEmptyString));
564
- const names = new Set();
565
- const children = new Set();
566
- said.forEach((raw, at) => {
567
- const path = `joints[${at}]`;
568
- if (!isObject(raw)) {
569
- c.error('not-object', path, '관절은 객체여야 한다');
570
- return;
571
- }
572
- let named = false;
573
- if (!isNonEmptyString(raw.name)) {
574
- c.error('missing-name', `${path}.name`, '관절 이름이 없다 — channel 이 이 이름으로 관절을 몬다');
575
- }
576
- else if (names.has(raw.name) || parts.has(raw.name)) {
577
- c.error('duplicate-name', `${path}.name`, `이름이 겹친다: ${raw.name} — 관절과 부품은 이름을 나눠 쓴다`);
578
- }
579
- else {
580
- names.add(raw.name);
581
- named = true;
582
- }
583
- if (!isNonEmptyString(raw.child) || !parts.has(raw.child)) {
584
- c.error('unknown-joint-child', `${path}.child`, `child 가 부품 이름이 아니다: ${String(raw.child)}`);
585
- }
586
- else if (children.has(raw.child)) {
587
- c.error('joint-child-taken', `${path}.child`, `'${raw.child}' 은 이미 다른 관절의 child 다 — 한 부품에 관절은 하나다`);
588
- }
589
- else {
590
- children.add(raw.child);
591
- }
592
- const typed = c.oneOf(raw.type, JOINT_TYPES, `${path}.type`, 'type');
593
- validateVec3(c, raw.origin, `${path}.origin`, 'origin');
594
- if (!isObject(raw.axis)) {
595
- validateVec3(c, raw.axis, `${path}.axis`, 'axis');
596
- }
597
- else {
598
- const { x, y, z } = raw.axis;
599
- if (c.finite(x, `${path}.axis.x`, 'axis.x') && c.finite(y, `${path}.axis.y`, 'axis.y') && c.finite(z, `${path}.axis.z`, 'axis.z')) {
600
- if (Math.hypot(x, y, z) === 0) {
601
- c.error('bad-joint-axis', `${path}.axis`, 'axis 가 0 벡터다 — 어느 쪽으로 움직이는지 없다');
602
- }
603
- }
604
- }
605
- let limits;
606
- if (raw.limits !== undefined) {
607
- if (typed && raw.type === 'continuous') {
608
- c.error('not-allowed', `${path}.limits`, 'continuous 관절에는 limits 가 없다 — 끝없이 돈다');
609
- }
610
- else if (!isObject(raw.limits)) {
611
- c.error('not-object', `${path}.limits`, 'limits 는 { min, max } 여야 한다');
612
- }
613
- else {
614
- const lo = c.finite(raw.limits.min, `${path}.limits.min`, 'min');
615
- const hi = c.finite(raw.limits.max, `${path}.limits.max`, 'max');
616
- if (lo && hi) {
617
- const min = raw.limits.min;
618
- const max = raw.limits.max;
619
- if (max <= min) {
620
- c.error('bad-range', `${path}.limits`, 'max 는 min 보다 커야 한다 — 같으면 움직일 자리가 없다');
621
- }
622
- else if (min > 0 || max < 0) {
623
- c.error('out-of-range', `${path}.limits`, 'limits 가 0 을 품어야 한다 — 그린 자세가 관절 값 0 이다');
624
- }
625
- else {
626
- limits = { min, max };
627
- }
628
- }
629
- }
630
- }
631
- if (named && typed) {
632
- seen.set(raw.name, limits ? { type: raw.type, limits } : { type: raw.type });
633
- }
634
- });
635
- return seen;
636
- }
637
- function checkAnimations(c, source, joints = NO_JOINTS) {
638
- const clips = source.animations;
639
- if (clips === undefined)
640
- return;
641
- if (!Array.isArray(clips)) {
642
- c.error('not-object', 'animations', 'animations 는 배열이어야 한다');
643
- return;
644
- }
645
- const names = new Set();
646
- const parts = new Set(source.parts.map(part => part?.name).filter(isNonEmptyString));
647
- clips.forEach((raw, at) => {
648
- const path = `animations[${at}]`;
649
- if (!isObject(raw)) {
650
- c.error('not-object', path, 'clip 은 객체여야 한다');
651
- return;
652
- }
653
- if (!isNonEmptyString(raw.name)) {
654
- c.error('missing-name', `${path}.name`, 'clip 이름이 없다 — 인스턴스가 이 이름으로 구동한다');
655
- }
656
- else if (names.has(raw.name)) {
657
- c.error('duplicate-name', `${path}.name`, `clip 이름이 겹친다: ${raw.name}`);
658
- }
659
- else {
660
- names.add(raw.name);
661
- }
662
- /*
663
- `drive` held a value-driven pose as 'hold' before parameters had their own field (ADR-0051).
664
- Stored sources were moved when edition 2 arrived (ADR-0065), so the field is refused rather
665
- than read by an old rule.
666
- */
667
- if (raw.drive !== undefined) {
668
- c.error('not-allowed', `${path}.drive`, 'drive 칸은 없어졌다. 값으로 움직이는 자세는 parameters 에 적는다');
669
- }
670
- checkChannels(c, raw.channels, path, parts, joints);
671
- });
672
- }
673
- /**
674
- * 능력 선언의 **모양만** 본다.
675
- *
676
- * 이름이 실제로 얹히는 능력인지는 씬이 판정한다(`registerFigure`). 여기서 닫힌 목록을 또 적으면
677
- * 계약이 두 벌이 되고, 씬에 능력이 하나 생길 때마다 형식을 고쳐야 한다.
678
- */
679
- /**
680
- * 부품이 능력의 닻일 때 그 선언의 모양을 본다.
681
- *
682
- * 역할 이름은 형식이 정한다(`PART_ROLES`) — 이것은 씬의 낱말이 아니라 **형식이 부품에 붙이는
683
- * 역할**이고, 씬은 그것을 받아 `SlotDef`·`Port` 로 옮긴다.
684
- */
685
- function validatePartCapability(c, raw, path) {
686
- const spec = raw.capability;
687
- if (spec === undefined)
688
- return;
689
- if (!isObject(spec)) {
690
- c.error('bad-part-capability', `${path}.capability`, 'capability 는 { roles, … } 여야 한다');
691
- return;
692
- }
693
- /*
694
- 역할은 **여럿일 수 있다.** AGV 의 상판이 그렇다 — 물건을 놓는 자리이면서 그 자리로 물건이
695
- 들어오고 나간다. 하나만 받으면 저작자가 같은 위치에 부품을 두 개 만들게 되고, 실물에 없는
696
- 구조를 형식이 만들어 낸다.
697
-
698
- 비어 있는 배열은 막는다. 역할이 없는 부품은 그냥 그리는 부품이므로 `capability` 를 적지
699
- 않는 것이 맞고, 빈 배열을 통과시키면 「능력을 줬는데 아무 일도 없는」 부품이 생긴다.
700
- */
701
- if (!Array.isArray(spec.roles) || spec.roles.length === 0) {
702
- c.error('bad-part-capability', `${path}.capability.roles`, 'roles 는 역할 하나 이상의 배열이어야 한다');
703
- }
704
- else {
705
- const seen = new Set();
706
- for (const [at, role] of spec.roles.entries()) {
707
- if (typeof role !== 'string' || !PART_ROLES.includes(role)) {
708
- c.error('bad-part-capability', `${path}.capability.roles[${at}]`, `역할은 ${PART_ROLES.join(' · ')} 중 하나여야 한다`);
709
- continue;
710
- }
711
- if (seen.has(role)) {
712
- c.error('bad-part-capability', `${path}.capability.roles[${at}]`, `역할 ${role} 이 두 번 적혔다`);
713
- }
714
- seen.add(role);
715
- }
716
- }
717
- if (spec.accepts !== undefined) {
718
- if (!Array.isArray(spec.accepts) || spec.accepts.some(one => typeof one !== 'string' || !one.trim())) {
719
- c.error('bad-part-capability', `${path}.capability.accepts`, 'accepts 는 타입 이름의 배열이어야 한다');
720
- }
721
- }
722
- if (spec.capacity !== undefined) {
723
- if (typeof spec.capacity !== 'number' || !Number.isInteger(spec.capacity) || spec.capacity < 1) {
724
- c.error('bad-part-capability', `${path}.capability.capacity`, 'capacity 는 1 이상의 정수여야 한다');
725
- }
726
- /*
727
- `repeat` 인 자리는 **배율이 칸 수를 정한다.** 둘 다 적으면 어느 것이 이기는지 형식이 말하지
728
- 않게 되고, 저작자는 적어 둔 수가 지켜질 것으로 읽는다.
729
- */
730
- if (raw.sizing === 'repeat') {
731
- c.violate('capacity-with-repeat', '반복하는 자리에 capacity 를 적었다 — 칸 수는 인스턴스 크기가 정하므로 이 값은 쓰이지 않는다');
732
- }
733
- }
734
- if (spec.direction !== undefined) {
735
- validateVec3(c, spec.direction, `${path}.capability.direction`, 'direction');
736
- }
737
- }
738
- /**
739
- * 파라미터 — 값이 만드는 자세.
740
- *
741
- * 자세 표현이 `animations` 와 같은 모양이라 채널 검사는 같은 것을 부른다(ADR-0051 —
742
- * 「포즈 데이터는 glTF 꼴 한 벌」). 여기서 더 보는 것은 **그 위에 얹은 선언**이다.
743
- *
744
- * 이름이 clip 이름과 겹치는지도 본다. 인스턴스는 둘을 이름으로 부르므로, 겹치면 어느 쪽에 값을
745
- * 준 것인지 알 수 없다.
746
- */
747
- function checkParameters(c, source, clipNames, joints = NO_JOINTS) {
748
- const said = source.parameters;
749
- if (said === undefined)
750
- return;
751
- if (!Array.isArray(said)) {
752
- c.error('not-object', 'parameters', 'parameters 는 배열이어야 한다');
753
- return;
754
- }
755
- const names = new Set();
756
- const parts = new Set(source.parts.map(part => part?.name).filter(isNonEmptyString));
757
- said.forEach((raw, at) => {
758
- const path = `parameters[${at}]`;
759
- if (!isObject(raw)) {
760
- c.error('not-object', path, '파라미터는 객체여야 한다');
761
- return;
762
- }
763
- if (!isNonEmptyString(raw.name)) {
764
- c.error('missing-name', `${path}.name`, '파라미터 이름이 없다 — 인스턴스가 이 이름으로 값을 준다');
765
- }
766
- else if (names.has(raw.name) || clipNames.has(raw.name)) {
767
- c.error('duplicate-name', `${path}.name`, `이름이 겹친다: ${raw.name}`);
768
- }
769
- else {
770
- names.add(raw.name);
771
- }
772
- /*
773
- 범위. 인스턴스가 물리량으로 주고받으므로 무엇부터 무엇까지인지 없으면 값을 줄 수가 없다.
774
- 단위는 형식이 해석하지 않는다 — 화면과 API 가 읽는 낱말이다.
775
- */
776
- const range = raw.range;
777
- if (!isObject(range)) {
778
- c.error('missing-range', `${path}.range`, 'range 가 없다 — 인스턴스가 어느 값을 줄지 모른다');
779
- }
780
- else {
781
- if (!isNonEmptyString(range.unit)) {
782
- c.error('missing-range', `${path}.range.unit`, 'unit 이 없다 — 화면이 무슨 수인지 말할 수 없다');
783
- }
784
- const lo = c.finite(range.min, `${path}.range.min`, 'min');
785
- const hi = c.finite(range.max, `${path}.range.max`, 'max');
786
- if (lo && hi && range.max <= range.min) {
787
- c.error('bad-range', `${path}.range`, 'max 는 min 보다 커야 한다 — 같으면 움직일 자리가 없다');
788
- }
789
- if (raw.default !== undefined && c.finite(raw.default, `${path}.default`, 'default')) {
790
- const value = raw.default;
791
- if (lo && hi && (value < range.min || value > range.max)) {
792
- c.error('bad-range', `${path}.default`, '기본값이 범위 밖이다');
793
- }
794
- }
795
- }
796
- const clip = raw.clip;
797
- if (!isObject(clip)) {
798
- c.error('not-object', `${path}.clip`, 'clip 이 없다 — 값이 무슨 자세를 만드는지 모른다');
799
- return;
800
- }
801
- checkChannels(c, clip.channels, `${path}.clip`, parts, joints);
802
- /*
803
- Transition time. 0 is accepted because it means instant. A negative value has no meaning.
804
- */
805
- if (clip.duration !== undefined && c.finite(clip.duration, `${path}.clip.duration`, 'duration')) {
806
- if (clip.duration < 0) {
807
- c.error('not-positive', `${path}.clip.duration`, 'duration 은 0 이상이어야 한다. 0 이면 즉시 움직인다');
808
- }
809
- }
810
- /*
811
- Parameter keys sit on the value span. A key above 1 is a pose no value can reach, which means
812
- the author wrote seconds. Refuse it here instead of leaving an unreachable pose. Negative keys
813
- and key order are already checked in checkChannels.
814
- */
815
- if (Array.isArray(clip.channels)) {
816
- clip.channels.forEach((channel, index) => {
817
- if (!isObject(channel) || !Array.isArray(channel.keys))
818
- return;
819
- channel.keys.forEach((key, k) => {
820
- if (isObject(key) && isFiniteNumber(key.at) && key.at > 1) {
821
- c.error('out-of-range', `${path}.clip.channels[${index}].keys[${k}].at`, '파라미터 키의 at 은 0~1 사이여야 한다. 전이 시간은 clip.duration 에 적는다');
822
- }
823
- });
824
- });
825
- }
826
- });
827
- }
828
- function checkCapabilities(c, source) {
829
- const said = source.capabilities;
830
- if (said === undefined) {
831
- return;
832
- }
833
- if (!Array.isArray(said)) {
834
- c.error('bad-capability', 'capabilities', 'capabilities 는 이름의 배열이어야 한다');
835
- return;
836
- }
837
- const seen = new Set();
838
- said.forEach((name, at) => {
839
- if (typeof name !== 'string' || !name.trim()) {
840
- c.error('bad-capability', `capabilities[${at}]`, '능력 이름이 비어 있다');
841
- return;
842
- }
843
- if (seen.has(name)) {
844
- c.error('duplicate-capability', `capabilities[${at}]`, `능력 ${name} 이 두 번 선언되었다`);
845
- return;
846
- }
847
- /*
848
- 모르는 이름은 **여기서** 막는다.
849
-
850
- 한동안 목록이 씬에만 있어서 이 검사가 없었고, `capabilities: ['hoist']` 가 검증을
851
- 지나고 발행을 지나 렌더링에서 죽었다. 저작자는 자산을 내보내고 나서야 그것이 안
852
- 선다는 것을 알았다(ADR-0051 ④).
853
- */
854
- if (!FIGURE_CAPABILITIES.includes(name)) {
855
- c.error('bad-capability', `capabilities[${at}]`, `모르는 능력이다: ${name} — 쓸 수 있는 것은 ${FIGURE_CAPABILITIES.join(' · ')}`);
856
- return;
857
- }
858
- seen.add(name);
859
- });
860
- /*
861
- 혼자 설 수 없는 능력. 씬이 조립할 때 던지는 것과 같은 규칙인데, 그때는 이미 발행된
862
- 뒤다. 모르는 이름과 같은 자리에서 본다.
863
- */
864
- for (const [name, needs] of Object.entries(CAPABILITY_NEEDS)) {
865
- if (!seen.has(name))
866
- continue;
867
- if (needs.some(one => seen.has(one)))
868
- continue;
869
- c.error('bad-capability', 'capabilities', `${name} 은 혼자 설 수 없다 — ${needs.join(' 이나 ')} 가 함께 있어야 한다`);
870
- }
871
- }
872
- function checkPolicy(c, source) {
873
- /*
874
- 그려지는 부품만 센다.
875
-
876
- 능력의 닻은 재질도 기하도 없다 — `compile` 은 그것을 `groups` 가 아니라 `anchors` 로 보내고,
877
- `costOf` 도 그리는 것만 센다. 여기서만 다 세면 **화면이 말하는 부품 수와 게이트가 말하는
878
- 부품 수가 갈린다.** 이 파일이 한 번 겪은 부류다(정책 3, 실제 17).
879
-
880
- 수가 갈리는 것으로 끝나지도 않는다. `groupKeyOf` 는 재질을 읽으므로 닻이 섞여 들어오면
881
- 형식이 옳다고 한 원본에서 던진다.
882
- */
883
- const parts = source.parts.filter(isDrawn);
884
- // 등급. 없으면 예산을 잡을 수 없다. 오류보고없이 넘기지 않는다.
885
- if (source.detailLevel === undefined) {
886
- c.violate('detail-level-missing', '디테일 등급이 없다 — 부품 수 예산을 잡을 수 없다');
887
- }
888
- else {
889
- const limit = PART_LIMIT[source.detailLevel];
890
- if (parts.length > limit) {
891
- c.violate('too-many-parts', `등급 ${source.detailLevel} 의 부품 수 한도를 넘었다`, parts.length, limit);
892
- }
893
- }
894
- const moving = movingNames(source);
895
- if (moving.size > LIMITS.independentParts) {
896
- c.violate('too-many-independent-parts', '따로 변환되는 부품이 많다 — 저마다 단독 묶음이라 한 화면에 세울 수 있는 대수가 준다', moving.size, LIMITS.independentParts);
897
- }
898
- /*
899
- Draw calls per instance. **What compile and the renderer actually produce.**
900
-
901
- This counted distinct colour and slot combinations, which is smaller than
902
- what gets drawn. A part that does not scale with the instance takes its own
903
- transform and cannot merge, and a repeat part draws once per copy.
904
-
905
- conveyor counted 3, drew 17
906
- cell tray counted 2, drew 9
907
-
908
- A tray drawing nine times passed a limit of three with room to spare. The
909
- author was told the asset was cheap by the one number that did not measure
910
- the cost.
911
- */
912
- // Parts merge only within one joint frame, as compile groups them (ADR-0066 decision 4).
913
- const calls = drawCallsOf(parts, source.base, moving, jointFrames(source));
914
- if (calls > LIMITS.materialGroups) {
915
- c.violate('too-many-material-groups', 'draw call 이 많다 — 한 화면에 함께 세울 수 있는 개수가 준다', calls, LIMITS.materialGroups);
916
- }
917
- const transparent = new Set(parts.filter(p => p.material?.transparent).map(groupKeyOf));
918
- if (transparent.size > LIMITS.transparentMaterials) {
919
- c.violate('too-many-transparent', '투명 재질이 많다 — 정렬 비용이 크다', transparent.size, LIMITS.transparentMaterials);
920
- }
921
- // 팔레트 밖 표면 — 단색 규칙의 예외다. **종류 수**를 센다(같은 ref 는 한 텍스처다).
922
- const surfaces = new Set(parts
923
- .map(p => p.material?.surface)
924
- .filter((u) => u !== undefined)
925
- .map(u => `${u.kind}:${u.ref}`));
926
- if (surfaces.size > LIMITS.surfaceTextures) {
927
- c.violate('too-many-surface-textures', '팔레트 밖 표면이 많다 — 단색 규칙을 벗어나고 텍스처마다 GPU 메모리를 쓴다', surfaces.size, LIMITS.surfaceTextures);
928
- }
929
- const always = parts.filter(p => p.label?.when === 'always');
930
- if (always.length > 1) {
931
- c.violate('too-many-always-labels', "'always' 라벨이 여럿이다 — 대량 배치에서 DOM·텍스처가 는다", always.length, 1);
932
- }
933
- checkBaseBox(c, source);
934
- }
935
- /**
936
- * 부품이 기준 상자와 어떻게 놓여 있나 — **두 가지를 따로 본다.**
937
- *
938
- * ADR-0044 `operato-twin/design/04-decisions.md` — 결정 ① `base` 는 선언된 점유 부피 ·
939
- * 결정 ② 넘침은 violation 둘 · 결정 ③ 자리는 여기지 저작 도구가 아니다.
940
- *
941
- * ## 넘치는 것
942
- *
943
- * `base` 는 나누는 수다. 씬이 `instanceScale = width/base.x` 로 인스턴스 배율을 내므로
944
- * (`figure-instance.ts`), 부품이 상자를 넘으면 그 나누는 수가 거짓말을 하고 도면에 놓였을 때
945
- * 크기가 틀린다. 배치 기준의 높이도 `base.y` 에서 나오므로 높이까지 같이 틀린다.
946
- *
947
- * **덜 채우는 것은 보지 않는다.** `base` 는 감싸는 최소 상자가 아니라 저작자가 선언하는 점유
948
- * 부피다 — 컨베이어 앞의 접근 공간, 랙의 칸 pitch 가 그 여백에 담긴다.
949
- *
950
- * ## 기준면에 안 닿는 것
951
- *
952
- * 바닥 기반이면 부품이 상자 밑면까지 내려와야 하고, 천정 기반이면 윗면까지 올라가야 한다.
953
- * 안 그러면 「바닥에 선다」고 선언해 놓고 공중에 뜬다. 중심 기반에는 요구가 없다 — 떠 있는
954
- * 것이 그 배치의 뜻이다.
955
- *
956
- * **이것이 OHT 표본의 진짜 결함이었다.** 상자는 ±1.5 인데 부품이 -2.725..0 이었다. 「상자가
957
- * 크다」로 읽으면 저작자가 상자를 키우는 쪽으로 고치는데, 실제로 어긋난 것은 원점 규약이다.
958
- * 그래서 코드를 둘로 나눠 사유를 따로 말한다.
959
- *
960
- * **닿는 것은 「도달」로 본다** — 밑면 아래로 지나가는 것도 닿은 것이다. 지나간 것은
961
- * `part-outside-base` 가 이미 말하므로, 같은 사실을 두 번 말하지 않는다.
962
- *
963
- * 회전을 포함한 실제 부피로 잰다(`rotatedExtentOf`). 45도 돌린 판은 제 `size` 보다 넓다.
964
- * 그리지 않는 부품(능력의 닻)도 센다 — 물건이 놓이는 자리가 도형 밖이면 그것도 결함이다.
965
- */
966
- /**
967
- * What to change when a part leaves the base box — in the source's coordinates, which are what the author edits.
968
- *
969
- * X and Z are centred on the box, so a box grown by `size` gains half of it on each side: covering `mm` on one side
970
- * takes `2·mm`. Y is not centred in the source: its bottom is y 0 and its top is `base.y` (ADR-0065 decision 2). A part
971
- * over the top is covered by growing the box by `mm`; a part below the bottom cannot be covered by resizing at all,
972
- * because the bottom does not move. The check itself runs on the centred copy; the advice must not (09-24: it told
973
- * authors to grow Y by `2·mm` for both, and neither fixed a part through the floor).
974
- */
975
- function outsideBaseHow(base, axis, name, mm, overTheTop) {
976
- const AXIS = axis.toUpperCase();
977
- if (axis !== 'y')
978
- return `기준 상자의 ${AXIS} 를 ${Math.round(base[axis] + 2 * mm)} 으로 키우거나(지금 ${base[axis]}), '${name}' 을 ${AXIS} 로 ${mm}mm 안으로 옮기세요.`;
979
- if (overTheTop)
980
- return `기준 상자의 Y 를 ${Math.round(base.y + mm)} 으로 키우거나(지금 ${base.y}), '${name}' 을 Y 로 ${mm}mm 내리세요.`;
981
- return `'${name}' 을 Y 로 ${mm}mm 올리세요. 기준 상자의 밑면은 바닥(Y 0)에 고정이라 상자 크기로는 닿지 않습니다.`;
982
- }
983
- function checkBaseBox(c, source) {
984
- const half = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 };
985
- let low = Infinity;
986
- let high = -Infinity;
987
- let lowest = '';
988
- let highest = '';
989
- for (const part of source.parts) {
990
- const extent = rotatedExtentOf(part);
991
- const at = part.transform.position;
992
- for (const axis of AXES) {
993
- const centre = at[axis] ?? 0;
994
- const reach = extent[axis] / 2;
995
- const above = centre + reach - half[axis];
996
- const below = -half[axis] - (centre - reach);
997
- const over = Math.max(above, below);
998
- if (over > SKIN) {
999
- const mm = Math.round(over * 1e4) / 1e4;
1000
- const AXIS = axis.toUpperCase();
1001
- c.violate('part-outside-base', `'${part.name}' 부품이 기준 상자를 ${AXIS} 로 ${mm}mm 넘습니다.`, mm, 0, {
1002
- why: `기준 상자가 도면에 놓일 때의 배율을 정합니다. 넘은 만큼은 그 배율에 들어가지 않아서, 도면에서 이 부품이 도형에 주어진 자리 밖으로 나갑니다.`,
1003
- how: outsideBaseHow(source.base, axis, part.name, mm, above >= below)
1004
- });
1005
- }
1006
- }
1007
- const bottom = (at.y ?? 0) - extent.y / 2;
1008
- const top = (at.y ?? 0) + extent.y / 2;
1009
- if (bottom < low)
1010
- lowest = part.name;
1011
- if (top > high)
1012
- highest = part.name;
1013
- low = Math.min(low, bottom);
1014
- high = Math.max(high, top);
1015
- }
1016
- const placement = source.placement ?? 'floor';
1017
- if (placement === 'floor' && low > -half.y + SKIN) {
1018
- const mm = Math.round((low + half.y) * 1e4) / 1e4;
1019
- c.violate('parts-off-placement-face', `가장 낮은 '${lowest}' 부품이 기준 상자 밑면에서 ${mm}mm 떠 있습니다.`, mm, 0, {
1020
- why: '배치 기준이 바닥 기반입니다. 도형이 밑면으로 바닥에 닿는다는 뜻이라서, 밑면에 닿지 않으면 도면에서 그만큼 떠 보이고 자리에 놓인 물건도 함께 뜹니다.',
1021
- /* No box change is offered: the box's bottom is y 0 in the source (ADR-0065), so resizing it moves only its top. */
1022
- how: `부품 전체를 Y 로 ${mm}mm 내리거나, 떠 있는 것이 뜻이라면 배치 기준을 중심 기반으로 바꾸세요.`
1023
- });
1024
- }
1025
- if (placement === 'ceiling' && high < half.y - SKIN) {
1026
- const mm = Math.round((half.y - high) * 1e4) / 1e4;
1027
- c.violate('parts-off-placement-face', `가장 높은 '${highest}' 부품이 기준 상자 윗면에서 ${mm}mm 떨어져 있습니다.`, mm, 0, {
1028
- why: '배치 기준이 천장 기반입니다. 도형이 윗면으로 천장에 매달린다는 뜻이라서, 윗면에 닿지 않으면 도면에서 그만큼 내려와 걸립니다.',
1029
- /* The top face is at base.y in the source, so shrinking the box by the gap brings it down to the parts. */
1030
- how: `부품 전체를 Y 로 ${mm}mm 올리거나, 기준 상자의 Y 를 ${Math.round(source.base.y - mm)} 으로 줄이거나(지금 ${source.base.y}), 떨어져 있는 것이 뜻이라면 배치 기준을 중심 기반으로 바꾸세요.`
1031
- });
1032
- }
1033
- }
1034
- /**
1035
- * 원본 데이터를 검증한다.
1036
- *
1037
- * 바깥에서 온 데이터(DB · API · 파일)를 받으므로 `unknown` 을 받는다.
1038
- * `errors` 가 비어 있으면 `source` 는 `FigureSource` 로 다룰 수 있다.
1039
- */
1040
- /**
1041
- * Type name: its shape, and whether a person has named it. These are violations that hold back a
1042
- * release, not errors. Building something before naming it is fine; what gets blocked is the release
1043
- * that makes a promise to drawings.
1044
- */
1045
- function checkTypeName(c, type) {
1046
- if (isPlaceholderType(type)) {
1047
- c.violate('type-is-placeholder', `type '${type}' 은 저장할 때 자동으로 붙은 임시 이름입니다. 발행하려면 이름을 정하세요.`);
1048
- return;
1049
- }
1050
- if (!TYPE_PATTERN.test(type) || type.length < TYPE_LENGTH.min || type.length > TYPE_LENGTH.max) {
1051
- c.violate('type-not-identifier', `type '${type}' 은 쓸 수 없는 이름입니다. 대문자와 밑줄로 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max} 자로 적으세요(예: ROLLER_CONVEYOR).`);
1052
- }
1053
- }
1054
- export function validate(source) {
1055
- const c = new Collector();
1056
- if (!isObject(source)) {
1057
- c.error('not-object', '', '원본 데이터는 객체여야 한다');
1058
- return { errors: c.errors, violations: c.violations };
1059
- }
1060
- if (!isNonEmptyString(source.type)) {
1061
- c.error('missing-type', 'type', 'type 이 없다 — 저장되는 식별자다');
1062
- }
1063
- if (source.version !== undefined && (typeof source.version !== 'number' || !Number.isInteger(source.version) || source.version <= 0)) {
1064
- c.error('not-positive', 'version', 'version 은 양의 정수여야 한다');
1065
- }
1066
- else if (source.version !== FIGURE_SOURCE_VERSION) {
1067
- /*
1068
- A missing version is refused too. Sources saved before the field existed were written with y
1069
- measured from the box centre, so reading them as the current edition would move every part.
1070
- */
1071
- c.error('source-version-unsupported', 'version', `원본 판이 ${source.version ?? '없음'} 이다. 판 ${FIGURE_SOURCE_VERSION} 원본만 읽는다`);
1072
- }
1073
- if (!isObject(source.base)) {
1074
- c.error('missing-base', 'base', 'base 가 없다 — 배치 비율의 근거다');
1075
- }
1076
- else {
1077
- c.positive(source.base.x, 'base.x', 'base.x');
1078
- c.positive(source.base.y, 'base.y', 'base.y');
1079
- c.positive(source.base.z, 'base.z', 'base.z');
1080
- }
1081
- if (source.detailLevel !== undefined) {
1082
- c.oneOf(source.detailLevel, DETAIL_LEVELS, 'detailLevel', 'detailLevel');
1083
- }
1084
- if (source.placement !== undefined) {
1085
- c.oneOf(source.placement, FIGURE_PLACEMENTS, 'placement', 'placement');
1086
- }
1087
- if (source.styleKit !== undefined && !isNonEmptyString(source.styleKit)) {
1088
- c.error('bad-style-kit', 'styleKit', 'styleKit 은 비어 있지 않은 문자여야 한다');
1089
- }
1090
- if (!Array.isArray(source.parts) || source.parts.length === 0) {
1091
- c.error('missing-parts', 'parts', 'parts 가 없다 — 부품이 하나는 있어야 한다');
1092
- return { errors: c.errors, violations: c.violations };
1093
- }
1094
- const seen = new Set();
1095
- source.parts.forEach((part, i) => validatePart(c, part, i, seen));
1096
- // Parents and joints need every part name; channels need the joints.
1097
- checkHierarchy(c, source);
1098
- const joints = checkJoints(c, source);
1099
- // 부품 이름을 다 읽은 뒤라야 channel 의 target 이 가리키는 것이 있는지 볼 수 있다
1100
- checkAnimations(c, source, joints);
1101
- /*
1102
- clip 이름을 함께 넘긴다 — 파라미터와 clip 이 같은 이름을 쓰면 인스턴스가 어느 쪽에 값을 준
1103
- 것인지 알 수 없다. `animations` 가 배열이 아닌 것은 `checkAnimations` 가 이미 말했으므로
1104
- 여기서는 셀 것이 없다고 보고 넘어간다.
1105
- */
1106
- const clips = source.animations;
1107
- checkParameters(c, source, new Set((Array.isArray(clips) ? clips : [])
1108
- .map((clip) => clip?.name)
1109
- .filter(isNonEmptyString)), joints);
1110
- /* 능력 선언의 모양 — 부품과 무관하므로 순서를 따지지 않는다 */
1111
- checkCapabilities(c, source);
1112
- // 정책 검사는 형식이 성립할 때만 뜻이 있다
1113
- if (c.errors.length === 0) {
1114
- checkTypeName(c, source.type);
1115
- checkPolicy(c, centredSource(source));
1116
- }
1117
- return { errors: c.errors, violations: c.violations };
1118
- }
1119
- //# sourceMappingURL=validate.js.map