@things-factory/figure-ui 10.1.28 → 10.1.31

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 (62) hide show
  1. package/client/{themes → harness-theme}/brand.css +15 -4
  2. package/{dist-client/themes → client/harness-theme}/dark.css +4 -4
  3. package/client/{themes → harness-theme}/light.css +6 -6
  4. package/client/modeller/angle-dial.ts +70 -0
  5. package/client/modeller/figure-animations.ts +9 -9
  6. package/client/modeller/figure-canvas.ts +6 -3
  7. package/client/modeller/figure-inspector.ts +18 -3
  8. package/client/modeller/figure-preview.ts +56 -4
  9. package/client/modeller/figure-report.ts +11 -28
  10. package/client/modeller/figure-side.ts +1 -0
  11. package/client/modeller/joint-gizmo.ts +63 -1
  12. package/client/modeller/part-edits.ts +75 -9
  13. package/client/pages/figure-modeller-page.ts +20 -2
  14. package/client/types.ts +2 -0
  15. package/dist-client/{themes → harness-theme}/brand.css +15 -4
  16. package/{client/themes → dist-client/harness-theme}/dark.css +4 -4
  17. package/dist-client/{themes → harness-theme}/light.css +6 -6
  18. package/dist-client/modeller/angle-dial.d.ts +32 -0
  19. package/dist-client/modeller/angle-dial.js +41 -0
  20. package/dist-client/modeller/angle-dial.js.map +1 -0
  21. package/dist-client/modeller/figure-animations.d.ts +4 -4
  22. package/dist-client/modeller/figure-animations.js +9 -9
  23. package/dist-client/modeller/figure-animations.js.map +1 -1
  24. package/dist-client/modeller/figure-canvas.js +6 -3
  25. package/dist-client/modeller/figure-canvas.js.map +1 -1
  26. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  27. package/dist-client/modeller/figure-inspector.js +20 -3
  28. package/dist-client/modeller/figure-inspector.js.map +1 -1
  29. package/dist-client/modeller/figure-preview.d.ts +11 -0
  30. package/dist-client/modeller/figure-preview.js +53 -3
  31. package/dist-client/modeller/figure-preview.js.map +1 -1
  32. package/dist-client/modeller/figure-report.d.ts +8 -4
  33. package/dist-client/modeller/figure-report.js +11 -31
  34. package/dist-client/modeller/figure-report.js.map +1 -1
  35. package/dist-client/modeller/figure-side.js +1 -0
  36. package/dist-client/modeller/figure-side.js.map +1 -1
  37. package/dist-client/modeller/joint-gizmo.d.ts +4 -1
  38. package/dist-client/modeller/joint-gizmo.js +54 -0
  39. package/dist-client/modeller/joint-gizmo.js.map +1 -1
  40. package/dist-client/modeller/part-edits.d.ts +29 -4
  41. package/dist-client/modeller/part-edits.js +61 -9
  42. package/dist-client/modeller/part-edits.js.map +1 -1
  43. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  44. package/dist-client/pages/figure-modeller-page.js +20 -2
  45. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/dist-client/types.d.ts +2 -0
  48. package/dist-client/types.js.map +1 -1
  49. package/package.json +5 -5
  50. package/test/angle-dial.test.ts +78 -0
  51. package/test/base-box.test.ts +11 -0
  52. package/test/brand-palette.test.ts +99 -18
  53. package/test/expect.ts +2 -0
  54. package/test/i18n-prefix-guard.test.ts +17 -0
  55. package/test/joint-needle.test.ts +162 -0
  56. package/test/part-edits.test.ts +100 -0
  57. package/test/scene-budget.test.ts +29 -0
  58. package/translations/en.json +3 -7
  59. package/translations/ja.json +3 -7
  60. package/translations/ko.json +3 -7
  61. package/translations/ms.json +3 -7
  62. package/translations/zh.json +3 -7
@@ -9,8 +9,19 @@
9
9
  * operato-plant 가 같은 문제를 겪고 같은 방식으로 풀었다(`operato-plant/assets/brand.css`).
