@things-factory/figure-ui 10.1.28 → 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 (55) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-animations.ts +9 -9
  3. package/client/modeller/figure-canvas.ts +6 -3
  4. package/client/modeller/figure-inspector.ts +18 -3
  5. package/client/modeller/figure-preview.ts +55 -3
  6. package/client/modeller/figure-report.ts +11 -28
  7. package/client/modeller/figure-side.ts +1 -0
  8. package/client/modeller/joint-gizmo.ts +63 -1
  9. package/client/modeller/part-edits.ts +75 -9
  10. package/client/pages/figure-modeller-page.ts +20 -2
  11. package/client/types.ts +2 -0
  12. package/dist-client/modeller/angle-dial.d.ts +32 -0
  13. package/dist-client/modeller/angle-dial.js +41 -0
  14. package/dist-client/modeller/angle-dial.js.map +1 -0
  15. package/dist-client/modeller/figure-animations.d.ts +4 -4
  16. package/dist-client/modeller/figure-animations.js +9 -9
  17. package/dist-client/modeller/figure-animations.js.map +1 -1
  18. package/dist-client/modeller/figure-canvas.js +6 -3
  19. package/dist-client/modeller/figure-canvas.js.map +1 -1
  20. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  21. package/dist-client/modeller/figure-inspector.js +20 -3
  22. package/dist-client/modeller/figure-inspector.js.map +1 -1
  23. package/dist-client/modeller/figure-preview.d.ts +11 -0
  24. package/dist-client/modeller/figure-preview.js +53 -3
  25. package/dist-client/modeller/figure-preview.js.map +1 -1
  26. package/dist-client/modeller/figure-report.d.ts +8 -4
  27. package/dist-client/modeller/figure-report.js +11 -31
  28. package/dist-client/modeller/figure-report.js.map +1 -1
  29. package/dist-client/modeller/figure-side.js +1 -0
  30. package/dist-client/modeller/figure-side.js.map +1 -1
  31. package/dist-client/modeller/joint-gizmo.d.ts +4 -1
  32. package/dist-client/modeller/joint-gizmo.js +54 -0
  33. package/dist-client/modeller/joint-gizmo.js.map +1 -1
  34. package/dist-client/modeller/part-edits.d.ts +29 -4
  35. package/dist-client/modeller/part-edits.js +61 -9
  36. package/dist-client/modeller/part-edits.js.map +1 -1
  37. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  38. package/dist-client/pages/figure-modeller-page.js +20 -2
  39. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  40. package/dist-client/tsconfig.tsbuildinfo +1 -1
  41. package/dist-client/types.d.ts +2 -0
  42. package/dist-client/types.js.map +1 -1
  43. package/package.json +5 -5
  44. package/test/angle-dial.test.ts +78 -0
  45. package/test/base-box.test.ts +11 -0
  46. package/test/expect.ts +2 -0
  47. package/test/i18n-prefix-guard.test.ts +17 -0
  48. package/test/joint-needle.test.ts +162 -0
  49. package/test/part-edits.test.ts +100 -0
  50. package/test/scene-budget.test.ts +29 -0
  51. package/translations/en.json +3 -7
  52. package/translations/ja.json +3 -7
  53. package/translations/ko.json +3 -7
  54. package/translations/ms.json +3 -7
  55. package/translations/zh.json +3 -7
@@ -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
 
@@ -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
+ }
@@ -27,6 +27,13 @@ function freeName(taken: Set<string>, base: string): string {
27
27
  }
28
28
  }
29
29
 
30
+ const round = (value: number) => Math.round(value)
31
+
32
+ /** 값을 두 끝 사이로 당긴다. 끝이 뒤집혀 있으면(부품이 상자보다 크다) 앞의 끝을 따른다. */
33
+ function clamp(value: number, least: number, most: number): number {
34
+ return Math.max(least, Math.min(most, value))
35
+ }
36
+
30
37
  /** 이 초안에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
31
38
  export function partLimit(draft: FigureDraft): number {
32
39
  return PART_LIMIT[(draft.detailLevel ?? 'M') as DetailLevel]
@@ -35,26 +42,58 @@ export function partLimit(draft: FigureDraft): number {
35
42
  /**
36
43
  * 새 부품 하나를 만든다.
37
44
  *
38
- * 기준 상자의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
39
- * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
45
+ * 기준 상자의 3분의 1 크기다. 추가하자마자 화면에 보여야 한다 — 크기 0 으로 놓으면
46
+ * 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
47
+ *
48
+ * ## 어디에 놓나 — 고른 부품 **위**다
49
+ *
50
+ * 전에는 언제나 바닥 한가운데였다. 첫 부품은 그 자리가 맞지만 둘째부터는 **첫 부품 속에**
51
+ * 들어간다 — 같은 자리에 같은 크기로 놓이니 화면에는 덩어리 하나뿐이고, 저작자는 무엇이
52
+ * 늘었는지 못 본다. 「추가하자마자 보여야 한다」는 그때 이미 깨져 있었다.
53
+ *
54
+ * 그래서 고른 부품이 있으면 그 부품의 **윗면**에 얹고 발자국을 가운데로 맞춘다. 기계는
55
+ * 대개 그렇게 쌓인다(받침 → 어깨 → 팔). 관절을 걸 때도 이 자리가 필요하다 — 맞닿는 면이
56
+ * 실제로 있어야 `seamOrigin` 이 회전 중심을 그 면에서 잡는다.
57
+ *
58
+ * 위에 얹으면 기준 상자를 넘는 경우에는 바닥 한가운데로 간다. 넘은 채로 놓으면 발행이
59
+ * 막히고(`part-outside-base`), 그것을 고치는 일이 「부품을 추가했다」의 대가가 된다.
60
+ * 가로·세로는 상자 안으로 당긴다 — 고른 부품이 가장자리에 있으면 그 위가 상자 밖이다.
40
61
  */
