@things-factory/figure-ui 10.1.26 → 10.1.28

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 (83) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/base-box.ts +104 -0
  3. package/client/modeller/figure-animations.ts +253 -32
  4. package/client/modeller/figure-canvas.ts +18 -3
  5. package/client/modeller/figure-inspector.ts +292 -3
  6. package/client/modeller/figure-preview.ts +57 -2
  7. package/client/modeller/figure-report.ts +9 -4
  8. package/client/modeller/figure-side.ts +9 -1
  9. package/client/modeller/figure-source.ts +24 -4
  10. package/client/modeller/figure-view.ts +20 -0
  11. package/client/modeller/joint-edits.ts +262 -0
  12. package/client/modeller/joint-gizmo.ts +118 -0
  13. package/client/modeller/pose-defaults.ts +67 -0
  14. package/client/modeller/proposal.ts +9 -3
  15. package/client/modeller/scene-budget.ts +31 -40
  16. package/client/pages/figure-list-page.ts +4 -9
  17. package/client/pages/figure-modeller-page.ts +124 -41
  18. package/dist-client/graphql/index.d.ts +7 -0
  19. package/dist-client/graphql/index.js +21 -0
  20. package/dist-client/graphql/index.js.map +1 -1
  21. package/dist-client/modeller/base-box.d.ts +47 -0
  22. package/dist-client/modeller/base-box.js +52 -0
  23. package/dist-client/modeller/base-box.js.map +1 -0
  24. package/dist-client/modeller/figure-animations.d.ts +29 -0
  25. package/dist-client/modeller/figure-animations.js +228 -25
  26. package/dist-client/modeller/figure-animations.js.map +1 -1
  27. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  28. package/dist-client/modeller/figure-canvas.js +14 -3
  29. package/dist-client/modeller/figure-canvas.js.map +1 -1
  30. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  31. package/dist-client/modeller/figure-inspector.js +253 -2
  32. package/dist-client/modeller/figure-inspector.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +15 -0
  34. package/dist-client/modeller/figure-preview.js +48 -2
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-report.d.ts +3 -2
  37. package/dist-client/modeller/figure-report.js +9 -4
  38. package/dist-client/modeller/figure-report.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +5 -1
  40. package/dist-client/modeller/figure-side.js +12 -0
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +15 -0
  43. package/dist-client/modeller/figure-source.js +8 -4
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-view.d.ts +16 -0
  46. package/dist-client/modeller/figure-view.js +15 -0
  47. package/dist-client/modeller/figure-view.js.map +1 -1
  48. package/dist-client/modeller/joint-edits.d.ts +49 -0
  49. package/dist-client/modeller/joint-edits.js +223 -0
  50. package/dist-client/modeller/joint-edits.js.map +1 -0
  51. package/dist-client/modeller/joint-gizmo.d.ts +31 -0
  52. package/dist-client/modeller/joint-gizmo.js +104 -0
  53. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  54. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  55. package/dist-client/modeller/pose-defaults.js +35 -0
  56. package/dist-client/modeller/pose-defaults.js.map +1 -0
  57. package/dist-client/modeller/proposal.d.ts +1 -1
  58. package/dist-client/modeller/proposal.js +8 -3
  59. package/dist-client/modeller/proposal.js.map +1 -1
  60. package/dist-client/modeller/scene-budget.d.ts +31 -34
  61. package/dist-client/modeller/scene-budget.js +28 -37
  62. package/dist-client/modeller/scene-budget.js.map +1 -1
  63. package/dist-client/pages/figure-list-page.js +4 -9
  64. package/dist-client/pages/figure-list-page.js.map +1 -1
  65. package/dist-client/pages/figure-modeller-page.d.ts +43 -0
  66. package/dist-client/pages/figure-modeller-page.js +121 -40
  67. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  68. package/dist-client/tsconfig.tsbuildinfo +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/test/ai-proposal-contract.test.ts +16 -11
  72. package/test/base-box.test.ts +154 -0
  73. package/test/figure-source.test.ts +32 -0
  74. package/test/joint-edits.test.ts +165 -0
  75. package/test/joint-gizmo.test.ts +174 -0
  76. package/test/pose-defaults.test.ts +71 -0
  77. package/test/preview-board-depth.test.ts +27 -0
  78. package/test/scene-budget.test.ts +53 -0
  79. package/translations/en.json +37 -2
  80. package/translations/ja.json +37 -2
  81. package/translations/ko.json +37 -2
  82. package/translations/ms.json +37 -2
  83. package/translations/zh.json +37 -2
