@things-factory/figure-ui 10.1.27 → 10.1.29

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 (91) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/angle-dial.ts +70 -0
  3. package/client/modeller/base-box.ts +104 -0
  4. package/client/modeller/figure-animations.ts +90 -17
  5. package/client/modeller/figure-canvas.ts +24 -6
  6. package/client/modeller/figure-inspector.ts +169 -5
  7. package/client/modeller/figure-preview.ts +110 -3
  8. package/client/modeller/figure-report.ts +20 -32
  9. package/client/modeller/figure-side.ts +10 -1
  10. package/client/modeller/figure-source.ts +8 -0
  11. package/client/modeller/joint-edits.ts +66 -5
  12. package/client/modeller/joint-gizmo.ts +180 -0
  13. package/client/modeller/part-edits.ts +75 -9
  14. package/client/modeller/pose-defaults.ts +67 -0
  15. package/client/modeller/proposal.ts +9 -3
  16. package/client/modeller/scene-budget.ts +31 -40
  17. package/client/pages/figure-list-page.ts +4 -9
  18. package/client/pages/figure-modeller-page.ts +144 -43
  19. package/client/types.ts +2 -0
  20. package/dist-client/graphql/index.d.ts +7 -0
  21. package/dist-client/graphql/index.js +21 -0
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/angle-dial.d.ts +32 -0
  24. package/dist-client/modeller/angle-dial.js +41 -0
  25. package/dist-client/modeller/angle-dial.js.map +1 -0
  26. package/dist-client/modeller/base-box.d.ts +47 -0
  27. package/dist-client/modeller/base-box.js +52 -0
  28. package/dist-client/modeller/base-box.js.map +1 -0
  29. package/dist-client/modeller/figure-animations.d.ts +19 -4
  30. package/dist-client/modeller/figure-animations.js +86 -17
  31. package/dist-client/modeller/figure-animations.js.map +1 -1
  32. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  33. package/dist-client/modeller/figure-canvas.js +20 -6
  34. package/dist-client/modeller/figure-canvas.js.map +1 -1
  35. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  36. package/dist-client/modeller/figure-inspector.js +159 -5
  37. package/dist-client/modeller/figure-inspector.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.d.ts +26 -0
  39. package/dist-client/modeller/figure-preview.js +99 -3
  40. package/dist-client/modeller/figure-preview.js.map +1 -1
  41. package/dist-client/modeller/figure-report.d.ts +11 -6
  42. package/dist-client/modeller/figure-report.js +20 -35
  43. package/dist-client/modeller/figure-report.js.map +1 -1
  44. package/dist-client/modeller/figure-side.d.ts +5 -1
  45. package/dist-client/modeller/figure-side.js +13 -0
  46. package/dist-client/modeller/figure-side.js.map +1 -1
  47. package/dist-client/modeller/figure-source.d.ts +7 -0
  48. package/dist-client/modeller/figure-source.js.map +1 -1
  49. package/dist-client/modeller/joint-edits.d.ts +23 -3
  50. package/dist-client/modeller/joint-edits.js +53 -5
  51. package/dist-client/modeller/joint-edits.js.map +1 -1
  52. package/dist-client/modeller/joint-gizmo.d.ts +34 -0
  53. package/dist-client/modeller/joint-gizmo.js +158 -0
  54. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  55. package/dist-client/modeller/part-edits.d.ts +29 -4
  56. package/dist-client/modeller/part-edits.js +61 -9
  57. package/dist-client/modeller/part-edits.js.map +1 -1
  58. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  59. package/dist-client/modeller/pose-defaults.js +35 -0
  60. package/dist-client/modeller/pose-defaults.js.map +1 -0
  61. package/dist-client/modeller/proposal.d.ts +1 -1
  62. package/dist-client/modeller/proposal.js +8 -3
  63. package/dist-client/modeller/proposal.js.map +1 -1
  64. package/dist-client/modeller/scene-budget.d.ts +31 -34
  65. package/dist-client/modeller/scene-budget.js +28 -37
  66. package/dist-client/modeller/scene-budget.js.map +1 -1
  67. package/dist-client/pages/figure-list-page.js +4 -9
  68. package/dist-client/pages/figure-list-page.js.map +1 -1
  69. package/dist-client/pages/figure-modeller-page.d.ts +50 -0
  70. package/dist-client/pages/figure-modeller-page.js +141 -42
  71. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  72. package/dist-client/tsconfig.tsbuildinfo +1 -1
  73. package/dist-client/types.d.ts +2 -0
  74. package/dist-client/types.js.map +1 -1
  75. package/package.json +5 -5
  76. package/test/ai-proposal-contract.test.ts +16 -11
  77. package/test/angle-dial.test.ts +78 -0
  78. package/test/base-box.test.ts +165 -0
  79. package/test/expect.ts +2 -0
  80. package/test/i18n-prefix-guard.test.ts +17 -0
  81. package/test/joint-edits.test.ts +37 -2
  82. package/test/joint-gizmo.test.ts +174 -0
  83. package/test/joint-needle.test.ts +162 -0
  84. package/test/part-edits.test.ts +100 -0
  85. package/test/pose-defaults.test.ts +71 -0
  86. package/test/scene-budget.test.ts +82 -0
  87. package/translations/en.json +16 -9
  88. package/translations/ja.json +16 -9
  89. package/translations/ko.json +16 -9
  90. package/translations/ms.json +16 -9
  91. package/translations/zh.json +16 -9