41
- export function newPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel {
62
+ export function newPart(
63
+ draft: FigureDraft,
64
+ parts: PartModel[],
65
+ primitive: PrimitiveKind,
66
+ onTopOf?: PartModel
67
+ ): PartModel {
42
68
  const taken = new Set(parts.map(part => part.name))
43
69
 
44
70
  const width = Math.max(1, Math.round(draft.width / 3))
45
71
  const height = Math.max(1, Math.round(draft.height / 3))
46
72
  const depth = Math.max(1, Math.round(draft.depth / 3))
47
73
 
74
+ // 바닥 한가운데 — 고를 부품이 없을 때, 그리고 위가 상자 밖일 때의 자리.
75
+ const middle = {
76
+ left: Math.round((draft.width - width) / 2),
77
+ top: Math.round((draft.height - height) / 2),
78
+ // `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
79
+ zPos: 0
80
+ }
81
+ const above = onTopOf && {
82
+ left: clamp(Math.round(onTopOf.left + onTopOf.width / 2 - width / 2), 0, draft.width - width),
83
+ top: clamp(Math.round(onTopOf.top + onTopOf.height / 2 - height / 2), 0, draft.height - height),
84
+ zPos: Math.round(onTopOf.zPos + onTopOf.depth)
85
+ }
86
+ const at = above && above.zPos + depth <= draft.depth ? above : middle
87
+
48
88
  const part: PartModel = {
49
89
  type: 'figure-part',
50
90
  name: freeName(taken, primitive),
51
91
  primitive,
52
- left: Math.round((draft.width - width) / 2),
53
- top: Math.round((draft.height - height) / 2),
92
+ left: at.left,
93
+ top: at.top,
54
94
  width,
55
95
  height,
56
- // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
57
- zPos: 0,
96
+ zPos: at.zPos,
58
97
  depth,
59
98
  // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
60
99
  token: parts[0]?.token ?? 'palette.primary'
@@ -79,8 +118,35 @@ export function newPart(draft: FigureDraft, parts: PartModel[], primitive: Primi
79
118
  return part
80
119
  }
81
120
 
82
- export function addPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel[] {
83
- return [...parts, newPart(draft, parts, primitive)]
121
+ /**
122
+ * 씬에서 부품을 밀리미터로 맞춘다.
123
+ *
124
+ * 기즈모는 비율을 곱해서 크기를 낸다 — 420 에 1.72 를 곱하면 722.4180046035418 이고, 손대지
125
+ * 않은 축도 384.99999999999994 처럼 흔들린다. 저작자가 끈 결과가 소수 넷째 자리까지 남을
126
+ * 까닭이 없고, 칸에는 「722.4…」로 잘려 보인다. 이 편집기의 다른 길은 전부 정수 밀리미터로
127
+ * 맞춘다(부품을 새로 놓을 때 · 가운데 맞추기 · 격자 맞추기).
128
+ *
129
+ * 크기는 1 밑으로 내려가지 않는다 — 0 이면 부피가 사라지고 배율이 0 으로 나뉜다.
130
+ */
131
+ export function roundPart(part: PartModel): PartModel {
132
+ return {
133
+ ...part,
134
+ left: round(part.left),
135
+ top: round(part.top),
136
+ zPos: round(part.zPos),
137
+ width: Math.max(1, round(part.width)),
138
+ height: Math.max(1, round(part.height)),
139
+ depth: Math.max(1, round(part.depth))
140
+ }
141
+ }
142
+
143
+ export function addPart(
144
+ draft: FigureDraft,
145
+ parts: PartModel[],
146
+ primitive: PrimitiveKind,
147
+ onTopOf?: PartModel
148
+ ): PartModel[] {
149
+ return [...parts, newPart(draft, parts, primitive, onTopOf)]
84
150
  }
85
151
 
86
152
  export function removePart(parts: PartModel[], index: number): PartModel[] {
@@ -1510,8 +1510,15 @@ export class FigureModellerPage extends FigureModellerPageBase {
1510
1510
  this.dirty = true
1511
1511
  }
1512
1512
 
1513
+ /**
1514
+ * 타입을 고친다 — **발행 전까지만**(ADR-0063 결정 4).
1515
+ *
1516
+ * 전에는 저장된 도형이면 무조건 돌아섰다. 그래서 기계가 지은 이름이 그대로 굳었다. 발행된
1517
+ * 뒤에는 문이 거절하므로 여기서도 막는다 — 눌러서 되는 것처럼 보이고 저장에서 실패하면
1518
+ * 저작자가 무엇을 잘못했는지 못 읽는다.
1519
+ */
1513
1520
  private typeChanged(type: string) {
1514
- if (!type || this.figure?.id) return
1521
+ if (!type || (this.figure?.version ?? 0) > 0) return
1515
1522
  const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
1516
1523
  if (this.draft) this.draft.figureType = sanitized
1517
1524
  this.figure = { ...this.figure, type: sanitized } as Figure
@@ -1595,8 +1602,13 @@ export class FigureModellerPage extends FigureModellerPageBase {
1595
1602
 
1596
1603
  private addPart(primitive: PrimitiveKind) {
1597
1604
  if (!this.draft) return
1605
+ /*
1606
+ 고른 부품 위에 얹는다 — 그 자리에서 보이고, 관절을 걸면 맞닿는 면이 회전 중심이 된다.
1607
+ 고른 것이 없으면 `newPart` 가 바닥 한가운데로 놓는다.
1608
+ */
1609
+ const onTopOf = this.parts[this.selected]
1598
1610
  // 추가한 것을 바로 수정할 수 있게 선택 상태로 둔다.
1599
- this.put(edits.addPart(this.draft, this.parts, primitive), this.parts.length)
1611
+ this.put(edits.addPart(this.draft, this.parts, primitive, onTopOf), this.parts.length)
1600
1612
  }
1601
1613
 
1602
1614
  private removePart(index: number) {
@@ -1886,6 +1898,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
1886
1898
  const thumbnail = await captureThumbnail(this.draft, this.parts)
1887
1899
 
1888
1900
  const result = await updateFigure(this.figure.id, {
1901
+ /*
1902
+ 타입을 함께 보낸다. 정본 안의 `type` 만 고치고 행을 안 고치면 저장된 JSON 과 행이
1903
+ 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다. 발행된 뒤라면
1904
+ 문이 거절하고 그 사유가 화면에 그대로 뜬다.
1905
+ */
1906
+ type: this.figure.type,
1889
1907
  name: this.figure.name,
1890
1908
  category: this.figure.category,
1891
1909
  tags: this.figure.tags,
package/client/types.ts CHANGED
@@ -80,6 +80,8 @@ export interface NewFigure {
80
80
  * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.
81
81
  */
82
82
  export interface FigurePatch {
83
+ /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */
84
+ type?: string
83
85
  name?: string
84
86
  description?: string
85
87
  category?: string
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 막대 옆에 서는 **각도 눈**. 지금 각이 범위의 어디인지를 한 눈에 보인다.
3
+ *
4
+ * ## 왜 3D 로는 모자랐나
5
+ *
6
+ * 관절이 돌아도 화면이 그대로인 경우가 있다 — 제 축에서 대칭인 부품이 그렇다. 그래서 관절이
7
+ * 모는 부품에 바늘을 달아 미리보기에서 함께 돌게 했는데(`joint-gizmo.ts` 의 `jointNeedles`),
8
+ * **미리보기에서는 그 바늘이 4 × 2 픽셀이다**(잰 값: 부품 100mm · 바늘 60 × 12mm · 여덟 대를
9
+ * 한 화면에 세운 상태). 미리보기는 크기 반응을 보려고 여덟 대를 나란히 놓는 자리라 한 대가
10
+ * 20 픽셀쯤이고, 그 위의 어떤 표시도 몇 픽셀을 넘지 못한다.
11
+ *
12
+ * 각을 읽는 일은 3D 의 배율에서 떼어 놓는다. 눈은 막대 옆 24 픽셀 자리에 서고, 도형이 아무리
13
+ * 작게 서 있어도 같은 크기로 읽힌다.
14
+ */
15
+ export interface DialShape {
16
+ /** 가동 범위를 채운 부채꼴. SVG path 의 `d`. */
17
+ sector: string;
18
+ /** 지금 값이 가리키는 끝점. */
19
+ needle: {
20
+ x: number;
21
+ y: number;
22
+ };
23
+ centre: {
24
+ x: number;
25
+ y: number;
26
+ };
27
+ radius: number;
28
+ }
29
+ /**
30
+ * 눈의 모양. 값이 범위를 벗어나면 범위 끝에 세운다 — 밖을 가리키는 바늘은 없는 값을 말한다.
31
+ */
32
+ export declare function dialShape(min: number, max: number, value: number, radius?: number): DialShape;