10
10
  * 그 구조를 그대로 따른다 — 값은 여기 한 번만 적고, 앱 팔레트와 하네스가 이 파일을 읽는다.
11
11
  *
12
+ * ── 왜 `themes/` 가 아닌가 ─────────────────────────────────────────────────
13
+ * `@things-factory/builder` 의 `ThemeOverridePlugin` 이 모든 모듈의 `client/themes/*.css` 를
14
+ * **파일 이름 하나당 하나만** 골라 테마 묶음에 싣는다(앱이 맨 앞, 나머지는 건너뜀). 그래서
15
+ * 모듈이 `themes/light.css` 를 두면 앱이 같은 이름을 갖는 순간 통째로 빠지고, 이름을 비켜서
16
+ * 두면 앞의 것과 **같은 선택자로 md-sys 칸을 덮어** 묶음 순서가 색을 정하게 된다. md-sys 슬롯은
17
+ * 모듈이 선언하지 않는다 — shell 이 열고 앱이 채운다(ADR-0060 결정 2).
18
+ *
19
+ * 이 폴더의 셋은 **하네스가 쓰는 것**이고, `themes` 밖에 있으므로 플러그인이 아예 보지 않는다.
20
+ * 앱 화면의 색은 앱의 테마 파일이 정한다. 여기 값을 앱 색으로 알고 고치지 말 것 — 지금 이 셋은
21
+ * navy 이고 앱은 teal 이다(수석 아키텍트가 사용자께 올려 둔 미결이다).
22
+ *
12
23
  * ── 브랜드 색만 여기 둔다 ──────────────────────────────────────────────────
13
- * 중성색은 여기 없다. 그것은 `client/themes/light.css` 가 갖는다. 여기 있는 것은 **제품을
24
+ * 중성색은 여기 없다. 그것은 `client/harness-theme/light.css` 가 갖는다. 여기 있는 것은 **제품을
14
25
  * 가리키는 색**뿐이고, 그것만 절대 갈라지면 안 된다.
15
26
  *
16
27
  * ── 밝은 것과 어두운 것을 함께 든다 ────────────────────────────────────────
@@ -37,15 +48,15 @@
37
48
  * 고른 부품 색**이다. 틀이 진한 색으로 소리를 내면 캔버스 안의 색을 판단할 수 없다. 그래서
38
49
  * 브랜드 색은 깊고 조용하게 두고, 고른 것의 밑줄 · 기본 단추 · 링크에만 좁게 쓴다.
39
50
  */
40
- --brand-figure: #154473;
51
+ --figure-brand: #154473;
41
52
 
42
53
  /*
43
54
  * 어두운 면 위에서 쓰는 같은 색. L* 72 · C 0.130.
44
55
  *
45
56
  * 3D 캔버스의 선택 테두리와 어두운 배경의 강조가 이 값이다. 어두운 면(#12161b) 대비 7.02.
46
57
  */
47
- --brand-figure-2: #64a9f3;
58
+ --figure-brand-2: #64a9f3;
48
59
 
49
60
  /** 아주 옅게 깔 때 — 고른 줄의 바탕. 위 색과 같은 색상각이다. */
50
- --brand-figure-wash: #eaf3fd;
61
+ --figure-brand-wash: #eaf3fd;
51
62
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * ── 브랜드 색이 밝은 쪽으로 바뀐다 ────────────────────────────────────────
10
10
  * `#154473` 은 어두운 바탕에서 대비 1.6 이라 보이지 않는다. 그래서 어두운 면에서는