@@ -16,13 +16,41 @@ import {
16
16
  FIGURE_PLACEMENTS,
17
17
  trianglesOf
18
18
  } from '@hatiolab/figure-model'
19
- import { ANCHOR_RULES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
20
- import type { AnchorRule, Axis, FigurePart, FigurePlacement, MaterialPreset, PartRole, PrimitiveKind } from '@hatiolab/figure-model'
19
+ import { ANCHOR_RULES, JOINT_TYPES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
20
+ import type {
21
+ AnchorRule,
22
+ Axis,
23
+ ContractChange,
24
+ FigurePart,
25
+ FigurePlacement,
26
+ JointType,
27
+ MaterialPreset,
28
+ PartRole,
29
+ PrimitiveKind,
30
+ Vec3
31
+ } from '@hatiolab/figure-model'
21
32
  import { activePalette } from '@hatiolab/things-scene'
22
33
 
23
34
  import { partToFigure, partFromFigure } from './figure-source.js'
24
35
  import type { FigureDraft, PartModel } from './figure-source.js'
25
36
  import { canMirror } from './part-edits.js'
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'
40
+
41
+ /** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
42
+ const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
43
+ { key: '+x', axis: { x: 1, y: 0, z: 0 } },
44
+ { key: '-x', axis: { x: -1, y: 0, z: 0 } },
45
+ { key: '+y', axis: { x: 0, y: 1, z: 0 } },
46
+ { key: '-y', axis: { x: 0, y: -1, z: 0 } },
47
+ { key: '+z', axis: { x: 0, y: 0, z: 1 } },
48
+ { key: '-z', axis: { x: 0, y: 0, z: -1 } }
49
+ ]
50
+
51
+ function axisKeyOf(axis: Vec3): string | undefined {
52
+ return JOINT_AXES.find(one => one.axis.x === axis.x && one.axis.y === axis.y && one.axis.z === axis.z)?.key
53
+ }
26
54
 
27
55
  /**
28
56
  * 고른 부품의 속성.
@@ -291,9 +319,60 @@ export class FigureInspector extends localize(i18next)(LitElement) {
291
319
  grid-template-columns: repeat(3, 1fr);
292
320
  gap: 4px;
293
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
+ }
294
371
  div[field] + div[field],
295
372
  div[triple] + div[field],
296
373
  div[field] + div[triple],
374
+ div[double] + div[field],
375
+ div[field] + div[double],
297
376
  div[inline-field] + div[inline-field],
298
377
  div[inline-field] + div[field],
299
378
  div[field] + div[inline-field],
@@ -790,6 +869,16 @@ export class FigureInspector extends localize(i18next)(LitElement) {
790
869
  @property({ type: Object }) draft?: FigureDraft
791
870
  /** 초안 위의 부품들. 씬 모델 그대로다. */
792
871
  @property({ type: Array }) parts: PartModel[] = []
872
+
873
+ /**
874
+ * 이 고침이 이미 놓인 인스턴스에 무엇을 하나 — 발행된 도형에만 있다(ADR-0063 결정 3).
875
+ *
876
+ * 발행 문에서만 말하면 저작자는 열 군데를 고친 뒤에 듣는다. 고치는 칸 옆에서 말한다.
877
+ */
878
+ @property({ type: Array }) contract: ContractChange[] = []
879
+
880
+ /** 견주지 못한 까닭. 빈 목록과 뜻이 다르다 — 「깨지 않는다」가 아니라 「모른다」다. */
881
+ @property({ type: String }) contractFailure?: string
793
882
  @property({ type: Number }) selected = -1
794
883
 
795
884
  /** `part` 는 HTMLElement 의 속성(DOMTokenList)이다 — 가리면 형이 어긋난다. */
@@ -820,7 +909,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
820
909
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
821
910
  */
822
911
  return html`
823
- ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
912
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)} ${this.renderJoint(shown)}
824
913
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
825
914
  ${this.renderBehaviour(shown)}
826
915
  `
@@ -884,6 +973,185 @@ export class FigureInspector extends localize(i18next)(LitElement) {
884
973
  `
885
974
  }
886
975
 
976
+ /**
977
+ * How this part hangs from another and moves relative to it (ADR-0066).
978
+ *
979
+ * The parent is written on the part. The joint says how the part moves relative to that parent, and the
980
+ * parameter it comes with is what drives it; the preview shows that parameter as a slider.
981
+ */
982
+ private renderJoint(part: FigurePart) {
983
+ const joint = jointOf(this.draft, part.name)
984
+ const choices = parentChoices(this.parts, part.name)
985
+ const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
986
+ const axisKey = joint ? axisKeyOf(joint.axis) : undefined
987
+ const driver = joint
988
+ ? this.draft?.parameters?.find(one => one.clip?.channels?.some(channel => channel.target === joint.name))
989
+ : undefined
990
+
991
+ return html`
992
+ <section>
993
+ <h3>${i18next.t('figure.label.joint')}${this.help('figure.text.joint-explained')}</h3>
994
+
995
+ <div inline-field>
996
+ <label>${i18next.t('figure.label.joint-parent')}</label>
997
+ <select @change=${(e: Event) => this.setParent((e.target as HTMLSelectElement).value)}>
998
+ <option value="" ?selected=${part.parent === undefined}>${i18next.t('figure.label.joint-parent-none')}</option>
999
+ ${part.parent !== undefined && !choices.includes(part.parent)
1000
+ ? html`<option value=${part.parent} selected>${part.parent}</option>`
1001
+ : nothing}
1002
+ ${choices.map(name => html`<option value=${name} ?selected=${name === part.parent}>${name}</option>`)}
1003
+ </select>
1004
+ </div>
1005
+
1006
+ <div inline-field>
1007
+ <label>${i18next.t('figure.label.joint-type')}</label>
1008
+ <select @change=${(e: Event) => this.setJointType(part, (e.target as HTMLSelectElement).value)}>
1009
+ <option value="" ?selected=${!joint}>${i18next.t('figure.label.joint-type-none')}</option>
1010
+ ${(JOINT_TYPES as readonly JointType[]).map(
1011
+ type => html`<option value=${type} ?selected=${joint?.type === type}>${i18next.t('figure.label.joint-type-' + type)}</option>`
1012
+ )}
1013
+ </select>
1014
+ </div>
1015
+
1016
+ ${joint
1017
+ ? html`
1018
+ <div inline-field>
1019
+ <label>${i18next.t('figure.label.joint-axis')}</label>
1020
+ <select @change=${(e: Event) => this.setJointAxis(joint.name, (e.target as HTMLSelectElement).value)}>
1021
+ ${axisKey === undefined
1022
+ ? html`<option value="" selected>${joint.axis.x}, ${joint.axis.y}, ${joint.axis.z}</option>`
1023
+ : nothing}
1024
+ ${JOINT_AXES.map(one => html`<option value=${one.key} ?selected=${one.key === axisKey}>${one.key.toUpperCase()}</option>`)}
1025
+ </select>
1026
+ </div>
1027
+
1028
+ <div vector-row>
1029
+ <label>${i18next.t('figure.label.joint-origin')}</label>
1030
+ <div triple>
1031
+ ${(['x', 'y', 'z'] as const).map(axis =>
1032
+ this.number(axis.toUpperCase(), joint.origin[axis], value =>
1033
+ this.tellDraft(patchJoint(this.draft!, joint.name, { origin: { ...joint.origin, [axis]: value } }))
1034
+ )
1035
+ )}
1036
+ </div>
1037
+ </div>
1038
+
1039
+ ${joint.limits
1040
+ ? html`
1041
+ <div vector-row>
1042
+ <label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
1043
+ <div double>
1044
+ ${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
1045
+ this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
1046
+ )}
1047
+ ${this.number(i18next.t('figure.label.joint-limit-max'), joint.limits.max, value =>
1048
+ this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, max: value } }))
1049
+ )}
1050
+ </div>
1051
+ </div>
1052
+ ${joint.limits.min > 0 || joint.limits.max < 0
1053
+ ? html`<p warn>${i18next.t('figure.text.joint-limits-must-include-zero')}</p>`
1054
+ : nothing}
1055
+ `
1056
+ : nothing}
1057
+
1058
+ <p quiet>
1059
+ ${driver
1060
+ ? i18next.t('figure.text.joint-driven-by', { name: driver.label ?? driver.name })
1061
+ : i18next.t('figure.text.joint-not-driven')}
1062
+ </p>
1063
+ `
1064
+ : nothing}
1065
+ </section>
1066
+ `
1067
+ }
1068
+
1069
+ private setParent(name: string) {
1070
+ this.change(part => {
1071
+ const { parent: _dropped, ...rest } = part
1072
+ return name === '' ? (rest as FigurePart) : { ...rest, parent: name }
1073
+ })
1074
+ }
1075
+
1076
+ /**
1077
+ * 기준 상자를 고친다 — 부품은 저장되는 자리에 그대로 둔 채(`base-box.ts`).
1078
+ *
1079
+ * 초안과 부품이 함께 바뀌므로 둘을 한 번에 올린다. 따로 올리면 되돌리기가 둘로 갈리고, 그 사이
1080
+ * 한 번만 되돌린 화면은 부품이 반쯤 밀린 모습이 된다.
1081
+ */
1082
+ /**
1083
+ * 기준 상자를 고친 것이 **이미 놓인 인스턴스에 무엇을 하나** — 고친 칸 바로 아래에서 말한다.
1084
+ *
1085
+ * 막는 것과 알리는 것을 가른다. 비율이 달라지면 놓인 것이 찌그러지므로 발행이 막히고, 크기만
1086
+ * 달라지는 것은 실척이 어긋날 뿐이라 알리기만 한다. 까닭은 형식이 적은 것을 그대로 옮긴다 —
1087
+ * 여기서 다시 쓰면 두 벌이 되고 곧 갈린다.
1088
+ */
1089
+ private renderBaseContract() {
1090
+ if (this.contractFailure) {
1091
+ return html`<p contract>
1092
+ <md-icon>help</md-icon>
1093
+ <span>${i18next.t('figure.text.contract-not-compared', { why: this.contractFailure })}</span>
1094
+ </p>`
1095
+ }
1096
+
1097
+ const mine = this.contract.filter(one => one.code === 'base-ratio-changed' || one.code === 'base-size-changed')
1098
+ if (mine.length === 0) return nothing
1099
+
1100
+ return html`${mine.map(
1101
+ one => html`<p contract ?blocking=${one.blocking}>
1102
+ <md-icon>${one.blocking ? 'block' : 'info'}</md-icon>
1103
+ <span><b>${one.message}</b> ${one.why}</span>
1104
+ </p>`
1105
+ )}`
1106
+ }
1107
+
1108
+ private resizeBase(patch: Partial<BaseSize>) {
1109
+ if (!this.draft) return
1110
+
1111
+ const next = resizeBase(this.draft, this.parts, { ...baseOf(this.draft), ...patch })
1112
+ if (next.draft === this.draft) return
1113
+
1114
+ this.dispatchEvent(
1115
+ new CustomEvent('resize-base', {
1116
+ detail: { draft: next.draft, parts: next.parts },
1117
+ bubbles: true,
1118
+ composed: true
1119
+ })
1120
+ )
1121
+ }
1122
+
1123
+ /** 부품 전부를 감싸는 가장 작은 상자로. 맞출 것이 없으면 아무 일도 안 한다. */
1124
+ private fitBaseToParts() {
1125
+ if (!this.draft) return
1126
+ const fitted = baseFittingParts(this.draft, this.parts)
1127
+ if (fitted) this.resizeBase(fitted)
1128
+ }
1129
+
1130
+ private setJointType(part: FigurePart, value: string) {
1131
+ if (!this.draft) return
1132
+
1133
+ /* 붙은 부품을 함께 준다 — 새 관절이 둘이 맞닿는 자리에 서려면 그쪽 치수를 알아야 한다. */
1134
+ const parent = part.parent ? this.parts.find(one => one.name === part.parent) : undefined
1135
+ this.tellDraft(
1136
+ setJointType(
1137
+ this.draft,
1138
+ part,
1139
+ value === '' ? undefined : (value as JointType),
1140
+ parent ? partToFigure(parent, this.draft) : undefined
1141
+ )
1142
+ )
1143
+ }
1144
+
1145
+ private setJointAxis(name: string, key: string) {
1146
+ const choice = JOINT_AXES.find(one => one.key === key)
1147
+ if (!choice || !this.draft) return
1148
+ this.tellDraft(patchJoint(this.draft, name, { axis: { ...choice.axis } }))
1149
+ }
1150
+
1151
+ private tellDraft(draft: FigureDraft) {
1152
+ this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
1153
+ }
1154
+
887
1155
  /**
888
1156
  * 역할 하나를 켜고 끈다.
889
1157
  *
@@ -1007,6 +1275,27 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1007
1275
  ${this.helpHint('figure.text.placement-explained')}
1008
1276
  </div>
1009
1277
 
1278
+ <!--
1279
+ 기준 상자 — **고치는 길이 화면에 있어야 한다.** 부품이 이 상자를 넘으면 발행이 막히고,
1280
+ 고치는 방법은 상자 숫자를 키우는 것이다(ADR-0044 ④). 그 칸이 없어서 저작자는 넘쳤다는
1281
+ 말만 듣고 고칠 자리를 못 찾았다.
1282
+ -->
1283
+ <div field>
1284
+ <label>${i18next.t('figure.label.base-box')}${this.helpButton('figure.text.base-box-explained')}</label>
1285
+ <div triple>
1286
+ ${this.number('X', this.draft?.width ?? 0, value => this.resizeBase({ width: value }), 1)}
1287
+ ${this.number('Y', this.draft?.depth ?? 0, value => this.resizeBase({ depth: value }), 1)}
1288
+ ${this.number('Z', this.draft?.height ?? 0, value => this.resizeBase({ height: value }), 1)}
1289
+ </div>
1290
+ ${this.parts.length > 0
1291
+ ? html`<button fit @click=${() => this.fitBaseToParts()} title=${i18next.t('figure.text.fit-base-explained')}>
1292
+ <md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
1293
+ </button>`
1294
+ : nothing}
1295
+ ${this.renderBaseContract()}
1296
+ ${this.helpHint('figure.text.base-box-explained')}
1297
+ </div>
1298
+
1010
1299
  <div inline-field>
1011
1300
  <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
1012
1301
  <input
@@ -6,8 +6,10 @@ import { compile, validate } from '@hatiolab/figure-model'
6
6
  import type { FigureSource } from '@hatiolab/figure-model'
7
7
  import { create, registerFigure } from '@hatiolab/things-scene'
8
8
 
9
- import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
9
+ import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH } from './figure-view.js'
10
10
  import type { ViewSettings } from './figure-view.js'
11
+ import { poseChanges } from './pose-defaults.js'
12
+ import type { FigureDraft } from './figure-source.js'
11
13
 
12
14
  /**
13
15
  * 저작 중인 것을 **도면에 놓았을 때**의 모습.
@@ -144,6 +146,24 @@ export class FigurePreview extends localize(i18next)(LitElement) {
144
146
  align-items: center;
145
147
  gap: 8px;
146
148
  }
149
+ /*
150
+ 막대 위에 놓는 단추. 자세를 만든 자리에 있어야 「지금 이것을 기본으로」가 읽힌다.
151
+ 앉힐 것이 있을 때만 나오므로, 늘 서 있는 칸이 아니다.
152
+ */
153
+ div[clips] button[pose] {
154
+ align-self: stretch;
155
+ padding: 4px 8px;
156
+ font: inherit;
157
+ font-size: 0.7rem;
158
+ color: rgba(255, 255, 255, 0.92);
159
+ background-color: rgba(255, 255, 255, 0.14);
160
+ border: 1px solid rgba(255, 255, 255, 0.22);
161
+ border-radius: 5px;
162
+ cursor: pointer;
163
+ }
164
+ div[clips] button[pose]:hover {
165
+ background-color: rgba(255, 255, 255, 0.24);
166
+ }
147
167
  div[clips] span[drive] {
148
168
  opacity: 0.6;
149
169
  font-size: 0.66rem;
@@ -176,6 +196,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
176
196
  /** 저작 중인 것. 저장 형식으로 받는다 — 도면이 소비하는 것이 이 형식이다. */
177
197
  @property({ type: Object }) source?: FigureSource
178
198
 
199
+ /**
200
+ * 저작 중인 초안. **기본값이 지금 무엇인지 알려고만 쥔다** — 고치지 않는다.
201
+ *
202
+ * `source` 로도 파라미터를 읽을 수 있지만, 후보를 미리 볼 때는 `source` 가 후보의 것이고
203
+ * 초안은 열려 있는 도형의 것이다. 「이 자세를 기본으로」는 열려 있는 도형에만 뜻이 있다.
204
+ */
205
+ @property({ type: Object }) draft?: FigureDraft
206
+
179
207
  /**
180
208
  * 보기 설정. 저장되지 않는다(`figure-view.ts`).
181
209
  *
@@ -226,8 +254,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
226
254
  clips: NonNullable<FigureSource['animations']>,
227
255
  parameters: NonNullable<FigureSource['parameters']>
228
256
  ) {
257
+ /*
258
+ 지금 세운 자세를 기본값으로 앉히는 자리. **막대 옆에 둔다** — 자세를 만든 곳이 여기라,
259
+ 값을 읽어 파라미터 칸으로 옮겨 적는 왕복이 없어진다. 앉힐 것이 없으면 아예 안 나온다.
260
+ */
261
+ const changes = poseChanges(this.draft, this.giving)
262
+
229
263
  return html`