@@ -20,6 +20,7 @@ import { ANCHOR_RULES, JOINT_TYPES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repe
20
20
  import type {
21
21
  AnchorRule,
22
22
  Axis,
23
+ ContractChange,
23
24
  FigurePart,
24
25
  FigurePlacement,
25
26
  JointType,
@@ -34,6 +35,8 @@ import { partToFigure, partFromFigure } from './figure-source.js'
34
35
  import type { FigureDraft, PartModel } from './figure-source.js'
35
36
  import { canMirror } from './part-edits.js'
36
37
  import { jointOf, parentChoices, patchJoint, setJointType } from './joint-edits.js'
38
+ import { baseFittingParts, baseOf, resizeBase } from './base-box.js'
39
+ import type { BaseSize } from './base-box.js'
37
40
 
38
41
  /** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
39
42
  const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
@@ -316,9 +319,60 @@ export class FigureInspector extends localize(i18next)(LitElement) {
316
319
  grid-template-columns: repeat(3, 1fr);
317
320
  gap: 4px;
318
321
  }
322
+ /*
323
+ 값이 둘인 줄은 셋으로 나누지 않는다. 가동 범위를 세 칸짜리 줄에 넣었더니 칸이 폭의 1/3 이라
324
+ 「최소」라는 말과 -120 이 한 칸에 못 들어가 숫자가 잘렸다.
325
+ */
326
+ div[double] {
327
+ display: grid;
328
+ grid-template-columns: repeat(2, 1fr);
329
+ gap: 4px;
330
+ }
331
+ /* 칸 아래에 붙는 한 줄짜리 행동. 칸과 같은 폭으로 둬야 한 묶음으로 읽힌다. */
332
+ button[fit] {
333
+ display: inline-flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ gap: 4px;
337
+ width: 100%;
338
+ margin-top: 4px;
339
+ padding: 3px 8px;
340
+ font: inherit;
341
+ font-size: 0.72rem;
342
+ color: var(--md-sys-color-on-surface);
343
+ background-color: var(--md-sys-color-surface-container);
344
+ border: 1px solid var(--md-sys-color-outline-variant);
345
+ border-radius: 6px;
346
+ cursor: pointer;
347
+ }
348
+ /* 고친 칸 바로 아래에 붙는 말. 막는 것은 오류 색, 알리는 것은 조용한 색. */
349
+ p[contract] {
350
+ display: flex;
351
+ gap: 4px;
352
+ margin: 4px 0 0;
353
+ font-size: 0.68rem;
354
+ line-height: 1.4;
355
+ color: var(--md-sys-color-on-surface-variant);
356
+ }
357
+ p[contract][blocking] {
358
+ color: var(--md-sys-color-error);
359
+ }
360
+ p[contract] md-icon {
361
+ --md-icon-size: 14px;
362
+ flex: none;
363
+ }
364
+
365
+ button[fit]:hover {
366
+ background-color: var(--md-sys-color-surface-container-high);
367
+ }
368
+ button[fit] md-icon {
369
+ --md-icon-size: 15px;
370
+ }
319
371
  div[field] + div[field],
320
372
  div[triple] + div[field],
321
373
  div[field] + div[triple],
374
+ div[double] + div[field],
375
+ div[field] + div[double],
322
376
  div[inline-field] + div[inline-field],
323
377
  div[inline-field] + div[field],
324
378
  div[field] + div[inline-field],
@@ -802,6 +856,21 @@ export class FigureInspector extends localize(i18next)(LitElement) {
802
856
 
803
857
  /** 도형의 DB 식별자. 비어 있으면 미저장 신규 모델이다. */
804
858
  @property({ type: String }) figureId = ''
859
+
860
+ /**
861
+ * 발행 판 번호. 0 이면 한 번도 발행되지 않았고, 그때만 `type` 을 고칠 수 있다(ADR-0063 결정 4).
862
+ */
863
+ @property({ type: Number }) figureVersion = 0
864
+
865
+ /**
866
+ * 타입이 굳었나 — **한 번이라도 발행됐으면 굳는다**(ADR-0063 결정 4).
867
+ *
868
+ * 저장되지 않은 새 도형과 아직 발행 전인 초안에서는 고칠 수 있다. 전에는 「저장됐나」로
869
+ * 갈랐고, 그래서 저작자가 기계가 지은 이름(`FIGURE_MU6RJH47`)을 한 번도 못 고쳤다.
870
+ */
871
+ private get typeIsFixed(): boolean {
872
+ return !!this.figureId && (this.figureVersion ?? 0) > 0
873
+ }
805
874
  /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
806
875
  @property({ type: String }) figureName = ''
807
876
  /** 분류 하나 — 팔레트에서 묶는 기준. */
@@ -815,6 +884,16 @@ export class FigureInspector extends localize(i18next)(LitElement) {
815
884
  @property({ type: Object }) draft?: FigureDraft
816
885
  /** 초안 위의 부품들. 씬 모델 그대로다. */
817
886
  @property({ type: Array }) parts: PartModel[] = []
887
+
888
+ /**
889
+ * 이 고침이 이미 놓인 인스턴스에 무엇을 하나 — 발행된 도형에만 있다(ADR-0063 결정 3).
890
+ *
891
+ * 발행 문에서만 말하면 저작자는 열 군데를 고친 뒤에 듣는다. 고치는 칸 옆에서 말한다.
892
+ */
893
+ @property({ type: Array }) contract: ContractChange[] = []
894
+
895
+ /** 견주지 못한 까닭. 빈 목록과 뜻이 다르다 — 「깨지 않는다」가 아니라 「모른다」다. */
896
+ @property({ type: String }) contractFailure?: string
818
897
  @property({ type: Number }) selected = -1
819
898
 
820
899
  /** `part` 는 HTMLElement 의 속성(DOMTokenList)이다 — 가리면 형이 어긋난다. */
@@ -976,7 +1055,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
976
1055
  ? html`
977
1056
  <div vector-row>
978
1057
  <label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
979
- <div triple>
1058
+ <div double>
980
1059
  ${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
981
1060
  this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
982
1061
  )}
@@ -1009,9 +1088,73 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1009
1088
  })
1010
1089
  }
1011
1090
 
1091
+ /**
1092
+ * 기준 상자를 고친다 — 부품은 저장되는 자리에 그대로 둔 채(`base-box.ts`).
1093
+ *
1094
+ * 초안과 부품이 함께 바뀌므로 둘을 한 번에 올린다. 따로 올리면 되돌리기가 둘로 갈리고, 그 사이
1095
+ * 한 번만 되돌린 화면은 부품이 반쯤 밀린 모습이 된다.
1096
+ */
1097
+ /**
1098
+ * 기준 상자를 고친 것이 **이미 놓인 인스턴스에 무엇을 하나** — 고친 칸 바로 아래에서 말한다.
1099
+ *
1100
+ * 막는 것과 알리는 것을 가른다. 비율이 달라지면 놓인 것이 찌그러지므로 발행이 막히고, 크기만
1101
+ * 달라지는 것은 실척이 어긋날 뿐이라 알리기만 한다. 까닭은 형식이 적은 것을 그대로 옮긴다 —
1102
+ * 여기서 다시 쓰면 두 벌이 되고 곧 갈린다.
1103
+ */
1104
+ private renderBaseContract() {
1105
+ if (this.contractFailure) {
1106
+ return html`<p contract>
1107
+ <md-icon>help</md-icon>
1108
+ <span>${i18next.t('figure.text.contract-not-compared', { why: this.contractFailure })}</span>
1109
+ </p>`
1110
+ }
1111
+
1112
+ const mine = this.contract.filter(one => one.code === 'base-ratio-changed' || one.code === 'base-size-changed')
1113
+ if (mine.length === 0) return nothing
1114
+
1115
+ return html`${mine.map(
1116
+ one => html`<p contract ?blocking=${one.blocking}>
1117
+ <md-icon>${one.blocking ? 'block' : 'info'}</md-icon>
1118
+ <span><b>${one.message}</b> ${one.why}</span>
1119
+ </p>`
1120
+ )}`
1121
+ }
1122
+
1123
+ private resizeBase(patch: Partial<BaseSize>) {
1124
+ if (!this.draft) return
1125
+
1126
+ const next = resizeBase(this.draft, this.parts, { ...baseOf(this.draft), ...patch })
1127
+ if (next.draft === this.draft) return
1128
+
1129
+ this.dispatchEvent(
1130
+ new CustomEvent('resize-base', {
1131
+ detail: { draft: next.draft, parts: next.parts },
1132
+ bubbles: true,
1133
+ composed: true
1134
+ })
1135
+ )
1136
+ }
1137
+
1138
+ /** 부품 전부를 감싸는 가장 작은 상자로. 맞출 것이 없으면 아무 일도 안 한다. */
1139
+ private fitBaseToParts() {
1140
+ if (!this.draft) return
1141
+ const fitted = baseFittingParts(this.draft, this.parts)
1142
+ if (fitted) this.resizeBase(fitted)
1143
+ }
1144
+
1012
1145
  private setJointType(part: FigurePart, value: string) {
1013
1146
  if (!this.draft) return
1014
- this.tellDraft(setJointType(this.draft, part, value === '' ? undefined : (value as JointType)))
1147
+
1148
+ /* 붙은 부품을 함께 준다 — 새 관절이 둘이 맞닿는 자리에 서려면 그쪽 치수를 알아야 한다. */
1149
+ const parent = part.parent ? this.parts.find(one => one.name === part.parent) : undefined
1150
+ this.tellDraft(
1151
+ setJointType(
1152
+ this.draft,
1153
+ part,
1154
+ value === '' ? undefined : (value as JointType),
1155
+ parent ? partToFigure(parent, this.draft) : undefined
1156
+ )
1157
+ )
1015
1158
  }
1016
1159
 
1017
1160
  private setJointAxis(name: string, key: string) {
@@ -1104,11 +1247,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1104
1247
  />
1105
1248
  </div>
1106
1249
  <div inline-field>
1107
- <label>${i18next.t('figure.label.figure-type')}${this.figureId ? this.helpButton('figure.text.type-name-cannot-change') : nothing}</label>
1108
- ${!this.figureId
1250
+ <label>${i18next.t('figure.label.figure-type')}${this.typeIsFixed ? this.helpButton('figure.text.type-name-cannot-change') : nothing}</label>
1251
+ ${!this.typeIsFixed
1109
1252
  ? html`<input
1110
1253
  .value=${this.draft?.figureType ?? ''}
1111
- placeholder=${i18next.t('figure.text.figure-type-placeholder', { defaultValue: '도형 식별 타입 (예: OHT)' })}
1254
+ placeholder=${i18next.t('figure.text.figure-type-placeholder')}
1112
1255
  @change=${(e: Event) =>
1113
1256
  this.dispatchEvent(
1114
1257
  new CustomEvent('figure-type-change', {
@@ -1147,6 +1290,27 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1147
1290
  ${this.helpHint('figure.text.placement-explained')}
1148
1291
  </div>
1149
1292
 
1293
+ <!--
1294
+ 기준 상자 — **고치는 길이 화면에 있어야 한다.** 부품이 이 상자를 넘으면 발행이 막히고,
1295
+ 고치는 방법은 상자 숫자를 키우는 것이다(ADR-0044 ④). 그 칸이 없어서 저작자는 넘쳤다는
1296
+ 말만 듣고 고칠 자리를 못 찾았다.
1297
+ -->
1298
+ <div field>
1299
+ <label>${i18next.t('figure.label.base-box')}${this.helpButton('figure.text.base-box-explained')}</label>
1300
+ <div triple>
1301
+ ${this.number('X', this.draft?.width ?? 0, value => this.resizeBase({ width: value }), 1)}
1302
+ ${this.number('Y', this.draft?.depth ?? 0, value => this.resizeBase({ depth: value }), 1)}
1303
+ ${this.number('Z', this.draft?.height ?? 0, value => this.resizeBase({ height: value }), 1)}
1304
+ </div>
1305
+ ${this.parts.length > 0
1306
+ ? html`<button fit @click=${() => this.fitBaseToParts()} title=${i18next.t('figure.text.fit-base-explained')}>
1307
+ <md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
1308
+ </button>`
1309
+ : nothing}
1310
+ ${this.renderBaseContract()}
1311
+ ${this.helpHint('figure.text.base-box-explained')}
1312
+ </div>
1313
+
1150
1314
  <div inline-field>
1151
1315
  <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
1152
1316
  <input
@@ -8,6 +8,10 @@ import { create, registerFigure } from '@hatiolab/things-scene'
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH } from './figure-view.js'
10
10
  import type { ViewSettings } from './figure-view.js'
11
+ import { dialShape } from './angle-dial.js'
12
+ import { jointNeedles } from './joint-gizmo.js'
13
+ import { poseChanges } from './pose-defaults.js'
14
+ import type { FigureDraft } from './figure-source.js'
11
15
 
12
16
  /**
13
17
  * 저작 중인 것을 **도면에 놓았을 때**의 모습.
@@ -140,10 +144,45 @@ export class FigurePreview extends localize(i18next)(LitElement) {
140
144
  }
141
145
  div[clips] label {
142
146
  display: grid;
143
- grid-template-columns: auto 96px 34px;
147
+ grid-template-columns: auto 20px 96px 34px;
144
148
  align-items: center;
145
149
  gap: 8px;
146
150
  }
151
+ /* 각도 눈. 3D 가 아무리 작게 서 있어도 여기서는 같은 크기로 읽힌다. */
152
+ div[clips] svg[dial] {
153
+ width: 20px;
154
+ height: 20px;
155
+ }
156
+ div[clips] svg[dial] path {
157
+ fill: rgba(255, 255, 255, 0.22);
158
+ }
159
+ div[clips] svg[dial] line {
160
+ stroke: var(--md-sys-color-primary, #7cc6ff);
161
+ stroke-width: 2;
162
+ stroke-linecap: round;
163
+ }
164
+ div[clips] svg[dial] circle {
165
+ fill: none;
166
+ stroke: rgba(255, 255, 255, 0.35);
167
+ }
168
+ /*
169
+ 막대 위에 놓는 단추. 자세를 만든 자리에 있어야 「지금 이것을 기본으로」가 읽힌다.
170
+ 앉힐 것이 있을 때만 나오므로, 늘 서 있는 칸이 아니다.
171
+ */
172
+ div[clips] button[pose] {
173
+ align-self: stretch;
174
+ padding: 4px 8px;
175
+ font: inherit;
176
+ font-size: 0.7rem;
177
+ color: rgba(255, 255, 255, 0.92);
178
+ background-color: rgba(255, 255, 255, 0.14);
179
+ border: 1px solid rgba(255, 255, 255, 0.22);
180
+ border-radius: 5px;
181
+ cursor: pointer;
182
+ }
183
+ div[clips] button[pose]:hover {
184
+ background-color: rgba(255, 255, 255, 0.24);
185
+ }
147
186
  div[clips] span[drive] {
148
187
  opacity: 0.6;
149
188
  font-size: 0.66rem;
@@ -176,6 +215,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
176
215
  /** 저작 중인 것. 저장 형식으로 받는다 — 도면이 소비하는 것이 이 형식이다. */
177
216
  @property({ type: Object }) source?: FigureSource
178
217
 
218
+ /**
219
+ * 저작 중인 초안. **기본값이 지금 무엇인지 알려고만 쥔다** — 고치지 않는다.
220
+ *
221
+ * `source` 로도 파라미터를 읽을 수 있지만, 후보를 미리 볼 때는 `source` 가 후보의 것이고
222
+ * 초안은 열려 있는 도형의 것이다. 「이 자세를 기본으로」는 열려 있는 도형에만 뜻이 있다.
223
+ */
224
+ @property({ type: Object }) draft?: FigureDraft
225
+
179
226
  /**
180
227
  * 보기 설정. 저장되지 않는다(`figure-view.ts`).
181
228
  *
@@ -226,8 +273,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
226
273
  clips: NonNullable<FigureSource['animations']>,
227
274
  parameters: NonNullable<FigureSource['parameters']>
228
275
  ) {
276
+ /*
277
+ 지금 세운 자세를 기본값으로 앉히는 자리. **막대 옆에 둔다** — 자세를 만든 곳이 여기라,
278
+ 값을 읽어 파라미터 칸으로 옮겨 적는 왕복이 없어진다. 앉힐 것이 없으면 아예 안 나온다.
279
+ */
280
+ const changes = poseChanges(this.draft, this.giving)
281
+
229
282
  return html`
230
283
  <div clips>
284
+ ${changes.length > 0
285
+ ? html`<button
286
+ pose
287
+ title=${i18next.t('figure.text.pose-as-default-explained')}
288
+ @click=${() => this.poseAsDefault()}
289
+ >
290
+ ${i18next.t('figure.button.pose-as-default')}
291
+ </button>`
292
+ : ''}
231
293
  ${parameters.map(parameter => {
232
294
  const range = parameter.range
233
295
  const value = this.giving[parameter.name] ?? parameter.default ?? range?.min ?? 0
@@ -236,6 +298,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
236
298
  return html`
237
299
  <label>
238
300
  <span>${parameter.label ?? parameter.name} <span drive>${range?.unit ?? ''}</span></span>
301
+ ${this.renderDial(range, value)}
239
302
  <input
240
303
  type="range"
241
304
  min=${range?.min ?? 0}
@@ -254,6 +317,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
254
317
  return html`
255
318
  <label>
256
319
  <span>${clip.name} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
320
+ <span></span>
257
321
  <input
258
322
  type="range"
259
323
  min="0"
@@ -270,6 +334,31 @@ export class FigurePreview extends localize(i18next)(LitElement) {
270
334
  `
271
335
  }
272
336
 
337
+ /**
338
+ * 각도 파라미터에만 다는 눈.
339
+ *
340
+ * 미리보기의 3D 는 여덟 대를 한 화면에 세우느라 한 대가 20 픽셀쯤이다. 관절이 돌았다는
341
+ * 표시를 그 안에 그리면 몇 픽셀에 그친다 — 부품에 단 바늘이 4 × 2 픽셀로 나왔다(잰 값).
342
+ * 각을 읽는 일은 배율에서 떼어 여기에 둔다.
343
+ *
344
+ * 각이 아닌 파라미터(밀리미터 · 퍼센트 · 초)에는 안 단다. 눈금이 도는 것이 아니라서
345
+ * 원으로 그리면 없는 뜻이 생긴다.
346
+ */
347
+ private renderDial(range: { unit?: string; min: number; max: number } | undefined, value: number) {
348
+ if (!range || range.unit !== 'deg') return html`<span></span>`
349
+
350
+ const dial = dialShape(range.min, range.max, value)
351
+ const span = dial.radius * 2 + 2
352
+
353
+ return html`
354
+ <svg dial viewBox="0 0 ${span} ${span}" aria-hidden="true">
355
+ <path d=${dial.sector}></path>
356
+ <circle cx=${dial.centre.x} cy=${dial.centre.y} r=${dial.radius}></circle>
357
+ <line x1=${dial.centre.x} y1=${dial.centre.y} x2=${dial.needle.x} y2=${dial.needle.y}></line>
358
+ </svg>
359
+ `
360
+ }
361
+
273
362
  /**
274
363
  * 세워 둔 인스턴스 전부에 같은 값을 준다.
275
364
  *
@@ -292,6 +381,18 @@ export class FigurePreview extends localize(i18next)(LitElement) {
292
381
  this.tellInstances('parameters', this.giving)
293
382
  }
294
383
 
384
+ /**
385
+ * 세운 자세를 기본값으로 — **셈은 여기서 하지 않는다.**
386
+ *
387
+ * 초안을 쥐고 있는 것은 페이지이고, 되돌리기와 「저장 안 됨」도 거기서 매긴다. 여기서
388
+ * 초안을 만들어 보내면 그 셋이 두 곳으로 갈린다. 민 값만 올린다.
389
+ */
390
+ private poseAsDefault(): void {
391
+ this.dispatchEvent(
392
+ new CustomEvent('pose-as-default', { detail: { values: { ...this.giving } }, bubbles: true, composed: true })
393
+ )
394
+ }
395
+
295
396
  private tellInstances(key: 'clips' | 'parameters', values: Record<string, number>): void {
296
397
  for (const component of this.scene?.root?.components ?? []) {
297
398
  component.set?.(key, { ...values })
@@ -355,8 +456,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
355
456
  }
356
457
 
357
458
  const view = this.view ?? DEFAULT_VIEW
459
+ /*
460
+ 관절마다 바늘 하나를 붙여 세운다(`joint-gizmo.ts`). 대칭인 부품은 돌아도 그림이 같아서,
461
+ 바늘이 없으면 저작자가 막대를 끝까지 밀고도 관절이 도는 것을 못 본다. 여기서만 붙인다 —
462
+ 저장되는 것도 저작면에 서는 것도 원본 그대로다.
463
+ */
464
+ const shown: FigureSource = { ...source, parts: [...source.parts, ...jointNeedles(source)] }
358
465
  /* 씬이 이 둘로 인스턴스 높이를 정한다 — 서명에 안 넣으면 다시 세워도 그대로 선다. */
359
- const signature = JSON.stringify([source, view.placement, view.ceilingHeight])
466
+ const signature = JSON.stringify([shown, view.placement, view.ceilingHeight])
360
467
  if (this.scene && this.standing === signature) return
361
468
 
362
469
  const { errors } = validate(source)
@@ -371,7 +478,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
371
478
  this.driving = {}
372
479
 
373
480
  try {
374
- registerFigure(compile(source) as never)
481
+ registerFigure(compile(shown) as never)
375
482
 
376
483
  /*
377
484
  도면이 하는 일을 그대로 한다. 컴포넌트에는 타입 이름과 자리·크기만 적는다 —
@@ -9,7 +9,7 @@ import { ScrollbarStyles } from '@operato/styles'
9
9
  import { blocksRelease, compile, costCurveSvg, costOf, scoreOf, sizingReport, validate } from '@hatiolab/figure-model'
10
10
  import type { FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'
11
11
 
12
- import { capacityOf } from './scene-budget.js'
12
+ import { capacityOf, MEASURED } from './scene-budget.js'
13
13
  import { maturityOf } from './maturity.js'
14
14
  import type { MaturityAxis } from './maturity.js'
15
15
  import type { FigureCapacity } from './scene-budget.js'
@@ -433,7 +433,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
433
433
  @state() private score?: FigureScore
434
434
  @state() private curve = ''
435
435
  /** 1만 개를 놓았을 때. 저작자가 실제로 궁금한 숫자다. */
436
- @state() private many?: FigureCost
437
436
  /** 몇 개까지 놓을 수 있나. 이 패널의 머리다. */
438
437
  @state() private capacity?: FigureCapacity
439
438
  /** 추이 그림을 제 크기로 펼쳤나. */
@@ -507,8 +506,9 @@ export class FigureReport extends localize(i18next)(LitElement) {
507
506
  * 직접 답하지 않는다 — 형상이 선언된 한도 안인지를 말할 뿐이다. 그래서 개수를
508
507
  * 머리에 올리고 등급은 옆의 badge 로 내린다.
509
508
  *
510
- * 근거를 함께 낸다. 개수의 분모는 실측이 아니라 설계 문서의 목표치이고(미결
511
- * D-20), 사실이 화면에 없으면 값처럼 읽힌다.
509
+ * 근거를 함께 낸다. 묶음으로 갈리는 개수의 분모는 **실측**이고(2026-09-18, Apple M5),
510
+ * 기계와 날짜를 함께 적는다 적지 않으면 어디서나 참인 수로 읽힌다. 삼각형 쪽은 아직 설계
511
+ * 문서의 목표치라 그렇다고 적는다.
512
512
  */
513
513
  private renderVerdict() {
514
514
  const score = this.score
@@ -544,7 +544,11 @@ export class FigureReport extends localize(i18next)(LitElement) {
544
544
  per: capacity.per.toLocaleString(),
545
545
  budget: capacity.budget.toLocaleString()
546
546
  })}
547
- <em>${i18next.t('figure.text.budget-not-measured-yet')}</em>
547
+ <em>
548
+ ${capacity.bound === 'groups'
549
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
550
+ : i18next.t('figure.text.budget-not-measured-yet')}
551
+ </em>
548
552
  </p>
549
553
  </section>
550
554
  `
@@ -763,12 +767,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
763
767
  <dt>${i18next.t('figure.label.triangles')}</dt>
764
768
  <dd>${cost.triangles.toLocaleString()}</dd>
765
769
 
766
- <dt>${i18next.t('figure.label.triangles-at-100')}</dt>
767
- <dd>${cost.at.triangles.toLocaleString()}</dd>
768
-
769
- <dt>${i18next.t('figure.label.draw-calls-at-100')}</dt>
770
- <dd>${cost.at.drawCalls.toLocaleString()}</dd>
771
-
772
770
  <dt>${i18next.t('figure.label.distinct-shapes')}</dt>
773
771
  <dd>${cost.distinctShapes}</dd>
774
772
 
@@ -780,33 +778,26 @@ export class FigureReport extends localize(i18next)(LitElement) {
780
778
  }
781
779
 
782
780
  /**
783
- * 재활용 추이 — 「많이 놓으면 어떻게 되나」에 답한다.
781
+ * 재활용 추이 그림.
782
+ *
783
+ * **「몇 개 놓으면 얼마」를 숫자로 적지 않는다.** 놓는 개수는 값을 치르지 않는다 —
784
+ * 한 화면에 보이는 개수 × 그 도형의 묶음 수가 치른다(실측 2026-09-18, ADR-0051 ③).
785
+ * 전에는 여기에 「100개 기준 draw call」 · 「1만개 기준」을 적었고, 그 수는 실제로 드는
786
+ * 값과 어긋났다. 답은 위의 성숙도 칸이 한다 — 잰 기계와 날짜를 달고 「한 화면에 몇 대」를
787
+ * 말한다.
784
788
  *
785
789
  * ## 그림을 사이드바에 밀어 넣지 않는다
786
790
  *
787
791
  * `costCurveSvg` 의 자연 크기는 680x380 이고 글자가 11px 다. 300px 칸에서
788
792
  * width:100% 로 줄이면 글자가 4px 이 되어 **아무것도 읽을 수 없다** — 화면을 띄워
789
- * 보고서야 드러났다. 읽을 수 없는 그림은 없는 것과 같으므로, 사이드바에는 저작자가
790
- * 실제로 궁금한 숫자(1만 개일 때)를 두고 그림은 선택해서 제 크기로 본다.
793
+ * 보고서야 드러났다. 그림은 선택해서 크기로 본다.
791
794
  */
792
795
  private renderCurve() {
793
- const many = this.many
794
- if (!many && !this.curve) return nothing
796
+ if (!this.curve) return nothing
795
797
 
796
798
  return html`
797
799
  <section>
798
800
  <h3>${i18next.t('figure.label.reuse-trend')}</h3>
799
- ${many
800
- ? html`
801
- <dl metrics>
802
- <dt>${i18next.t('figure.label.triangles-at-10k')}</dt>
803
- <dd>${many.at.triangles.toLocaleString()}</dd>
804
-
805
- <dt>${i18next.t('figure.label.draw-calls-at-10k')}</dt>
806
- <dd>${many.at.drawCalls.toLocaleString()}</dd>
807
- </dl>
808
- `
809
- : nothing}
810
801
  ${this.curve
811
802
  ? html`
812
803
  <button expand @click=${() => (this.expanded = true)}>
@@ -894,7 +885,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
894
885
  if (!source) {
895
886
  this.cost = undefined
896
887
  this.score = undefined
897
- this.many = undefined
898
888
  this.capacity = undefined
899
889
  this.curve = ''
900
890
  this.errors = []
@@ -911,7 +901,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
911
901
  if (this.bare) {
912
902
  this.cost = undefined
913
903
  this.score = undefined
914
- this.many = undefined
915
904
  this.capacity = undefined
916
905
  this.curve = ''
917
906
  this.errors = []
@@ -933,7 +922,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
933
922
  // 형식을 어겼으면 컴파일이 던진다. 셈을 비우고 오류만 보여 준다.
934
923
  this.cost = undefined
935
924
  this.score = undefined
936
- this.many = undefined
937
925
  this.capacity = undefined
938
926
  this.curve = ''
939
927
  this.sizing = []
@@ -958,9 +946,9 @@ export class FigureReport extends localize(i18next)(LitElement) {
958
946
  blocking: finding.code === 'sizing-splits'
959
947
  }))
960
948
 
961
- this.cost = costOf(blueprint, 100)
949
+ /* 개수 인자는 `cost.at` 정한다 — 그 칸을 화면에서 뺐으니 기본값 그대로 둔다. */
950
+ this.cost = costOf(blueprint)
962
951
  // 1만 개는 figure-model 에 다시 묻는다 — 화면이 곱셈으로 짐작하지 않는다.
963
- this.many = costOf(blueprint, 10000)
964
952
  this.score = scoreOf(blueprint)
965
953
  this.capacity = capacityOf(this.cost)
966
954
 
@@ -10,7 +10,7 @@ import { css, html, LitElement } from 'lit'
10
10
  import { customElement, property, state } from 'lit/decorators.js'
11
11
 
12
12
  import { i18next, localize } from '@operato/i18n'
13
- import type { FigureSource } from '@hatiolab/figure-model'
13
+ import type { ContractChange, FigureSource } from '@hatiolab/figure-model'
14
14
 
15
15
  import type { FigureDraft, PartModel } from './figure-source.js'
16
16
  import { DEFAULT_VIEW } from './figure-view.js'
@@ -159,6 +159,12 @@ export class FigureSide extends localize(i18next)(LitElement) {
159
159
  @property({ type: Object }) draft?: FigureDraft
160
160
  @property({ type: Array }) parts: PartModel[] = []
161
161
  @property({ type: Object }) source?: FigureSource
162
+
163
+ /** 이 고침이 이미 놓인 인스턴스에 무엇을 하나(ADR-0063 결정 3). 발행 전 도형에는 비어 있다. */
164
+ @property({ type: Array }) contract: ContractChange[] = []
165
+
166
+ /** 견주지 못한 까닭. 있으면 화면이 「확인하지 못했다」로 말한다. */
167
+ @property({ type: String }) contractFailure?: string
162
168
  @property({ type: Number }) selected = -1
163
169
  /** 저작 화면의 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다. */
164
170
  @property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
@@ -193,11 +199,14 @@ export class FigureSide extends localize(i18next)(LitElement) {
193
199
  <figure-inspector
194
200
  ?off=${this.tab !== 'properties'}
195
201
  .figureId=${this.figureId}
202
+ .figureVersion=${this.figureVersion}
196
203
  .figureName=${this.figureName}
197
204
  .figureCategory=${this.figureCategory}
198
205
  .figureTags=${this.figureTags}
199
206
  .draft=${this.draft}
200
207
  .parts=${this.parts}
208
+ .contract=${this.contract}
209
+ .contractFailure=${this.contractFailure}
201
210
  .selected=${this.selected}
202
211
  ></figure-inspector>
203
212
 
@@ -174,6 +174,14 @@ export interface PartModel {
174
174
  */
175
175
  hidden?: boolean
176
176
 
177
+ /**
178
+ * 씬이 이것을 **집지 않는다** — 클릭·마퀴·기즈모 어디에도 안 걸린다.
179
+ *
180
+ * 저작면에만 서는 것(관절 부채꼴)이 쓴다. 저장 형식에는 없는 칸이라 `toFigureSource` 가
181
+ * 싣지 않는다.
182
+ */
183
+ locked?: boolean
184
+
177
185
  /** 색 토큰 이름 (`palette.primary` 등). */
178
186
  token?: string
179
187
  /*