11
- * `--brand-figure-2`(#64a9f3)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
11
+ * `--figure-brand-2`(#64a9f3)가 브랜드 색이다. 이름이 뜻이고 값은 자리에 따라 다르다 —
12
12
  * plant · twin 과 같은 규약이다.
13
13
  *
14
14
  * [측정] 어두운 면(#12161b) 대비 — #154473 1.60 · #64a9f3 7.02
@@ -40,12 +40,12 @@
40
40
  --md-sys-color-on-surface-dim: #7d848d;
41
41
 
42
42
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
43
- --md-sys-color-primary: var(--brand-figure-2);
43
+ --md-sys-color-primary: var(--figure-brand-2);
44
44
  --md-sys-color-on-primary: #0d1116;
45
- --md-sys-color-inverse-primary: var(--brand-figure);
45
+ --md-sys-color-inverse-primary: var(--figure-brand);
46
46
 
47
47
  --md-sys-color-primary-container: #1c2b3d;
48
- --md-sys-color-on-primary-container: var(--brand-figure-2);
48
+ --md-sys-color-on-primary-container: var(--figure-brand-2);
49
49
 
50
50
  --md-sys-color-secondary-container: #1a1f25;
51
51
  --md-sys-color-on-secondary-container: #a8afb8;
@@ -3,8 +3,8 @@
3
3
  /*
4
4
  * figure 라이트 팔레트 — 이 파일이 이 도구 색의 **정본**이다.
5
5
  *
6
- * 브랜드 색은 `client/themes/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--brand-figure` ·
7
- * `--brand-figure-2` · `--brand-figure-wash` 셋이다. 값을 여기 다시 적지 않는다.
6
+ * 브랜드 색은 `client/harness-theme/brand.css` 가 갖는다. 여기서 쓸 수 있는 이름은 `--figure-brand` ·
7
+ * `--figure-brand-2` · `--figure-brand-wash` 셋이다. 값을 여기 다시 적지 않는다.
8
8
  *
9
9
  * ── 이 도구에서 색이 말해야 하는 것 ────────────────────────────────────────
10
10
  * **저작자가 고른 부품 색**이다. 화면 한가운데 캔버스가 있고, 거기 놓인 부품의 색을 보고
@@ -52,16 +52,16 @@
52
52
  --md-sys-color-on-surface-dim: #6a6f76;
53
53
 
54
54
  /* ── 브랜드 ──────────────────────────────────────────────────────────── */
55
- --md-sys-color-primary: var(--brand-figure);
55
+ --md-sys-color-primary: var(--figure-brand);
56
56
  --md-sys-color-on-primary: #ffffff;
57
- --md-sys-color-inverse-primary: var(--brand-figure-2);
57
+ --md-sys-color-inverse-primary: var(--figure-brand-2);
58
58
 
59
59
  /*
60
60
  * 고른 줄의 바탕. 이것만 유일하게 색이 든 면이고, 그것도 거의 흰색이다(L* 96).
61
61
  * 그 위에 오는 글자는 브랜드 색이며 대비 8.90 이다.
62
62
  */
63
- --md-sys-color-primary-container: var(--brand-figure-wash);
64
- --md-sys-color-on-primary-container: var(--brand-figure);
63
+ --md-sys-color-primary-container: var(--figure-brand-wash);
64
+ --md-sys-color-on-primary-container: var(--figure-brand);
65
65
 
66
66
  /*
67
67
  * 두 번째 면 — 파스텔을 쓰지 않으므로 무채색이다. 칩과 배지가 이것을 깐다.
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ /**
6
+ * 막대 옆에 서는 **각도 눈**. 지금 각이 범위의 어디인지를 한 눈에 보인다.
7
+ *
8
+ * ## 왜 3D 로는 모자랐나
9
+ *
10
+ * 관절이 돌아도 화면이 그대로인 경우가 있다 — 제 축에서 대칭인 부품이 그렇다. 그래서 관절이
11
+ * 모는 부품에 바늘을 달아 미리보기에서 함께 돌게 했는데(`joint-gizmo.ts` 의 `jointNeedles`),
12
+ * **미리보기에서는 그 바늘이 4 × 2 픽셀이다**(잰 값: 부품 100mm · 바늘 60 × 12mm · 여덟 대를
13
+ * 한 화면에 세운 상태). 미리보기는 크기 반응을 보려고 여덟 대를 나란히 놓는 자리라 한 대가
14
+ * 20 픽셀쯤이고, 그 위의 어떤 표시도 몇 픽셀을 넘지 못한다.
15
+ *
16
+ * 각을 읽는 일은 3D 의 배율에서 떼어 놓는다. 눈은 막대 옆 24 픽셀 자리에 서고, 도형이 아무리
17
+ * 작게 서 있어도 같은 크기로 읽힌다.
18
+ */
19
+
20
+ export interface DialShape {
21
+ /** 가동 범위를 채운 부채꼴. SVG path 의 `d`. */
22
+ sector: string
23
+ /** 지금 값이 가리키는 끝점. */
24
+ needle: { x: number; y: number }
25
+ centre: { x: number; y: number }
26
+ radius: number
27
+ }
28
+
29
+ /** 0 도가 위를 가리키고, 값이 커지면 시계 방향으로 돈다 — 화면의 각과 같은 방향이다. */
30
+ function at(centre: { x: number; y: number }, radius: number, degrees: number) {
31
+ const rad = (degrees * Math.PI) / 180
32
+ return {
33
+ x: +(centre.x + radius * Math.sin(rad)).toFixed(2),
34
+ y: +(centre.y - radius * Math.cos(rad)).toFixed(2)
35
+ }
36
+ }
37
+
38
+ /** 한 바퀴에 가까우면 부채꼴 대신 온전한 원이다. */
39
+ const FULL = 359.9
40
+
41
+ /**
42
+ * 눈의 모양. 값이 범위를 벗어나면 범위 끝에 세운다 — 밖을 가리키는 바늘은 없는 값을 말한다.
43
+ */
44
+ export function dialShape(min: number, max: number, value: number, radius = 9): DialShape {
45
+ const centre = { x: radius + 1, y: radius + 1 }
46
+ const span = Math.abs(max - min)
47
+ const held = Math.min(Math.max(value, Math.min(min, max)), Math.max(min, max))
48
+
49
+ if (span >= FULL) {
50
+ const top = at(centre, radius, 0)
51
+ const bottom = at(centre, radius, 180)
52
+ return {
53
+ sector: `M ${top.x} ${top.y} A ${radius} ${radius} 0 1 1 ${bottom.x} ${bottom.y} A ${radius} ${radius} 0 1 1 ${top.x} ${top.y} Z`,
54
+ needle: at(centre, radius, held),
55
+ centre,
56
+ radius
57
+ }
58
+ }
59
+
60
+ const from = at(centre, radius, min)
61
+ const to = at(centre, radius, max)
62
+ const large = span > 180 ? 1 : 0
63
+
64
+ return {
65
+ sector: `M ${centre.x} ${centre.y} L ${from.x} ${from.y} A ${radius} ${radius} 0 ${large} 1 ${to.x} ${to.y} Z`,
66
+ needle: at(centre, radius, held),
67
+ centre,
68
+ radius
69
+ }
70
+ }
@@ -899,25 +899,25 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
899
899
  }
900
900
 
901
901
  /**
902
- * 움직이는 부품 예산.
902
+ * 따로 움직이는 부품 예산.
903
903
  *
904
- * **넘어도 막지 않는다.** 움직이는 부품은 저마다 단독 묶음이라 draw call 이 늘고 프레임마다
905
- * 갱신이 는다 — 그 대가가 수로 보이면 저작자가 아낀다. 랜드마크 설비 하나는 정교해도 되고,
906
- * 그 값이 도면 예산에 드러나면 된다.
904
+ * **넘어도 막지 않는다.** 제 변환을 지는 부품은 저마다 단독 묶음이라 draw call 이 늘고,
905
+ * 그만큼 한 화면에 함께 세울 수 있는 대수가 준다 — 그 대가가 수로 보이면 저작자가 아낀다.
906
+ * 랜드마크 설비 하나는 정교해도 되고, 그 값이 도면 예산에 드러나면 된다.
907
907
  */
908
908
  private renderBudget(): TemplateResult {
909
909
  const used = this.movingParts
910
- const over = used > LIMITS.animatedParts
910
+ const over = used > LIMITS.independentParts
911
911
 
912
912
  return html`
913
913
  <section>
914
- <h3>${i18next.t('figure.label.budget-animations')}</h3>
914
+ <h3>${i18next.t('figure.label.budget-independent')}</h3>
915
915
  <div budget ?over=${over}>
916
916
  <strong>${used}</strong>
917
- <span quiet>/ ${LIMITS.animatedParts}</span>
917
+ <span quiet>/ ${LIMITS.independentParts}</span>
918
918
  </div>
919
- <p quiet>${i18next.t('figure.text.animated-parts-cost')}</p>
920
- ${over ? html`<p warn>${i18next.t('figure.text.animated-parts-over')}</p>` : nothing}
919
+ <p quiet>${i18next.t('figure.text.independent-parts-cost')}</p>
920
+ ${over ? html`<p warn>${i18next.t('figure.text.independent-parts-over')}</p>` : nothing}
921
921
  </section>
922
922
  `
923
923
  }
@@ -4,7 +4,7 @@ import { customElement, property, query, state } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { create, Model } from '@hatiolab/things-scene'
6
6
 
7
- import { gridStep } from './part-edits.js'
7
+ import { gridStep, roundPart } from './part-edits.js'
8
8
  import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
9
9
  import type { ViewSettings } from './figure-view.js'
10
10
  import type { FigureDraft, PartModel } from './figure-source.js'
@@ -536,8 +536,11 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
536
536
  const was = this.parts.find(part => part.name === name)
537
537
  if (!was) return
538
538
 
539
- // 씬 모델 그대로 올려 보낸다. 모델러 페이지가 갖고 있는 것도 씬 모델이라 변환할 것이 없다.
540
- this.tell('part-changed', { name, part: { ...was, ...changed!.state } as PartModel })
539
+ /*
540
+ 씬 모델 그대로 올려 보낸다 — 모델러 페이지가 갖고 있는 것도 씬 모델이라 변환할 것이 없다.
541
+ 밀리미터로만 맞춘다: 기즈모는 비율을 곱해 크기를 내므로 소수가 그대로 남는다.
542
+ */
543
+ this.tell('part-changed', { name, part: roundPart({ ...was, ...changed!.state } as PartModel) })
541
544
  })