230
264
  <div clips>
265
+ ${changes.length > 0
266
+ ? html`<button
267
+ pose
268
+ title=${i18next.t('figure.text.pose-as-default-explained')}
269
+ @click=${() => this.poseAsDefault()}
270
+ >
271
+ ${i18next.t('figure.button.pose-as-default')}
272
+ </button>`
273
+ : ''}
231
274
  ${parameters.map(parameter => {
232
275
  const range = parameter.range
233
276
  const value = this.giving[parameter.name] ?? parameter.default ?? range?.min ?? 0
@@ -292,6 +335,18 @@ export class FigurePreview extends localize(i18next)(LitElement) {
292
335
  this.tellInstances('parameters', this.giving)
293
336
  }
294
337
 
338
+ /**
339
+ * 세운 자세를 기본값으로 — **셈은 여기서 하지 않는다.**
340
+ *
341
+ * 초안을 쥐고 있는 것은 페이지이고, 되돌리기와 「저장 안 됨」도 거기서 매긴다. 여기서
342
+ * 초안을 만들어 보내면 그 셋이 두 곳으로 갈린다. 민 값만 올린다.
343
+ */
344
+ private poseAsDefault(): void {
345
+ this.dispatchEvent(
346
+ new CustomEvent('pose-as-default', { detail: { values: { ...this.giving } }, bubbles: true, composed: true })
347
+ )
348
+ }
349
+
295
350
  private tellInstances(key: 'clips' | 'parameters', values: Record<string, number>): void {
296
351
  for (const component of this.scene?.root?.components ?? []) {
297
352
  component.set?.(key, { ...values })
@@ -435,7 +490,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
435
490
  어느 경우에도 담기는 경계만 잡는다. 바닥 기반은 가장 큰 것의 키까지, 천정 기반은
436
491
  천장까지다. 배치를 높이로 옮기는 계산을 여기서 다시 쓰면 그것이 두 벌째가 된다.
437
492
  */
438
- depth: Math.round(Math.max(view.ceilingHeight, base.y * biggest) * 1.1)
493
+ depth: previewBoardDepth(source, view.ceilingHeight, biggest)
439
494
  }
440
495
 
441
496
  this.scene = create({
@@ -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'
@@ -507,8 +507,9 @@ export class FigureReport extends localize(i18next)(LitElement) {
507
507
  * 직접 답하지 않는다 — 형상이 선언된 한도 안인지를 말할 뿐이다. 그래서 개수를
508
508
  * 머리에 올리고 등급은 옆의 badge 로 내린다.
509
509
  *
510
- * 근거를 함께 낸다. 개수의 분모는 실측이 아니라 설계 문서의 목표치이고(미결
511
- * D-20), 사실이 화면에 없으면 값처럼 읽힌다.
510
+ * 근거를 함께 낸다. 묶음으로 갈리는 개수의 분모는 **실측**이고(2026-09-18, Apple M5),
511
+ * 기계와 날짜를 함께 적는다 적지 않으면 어디서나 참인 수로 읽힌다. 삼각형 쪽은 아직 설계
512
+ * 문서의 목표치라 그렇다고 적는다.
512
513
  */
513
514
  private renderVerdict() {
514
515
  const score = this.score
@@ -544,7 +545,11 @@ export class FigureReport extends localize(i18next)(LitElement) {
544
545
  per: capacity.per.toLocaleString(),
545
546
  budget: capacity.budget.toLocaleString()
546
547
  })}
547
- <em>${i18next.t('figure.text.budget-not-measured-yet')}</em>
548
+ <em>
549
+ ${capacity.bound === 'groups'
550
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
551
+ : i18next.t('figure.text.budget-not-measured-yet')}
552
+ </em>
548
553
  </p>
549
554
  </section>
550
555
  `
@@ -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
@@ -198,6 +204,8 @@ export class FigureSide extends localize(i18next)(LitElement) {
198
204
  .figureTags=${this.figureTags}
199
205
  .draft=${this.draft}
200
206
  .parts=${this.parts}
207
+ .contract=${this.contract}
208
+ .contractFailure=${this.contractFailure}
201
209
  .selected=${this.selected}
202
210
  ></figure-inspector>
203
211
 
@@ -128,6 +128,12 @@ export interface FigureDraft {
128
128
  * 주나**를 말하는 다른 칸이다.
129
129
  */
130
130
  capabilities?: FigureSource['capabilities']
131
+ /**
132
+ * How parts move relative to their parents (ADR-0066). Kept in figure coordinates as stored, the same
133
+ * terms the inspector shows. Carried even when nothing edits them, so opening and saving never drops
134
+ * them.
135
+ */
136
+ joints?: FigureSource['joints']
131
137
  }
132
138
 
133
139
  /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
@@ -168,6 +174,14 @@ export interface PartModel {
168
174
  */
169
175
  hidden?: boolean
170
176
 
177
+ /**
178
+ * 씬이 이것을 **집지 않는다** — 클릭·마퀴·기즈모 어디에도 안 걸린다.
179
+ *
180
+ * 저작면에만 서는 것(관절 부채꼴)이 쓴다. 저장 형식에는 없는 칸이라 `toFigureSource` 가
181
+ * 싣지 않는다.
182
+ */
183
+ locked?: boolean
184
+
171
185
  /** 색 토큰 이름 (`palette.primary` 등). */
172
186
  token?: string
173
187
  /*
@@ -202,6 +216,8 @@ export interface PartModel {
202
216
  * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
203
217
  */
204
218
  capability?: FigurePart['capability']
219
+ /** The part this one is attached to (ADR-0066). The one place a parent is written. */
220
+ parent?: FigurePart['parent']
205
221
  }
206
222
 
207
223
  const RAD_TO_DEG = 180 / Math.PI
@@ -293,7 +309,8 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
293
309
  parts: parts.map(part => partTo(part, half)),
294
310
  animations: draft.animations,
295
311
  parameters: draft.parameters,
296
- capabilities: draft.capabilities
312
+ capabilities: draft.capabilities,
313
+ joints: draft.joints?.length ? draft.joints : undefined
297
314
  }) as FigureSource
298
315
  }
299
316
 
@@ -342,7 +359,8 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
342
359
  anchor: part.anchor,
343
360
  repeat: part.repeat,
344
361
  label: part.label,
345
- capability: part.capability
362
+ capability: part.capability,
363
+ parent: part.parent
346
364
  }) as FigurePart
347
365
  }
348
366
 
@@ -362,7 +380,8 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
362
380
  /* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
363
381
  animations: source.animations?.length ? source.animations : undefined,
364
382
  parameters: source.parameters?.length ? source.parameters : undefined,
365
- capabilities: source.capabilities
383
+ capabilities: source.capabilities,
384
+ joints: source.joints?.length ? source.joints : undefined
366
385
  }) as FigureDraft
367
386
 
368
387
  return { draft, parts: source.parts.map(part => partFrom(part, half)) }
@@ -412,6 +431,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
412
431
  anchor: part.anchor,
413
432
  repeat: part.repeat,
414
433
  label: part.label,
415
- capability: part.capability
434
+ capability: part.capability,
435
+ parent: part.parent
416
436
  }) as PartModel
417
437
  }
@@ -130,3 +130,23 @@ export const DEFAULT_VIEW: ViewSettings = {
130
130
  dirShadowEnabled: true,
131
131
  hemiIntensity: 4.0
132
132
  }
133
+
134
+ /**
135
+ * How tall the preview board is: enough room for the tallest instance where the figure actually stands.
136
+ *
137
+ * A floor or center figure stands on the floor, so the tallest instance is the whole height. Only a
138
+ * ceiling figure needs the room up to the ceiling. The board used to reach the ceiling for every figure,
139
+ * which made a 920 tall AGV preview 4400 tall.
140
+ *
141
+ * This is an envelope, not a placement rule: where each instance goes is still the scene's
142
+ * (`FigureRealObject.effectiveZPos`), and the camera fits what the scene drew.
143
+ */
144
+ export function previewBoardDepth(
145
+ figure: { placement?: 'floor' | 'ceiling' | 'center'; base: { y: number } },
146
+ ceilingHeight: number,
147
+ factor: number
148
+ ): number {
149
+ const tallest = figure.base.y * factor
150
+ const reach = figure.placement === 'ceiling' ? Math.max(ceilingHeight, tallest) : tallest
151
+ return Math.round(reach * 1.1)
152
+ }