542
545
  }
543
546
 
@@ -856,6 +856,21 @@ export class FigureInspector extends localize(i18next)(LitElement) {
856
856
 
857
857
  /** 도형의 DB 식별자. 비어 있으면 미저장 신규 모델이다. */
858
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
+ }
859
874
  /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
860
875
  @property({ type: String }) figureName = ''
861
876
  /** 분류 하나 — 팔레트에서 묶는 기준. */
@@ -1232,11 +1247,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1232
1247
  />
1233
1248
  </div>
1234
1249
  <div inline-field>
1235
- <label>${i18next.t('figure.label.figure-type')}${this.figureId ? this.helpButton('figure.text.type-name-cannot-change') : nothing}</label>
1236
- ${!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
1237
1252
  ? html`<input
1238
1253
  .value=${this.draft?.figureType ?? ''}
1239
- placeholder=${i18next.t('figure.text.figure-type-placeholder', { defaultValue: '도형 식별 타입 (예: OHT)' })}
1254
+ placeholder=${i18next.t('figure.text.figure-type-placeholder')}
1240
1255
  @change=${(e: Event) =>
1241
1256
  this.dispatchEvent(
1242
1257
  new CustomEvent('figure-type-change', {
@@ -8,6 +8,8 @@ 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'
11
13
  import { poseChanges } from './pose-defaults.js'
12
14
  import type { FigureDraft } from './figure-source.js'
13
15
 
@@ -64,7 +66,7 @@ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' |
64
66
  *
65
67
  * 한동안 배치 기준을 높이로 옮기는 계산을 이 파일이 들고 있었다. 이제 씬이 한다 —
66
68
  * `FigureRealObject.effectiveZPos` 가 청사진의 `placement` 와 레이어의 `heights` 로 정한다
67
- * (ADR-0045, `operato-twin/design/04-decisions.md`). 도면이 그렇게 세우므로 「도면에 놓았을
69
+ * (ADR-0045, `operato-application/adr/ADR-0045.md`). 도면이 그렇게 세우므로 「도면에 놓았을
68
70
  * 때의 모습」을 말하는 이 화면도 그렇게 세워야 한다 — 여기서 `zPos` 를 적으면 **적은 값이
69
71
  * 이겨서**, 씬이 무엇을 할지가 이 화면에서만 안 보인다.
70
72
  *
@@ -142,10 +144,27 @@ export class FigurePreview extends localize(i18next)(LitElement) {
142
144
  }
143
145
  div[clips] label {
144
146
  display: grid;
145
- grid-template-columns: auto 96px 34px;
147
+ grid-template-columns: auto 20px 96px 34px;
146
148
  align-items: center;
147
149
  gap: 8px;
148
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
+ }
149
168
  /*
150
169
  막대 위에 놓는 단추. 자세를 만든 자리에 있어야 「지금 이것을 기본으로」가 읽힌다.
151
170
  앉힐 것이 있을 때만 나오므로, 늘 서 있는 칸이 아니다.
@@ -279,6 +298,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
279
298
  return html`
280
299
  <label>
281
300
  <span>${parameter.label ?? parameter.name} <span drive>${range?.unit ?? ''}</span></span>
301
+ ${this.renderDial(range, value)}
282
302
  <input
283
303
  type="range"
284
304
  min=${range?.min ?? 0}
@@ -297,6 +317,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
297
317
  return html`
298
318
  <label>
299
319
  <span>${clip.name} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
320
+ <span></span>
300
321
  <input
301
322
  type="range"
302
323
  min="0"
@@ -313,6 +334,31 @@ export class FigurePreview extends localize(i18next)(LitElement) {
313
334
  `
314
335
  }
315
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
+
316
362
  /**
317
363
  * 세워 둔 인스턴스 전부에 같은 값을 준다.
318
364
  *
@@ -410,8 +456,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
410
456
  }
411
457
 
412
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)] }
413
465
  /* 씬이 이 둘로 인스턴스 높이를 정한다 — 서명에 안 넣으면 다시 세워도 그대로 선다. */
414
- const signature = JSON.stringify([source, view.placement, view.ceilingHeight])
466
+ const signature = JSON.stringify([shown, view.placement, view.ceilingHeight])
415
467
  if (this.scene && this.standing === signature) return
416
468
 
417
469
  const { errors } = validate(source)
@@ -426,7 +478,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
426
478
  this.driving = {}
427
479
 
428
480
  try {
429
- registerFigure(compile(source) as never)
481
+ registerFigure(compile(shown) as never)
430
482
 
431
483
  /*
432
484
  도면이 하는 일을 그대로 한다. 컴포넌트에는 타입 이름과 자리·크기만 적는다 —
@@ -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
  /** 추이 그림을 제 크기로 펼쳤나. */
@@ -768,12 +767,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
768
767
  <dt>${i18next.t('figure.label.triangles')}</dt>
769
768
  <dd>${cost.triangles.toLocaleString()}</dd>
770
769
 
771
- <dt>${i18next.t('figure.label.triangles-at-100')}</dt>
772
- <dd>${cost.at.triangles.toLocaleString()}</dd>
773
-
774
- <dt>${i18next.t('figure.label.draw-calls-at-100')}</dt>
775
- <dd>${cost.at.drawCalls.toLocaleString()}</dd>
776
-
777
770
  <dt>${i18next.t('figure.label.distinct-shapes')}</dt>
778
771
  <dd>${cost.distinctShapes}</dd>
779
772
 
@@ -785,33 +778,26 @@ export class FigureReport extends localize(i18next)(LitElement) {
785
778
  }
786
779
 
787
780
  /**
788
- * 재활용 추이 — 「많이 놓으면 어떻게 되나」에 답한다.
781
+ * 재활용 추이 그림.
782
+ *
783
+ * **「몇 개 놓으면 얼마」를 숫자로 적지 않는다.** 놓는 개수는 값을 치르지 않는다 —
784
+ * 한 화면에 보이는 개수 × 그 도형의 묶음 수가 치른다(실측 2026-09-18, ADR-0051 ③).
785
+ * 전에는 여기에 「100개 기준 draw call」 · 「1만개 기준」을 적었고, 그 수는 실제로 드는
786
+ * 값과 어긋났다. 답은 위의 성숙도 칸이 한다 — 잰 기계와 날짜를 달고 「한 화면에 몇 대」를
787
+ * 말한다.
789
788
  *
790
789
  * ## 그림을 사이드바에 밀어 넣지 않는다
791
790
  *
792
791
  * `costCurveSvg` 의 자연 크기는 680x380 이고 글자가 11px 다. 300px 칸에서
793
792
  * width:100% 로 줄이면 글자가 4px 이 되어 **아무것도 읽을 수 없다** — 화면을 띄워
794
- * 보고서야 드러났다. 읽을 수 없는 그림은 없는 것과 같으므로, 사이드바에는 저작자가
795
- * 실제로 궁금한 숫자(1만 개일 때)를 두고 그림은 선택해서 제 크기로 본다.
793
+ * 보고서야 드러났다. 그림은 선택해서 제 크기로 본다.
796
794
  */
797
795
  private renderCurve() {
798
- const many = this.many
799
- if (!many && !this.curve) return nothing
796
+ if (!this.curve) return nothing
800
797
 
801
798
  return html`
802
799
  <section>
803
800
  <h3>${i18next.t('figure.label.reuse-trend')}</h3>
804
- ${many
805
- ? html`
806
- <dl metrics>
807
- <dt>${i18next.t('figure.label.triangles-at-10k')}</dt>
808
- <dd>${many.at.triangles.toLocaleString()}</dd>
809
-
810
- <dt>${i18next.t('figure.label.draw-calls-at-10k')}</dt>
811
- <dd>${many.at.drawCalls.toLocaleString()}</dd>
812
- </dl>
813
- `
814
- : nothing}
815
801
  ${this.curve
816
802
  ? html`
817
803
  <button expand @click=${() => (this.expanded = true)}>
@@ -899,7 +885,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
899
885
  if (!source) {
900
886
  this.cost = undefined
901
887
  this.score = undefined
902
- this.many = undefined
903
888
  this.capacity = undefined
904
889
  this.curve = ''
905
890
  this.errors = []
@@ -916,7 +901,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
916
901
  if (this.bare) {
917
902
  this.cost = undefined
918
903
  this.score = undefined
919
- this.many = undefined
920
904
  this.capacity = undefined
921
905
  this.curve = ''
922
906
  this.errors = []
@@ -938,7 +922,6 @@ export class FigureReport extends localize(i18next)(LitElement) {
938
922
  // 형식을 어겼으면 컴파일이 던진다. 셈을 비우고 오류만 보여 준다.
939
923
  this.cost = undefined
940
924
  this.score = undefined
941
- this.many = undefined
942
925
  this.capacity = undefined
943
926
  this.curve = ''
944
927
  this.sizing = []
@@ -963,9 +946,9 @@ export class FigureReport extends localize(i18next)(LitElement) {
963
946
  blocking: finding.code === 'sizing-splits'
964
947
  }))
965
948
 
966
- this.cost = costOf(blueprint, 100)
949
+ /* 개수 인자는 `cost.at` 만 정한다 — 그 칸을 화면에서 뺐으니 기본값 그대로 둔다. */
950
+ this.cost = costOf(blueprint)
967
951
  // 1만 개는 figure-model 에 다시 묻는다 — 화면이 곱셈으로 짐작하지 않는다.
968
- this.many = costOf(blueprint, 10000)
969
952
  this.score = scoreOf(blueprint)
970
953
  this.capacity = capacityOf(this.cost)
971
954
 
@@ -199,6 +199,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
199
199
  <figure-inspector
200
200
  ?off=${this.tab !== 'properties'}
201
201
  .figureId=${this.figureId}
202
+ .figureVersion=${this.figureVersion}
202
203
  .figureName=${this.figureName}
203
204
  .figureCategory=${this.figureCategory}
204
205
  .figureTags=${this.figureTags}
@@ -2,7 +2,7 @@
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
4
 
5
- import type { FigureJoint, FigurePart } from '@hatiolab/figure-model'
5
+ import type { FigureJoint, FigurePart, FigureSource } from '@hatiolab/figure-model'
6
6
 
7
7
  import { partFromFigure } from './figure-source.js'
8
8
  import type { FigureDraft, PartModel } from './figure-source.js'
@@ -116,3 +116,65 @@ export function gizmoPart(joint: FigureJoint, child: PartModel | undefined, draf
116
116
 
117
117
  return { ...partFromFigure(part, draft), locked: true }
118
118
  }
119
+
120
+ /*
121
+ * 미리보기에서 **관절이 돌았다는 것**을 보이게 하는 바늘.
122
+ *
123
+ * 상자 위에 원기둥을 얹고 그 축으로 돌리면 화면이 그대로다 — 원기둥은 제 축에서 대칭이라
124
+ * 돌아도 같은 그림이다. 저작자는 막대를 76도까지 밀고도 아무것도 못 보고 「관절이 안 먹는다」로
125
+ * 읽는다. 값은 제대로 갔고 부품도 돌았는데 볼 것이 없었을 뿐이다.
126
+ *
127
+ * 그래서 관절이 모는 부품에 얇은 막대 하나를 **자식으로** 붙인다. 부모가 관절의 자식이라
128
+ * 관절이 도는 만큼 같이 돌고, 축에서 비켜나 있어 대칭인 부품에서도 움직임이 보인다. 매 프레임
129
+ * 무엇을 다시 그릴 일도, 막대를 밀 때마다 씬을 다시 세울 일도 없다.
130
+ *
131
+ * **미리보기에만 붙인다.** 저장 형식에도, 저작면에도, 카드 그림에도 안 들어간다 — 미리보기는
132
+ * 크기 반응을 보는 작업대이고 거기서만 쓰는 표시다.
133
+ */
134
+
135
+ /** 미리보기에만 있는 이름. 저장 형식에는 없다. */
136
+ export const NEEDLE = '__needle-'
137
+
138
+ /**
139
+ * 막대의 굵기 — **길이에 견주어** 잡는다.
140
+ *
141
+ * 8mm 로 고정했더니 미리보기에서 안 보였다. 미리보기는 여덟 대를 한 화면에 세우는 자리라
142
+ * 100mm 짜리 부품이 화면에서 20 픽셀쯤이고, 그 위의 8mm 는 한 픽셀이 안 된다. 세어 보면
143
+ * 막대는 분명히 서 있는데(부품 둘짜리 표본의 인스턴스에 mesh 셋 · 삼각형 72) 눈에 안 띈다.
144
+ */
145
+ const needleThick = (radius: number) => Math.max(6, Math.round(radius / 5))
146
+
147
+ /** 축에서 비켜날 방향. 그 축과 직각이어야 도는 것이 보인다. */
148
+ const ASIDE: Record<string, 'x' | 'z'> = { x: 'z', y: 'x', z: 'x' }
149
+
150
+ export function jointNeedles(source: FigureSource): FigurePart[] {
151
+ const byName = new Map(source.parts.map(part => [part.name, part]))
152
+
153
+ return (source.joints ?? []).flatMap(joint => {
154
+ const axis = axisName(joint.axis)
155
+ if (!axis) return []
156
+
157
+ const child = byName.get(joint.child)
158
+ const size = child?.transform.size
159
+ const reach = size ? Math.max(size.x, size.y, size.z) : 0
160
+ const radius = Math.max(60, Math.round(reach * 0.6))
161
+ const aside = ASIDE[axis.slice(1)]!
162
+ const thick = needleThick(radius)
163
+
164
+ return [
165
+ {
166
+ name: `${NEEDLE}${joint.name}`,
167
+ primitive: 'cube' as const,
168
+ transform: {
169
+ position: { ...joint.origin, [aside]: joint.origin[aside] + radius / 2 },
170
+ size:
171
+ aside === 'x'
172
+ ? { x: radius, y: thick, z: thick }
173
+ : { x: thick, y: thick, z: radius }
174
+ },
175
+ material: { token: 'palette.accent' },
176
+ parent: joint.child
177
+ }
178
+ ]
179
+ })
180
+ }