@things-factory/figure-ui 10.1.43 → 10.1.45

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 (128) hide show
  1. package/client/modeller/figure-v3-links.ts +303 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +44 -3
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +138 -0
  6. package/client/pages/figure-v3-modeller-page.ts +2 -5
  7. package/client/route.ts +2 -6
  8. package/dist-client/modeller/figure-v3-links.d.ts +42 -0
  9. package/dist-client/modeller/figure-v3-links.js +306 -0
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -0
  11. package/dist-client/modeller/modeller-route.d.ts +4 -11
  12. package/dist-client/modeller/modeller-route.js +5 -22
  13. package/dist-client/modeller/modeller-route.js.map +1 -1
  14. package/dist-client/modeller/v3-editor.d.ts +5 -0
  15. package/dist-client/modeller/v3-editor.js +46 -2
  16. package/dist-client/modeller/v3-editor.js.map +1 -1
  17. package/dist-client/modeller/v3-gizmo.js +11 -1
  18. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  19. package/dist-client/modeller/v3-links.d.ts +54 -0
  20. package/dist-client/modeller/v3-links.js +110 -0
  21. package/dist-client/modeller/v3-links.js.map +1 -0
  22. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js +2 -5
  24. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/route.js +2 -5
  27. package/dist-client/route.js.map +1 -1
  28. package/dist-client/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +4 -4
  30. package/test/ai-proposal-contract.test.ts +0 -88
  31. package/test/figure-source.test.ts +0 -102
  32. package/test/i18n-prefix-guard.test.ts +0 -24
  33. package/test/modeller-route.test.ts +14 -25
  34. package/test/part-edits.test.ts +0 -7
  35. package/test/scene-budget.test.ts +0 -7
  36. package/test/v3-gizmo.test.ts +10 -0
  37. package/test/v3-links.test.ts +123 -0
  38. package/things-factory.config.js +2 -5
  39. package/translations/en.json +6 -0
  40. package/translations/ja.json +11 -1
  41. package/translations/ko.json +6 -0
  42. package/translations/ms.json +11 -1
  43. package/translations/zh.json +11 -1
  44. package/client/modeller/angle-dial.ts +0 -70
  45. package/client/modeller/base-box.ts +0 -175
  46. package/client/modeller/figure-animations.ts +0 -1249
  47. package/client/modeller/figure-canvas.ts +0 -567
  48. package/client/modeller/figure-capabilities.ts +0 -379
  49. package/client/modeller/figure-inspector.ts +0 -2208
  50. package/client/modeller/figure-parts.ts +0 -479
  51. package/client/modeller/figure-preview.ts +0 -598
  52. package/client/modeller/figure-report.ts +0 -966
  53. package/client/modeller/figure-settings.ts +0 -328
  54. package/client/modeller/figure-side.ts +0 -272
  55. package/client/modeller/figure-thumbnail.ts +0 -123
  56. package/client/modeller/figure-view.ts +0 -152
  57. package/client/modeller/joint-edits.ts +0 -262
  58. package/client/modeller/joint-gizmo.ts +0 -180
  59. package/client/modeller/maturity.ts +0 -288
  60. package/client/modeller/new-figure-entry.ts +0 -4
  61. package/client/modeller/pose-defaults.ts +0 -67
  62. package/client/pages/figure-modeller-page.ts +0 -2082
  63. package/dist-client/modeller/angle-dial.d.ts +0 -32
  64. package/dist-client/modeller/angle-dial.js +0 -41
  65. package/dist-client/modeller/angle-dial.js.map +0 -1
  66. package/dist-client/modeller/base-box.d.ts +0 -71
  67. package/dist-client/modeller/base-box.js +0 -110
  68. package/dist-client/modeller/base-box.js.map +0 -1
  69. package/dist-client/modeller/figure-animations.d.ts +0 -159
  70. package/dist-client/modeller/figure-animations.js +0 -1132
  71. package/dist-client/modeller/figure-animations.js.map +0 -1
  72. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  73. package/dist-client/modeller/figure-canvas.js +0 -506
  74. package/dist-client/modeller/figure-canvas.js.map +0 -1
  75. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  76. package/dist-client/modeller/figure-capabilities.js +0 -349
  77. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  78. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  79. package/dist-client/modeller/figure-inspector.js +0 -2095
  80. package/dist-client/modeller/figure-inspector.js.map +0 -1
  81. package/dist-client/modeller/figure-parts.d.ts +0 -64
  82. package/dist-client/modeller/figure-parts.js +0 -478
  83. package/dist-client/modeller/figure-parts.js.map +0 -1
  84. package/dist-client/modeller/figure-preview.d.ts +0 -138
  85. package/dist-client/modeller/figure-preview.js +0 -549
  86. package/dist-client/modeller/figure-preview.js.map +0 -1
  87. package/dist-client/modeller/figure-report.d.ts +0 -165
  88. package/dist-client/modeller/figure-report.js +0 -952
  89. package/dist-client/modeller/figure-report.js.map +0 -1
  90. package/dist-client/modeller/figure-settings.d.ts +0 -39
  91. package/dist-client/modeller/figure-settings.js +0 -317
  92. package/dist-client/modeller/figure-settings.js.map +0 -1
  93. package/dist-client/modeller/figure-side.d.ts +0 -78
  94. package/dist-client/modeller/figure-side.js +0 -317
  95. package/dist-client/modeller/figure-side.js.map +0 -1
  96. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  97. package/dist-client/modeller/figure-thumbnail.js +0 -95
  98. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  99. package/dist-client/modeller/figure-view.d.ts +0 -118
  100. package/dist-client/modeller/figure-view.js +0 -78
  101. package/dist-client/modeller/figure-view.js.map +0 -1
  102. package/dist-client/modeller/joint-edits.d.ts +0 -49
  103. package/dist-client/modeller/joint-edits.js +0 -223
  104. package/dist-client/modeller/joint-edits.js.map +0 -1
  105. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  106. package/dist-client/modeller/joint-gizmo.js +0 -158
  107. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  108. package/dist-client/modeller/maturity.d.ts +0 -55
  109. package/dist-client/modeller/maturity.js +0 -212
  110. package/dist-client/modeller/maturity.js.map +0 -1
  111. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  112. package/dist-client/modeller/new-figure-entry.js +0 -5
  113. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  114. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  115. package/dist-client/modeller/pose-defaults.js +0 -35
  116. package/dist-client/modeller/pose-defaults.js.map +0 -1
  117. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  118. package/dist-client/pages/figure-modeller-page.js +0 -2060
  119. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  120. package/test/angle-dial.test.ts +0 -78
  121. package/test/base-box.test.ts +0 -235
  122. package/test/joint-edits.test.ts +0 -165
  123. package/test/joint-gizmo.test.ts +0 -174
  124. package/test/joint-needle.test.ts +0 -162
  125. package/test/modeller-undo-redo.test.ts +0 -33
  126. package/test/new-figure-entry.test.ts +0 -15
  127. package/test/pose-defaults.test.ts +0 -71
  128. package/test/preview-board-depth.test.ts +0 -27
@@ -1,123 +0,0 @@
1
- import './figure-canvas.js'
2
-
3
- import type { FigureDraft, PartModel } from './figure-source.js'
4
-
5
- /**
6
- * 카드에 쓸 그림을 찍는다 — **숨은 자리에 저작 캔버스를 하나 더 세워서.**
7
- *
8
- * ## 왜 보이는 캔버스를 찍지 않나
9
- *
10
- * 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있고, 칸의 비율도 창 크기와 접힌 패널에 따라
11
- * 다르다. 그것을 찍으면 카드마다 프레임이 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이
12
- * 된다. 목록은 견주는 자리이므로 그림은 **정해진 크기와 정해진 카메라**로 찍는다.
13
- *
14
- * ## 왜 씬을 여기서 세우지 않고 부품을 세우나
15
- *
16
- * 씬 값(환경 · 하늘 · 조명 · 픽셀 비율 · 좌표 바탕)은 `figure-canvas` 가 든다. 여기서 다시 적으면
17
- * 두 벌이 되고, 그러면 **카드에 보이는 그림과 저작면에서 보던 것이 아무 오류 없이 갈린다.** 그래서
18
- * 같은 부품을 `viewOnly` 로 하나 더 세워 그 부품이 자기 씬을 세우게 하고, 찍은 뒤 버린다.
19
- *
20
- * ## 왜 서버가 아니라 브라우저인가
21
- *
22
- * 도면은 저장할 때 서버가 찍는데, 백엔드에 GPU 가 없는 배포가 많아 헤드리스로 3D 를 찍는 것이 자주
23
- * 실패한다. 도형을 그릴 GPU 는 이미 보는 사람 손에 있다.
24
- */
25
-
26
- /** 카드는 4:3 이다. */
27
- const WIDTH = 480
28
- const HEIGHT = 360
29
-
30
- interface Shooter extends HTMLElement {
31
- draft?: FigureDraft
32
- parts?: PartModel[]
33
- viewOnly?: boolean
34
- snapshot?: (width?: number, height?: number) => Promise<string | undefined>
35
- }
36
-
37
- /**
38
- * 씬이 서고 카메라가 자리를 잡을 틈 — 프레임 두 장 뒤에 한 번 더 쉰다. 씬에 그 신호가 없다.
39
- *
40
- * **rAF 만 기다리면 안 된다.** 탭이 뒤로 가거나 창이 가려지면 브라우저가 rAF 를 멈추고, 그러면
41
- * 이 약속이 영원히 안 풀린다. 그 위에 저장이 얹혀 있어서 저장이 통째로 멈췄다 — 단추는 잠긴 채로,
42
- * 아무 말도 없이. [확인함] 2026-09-10 포커스 없는 탭에서 `saving: true` 로 고정.
43
- *
44
- * 그래서 시계와 함께 경주시킨다. 늦게 깨어난 쪽은 버려지고, 화면이 안 그려지는 상황이면 아래
45
- * `snapshot` 이 빈 것을 돌려주거나 시간이 다 된다 — 어느 쪽이든 **저장은 계속 간다.**
46
- */
47
- function settle(): Promise<void> {
48
- const frames = new Promise<void>(resolve =>
49
- requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
50
- )
51
-
52
- return Promise.race([frames, delay(600)])
53
- }
54
-
55
- function delay(ms: number): Promise<void> {
56
- return new Promise(resolve => setTimeout(resolve, ms))
57
- }
58
-
59
- /**
60
- * 그림을 기다리는 시간의 한도.
61
- *
62
- * 3D 를 세워 한 장 찍는 데 걸리는 시간은 형상과 기계에 따라 다르지만, 이 한도의 목적은 빠르기가
63
- * 아니라 **저장이 멈추지 않는 것**이다. 넘으면 그림 없이 저장한다 — 정본이 그림보다 중하고,
64
- * 그림은 다음에 저작면을 열 때 다시 채운다(`figure-modeller-page` 의 fillThumbnail).
65
- */
66
- const PATIENCE = 5000
67
-
68
- /** 시간이 다 되면 없는 것으로 답한다. 던지지 않는다 — 그림이 없는 것은 실패가 아니다. */
69
- function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
70
- return Promise.race([work, delay(ms).then(() => undefined)])
71
- }
72
-
73
- export async function captureThumbnail(
74
- draft: FigureDraft | undefined,
75
- parts: PartModel[]
76
- ): Promise<string | undefined> {
77
- if (!draft || !parts.length) {
78
- return undefined
79
- }
80
-
81
- /*
82
- 화면 밖에 둔다. `display: none` 이면 캔버스가 크기를 못 얻어 아무것도 그리지 못한다.
83
- WebGL context 는 브라우저가 8~16개에서 끊으므로 찍은 뒤 반드시 버린다(아래 finally).
84
- */
85
- const host = document.createElement('div')
86
- host.setAttribute('figure-thumbnail-stage', '')
87
- host.style.cssText = `position: fixed; left: -10000px; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px; pointer-events: none;`
88
- document.body.appendChild(host)
89
-
90
- const shooter = document.createElement('figure-canvas') as Shooter
91
- shooter.viewOnly = true
92
- shooter.draft = draft
93
- shooter.parts = parts
94
- shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
95
-
96
- try {
97
- host.appendChild(shooter)
98
-
99
- /*
100
- **한도 안에 못 찍으면 그림 없이 간다.** 이 함수를 부르는 자리가 저장이고, 저장은 그림을
101
- 기다리다 멈춰서는 안 된다. 못 찍은 것은 조용히 넘기지 않고 콘솔에 남긴다 — 그림이 계속
102
- 안 생기면 그 줄이 단서가 된다.
103
- */
104
- const shot = await within(
105
- (async () => {
106
- await settle()
107
- return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
108
- })(),
109
- PATIENCE
110
- )
111
-
112
- if (!shot) {
113
- console.warn(`[thumbnail] ${PATIENCE}ms 안에 못 찍었습니다 — 그림 없이 저장합니다`)
114
- }
115
-
116
- return shot
117
- } catch (e) {
118
- console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
119
- return undefined
120
- } finally {
121
- host.remove()
122
- }
123
- }
@@ -1,152 +0,0 @@
1
- /**
2
- * 저작 화면의 **보기 설정**.
3
- *
4
- * ## 저장되지 않는다
5
- *
6
- * 이것은 자산이 아니라 **작업대**다. 조명을 밝게 하고 바닥을 켠 것은 그 사람이 지금
7
- * 보기 편한 상태일 뿐, 그 자산의 성질이 아니다. 그래서 `FigureSource` 에 안 들어간다 —
8
- * `toFigureSource` 는 판에서 `width` · `height` · `depth` 와 몇 가지만 집어 가고 나머지는
9
- * 버린다.
10
- *
11
- * 다른 사람이 같은 자산을 열면 자기 설정으로 본다. 그것이 맞다.
12
- *
13
- * ## 씬이 아는 이름을 그대로 쓴다
14
- *
15
- * 여기 이름은 전부 `model-layer` 의 상태 이름이다. 옮겨 적는 표를 두지 않는다 — 표를
16
- * 두면 씬이 새 설정을 받아도 이쪽이 모른다.
17
- *
18
- * 값이 바뀌면 씬이 스스로 반영한다(`three-capability` 의 `onchange`). 다만 `grid` 는
19
- * 한동안 3D 를 세울 때만 읽혔다 — things-scene 10.1.5 에서 고쳤다.
20
- */
21
- export interface ViewSettings {
22
- /**
23
- * 어떤 도면에 놓인 것으로 보나 — 씬 전체의 좌표 모드.
24
- *
25
- * **도형의 `placement` 와 다른 것이다.** 도형의 것은 「이 종류는 바닥에 서나, 천장에
26
- * 매달리나」이고, 이것은 「지금 보고 있는 도면이 어느 쪽을 기준으로 재나」다. 바닥
27
- * 기준 도면에 천정 기반 OHT 를 놓으면 도면은 안 뒤집히고 OHT 가 천장 높이에 매달린다.
28
- *
29
- * 한동안 도형의 `placement` 를 이 자리에 흘려 넣었다. 모델러에 도형이 하나뿐이라
30
- * 그럴듯해 보였지만, 그러면 도형의 성질이 씬 전체를 뒤집는다 — 도면에서는 성립할 수가
31
- * 없고(도면에는 도형이 여럿이다), 저작 중에는 배치를 고를 때마다 저작자가 적어 둔
32
- * 높이가 전부 다른 숫자로 보였다.
33
- *
34
- * 씬의 낱말 그대로다: `floor` 바닥 기준 · `space` 중심 기준(바닥 없음) · `inverted`
35
- * 천장 기준. **미리보기에만 먹인다** — 편집 캔버스의 `zPos` 는 저작자가 직접 적는
36
- * 숫자라 그 뜻이 보기 설정에 따라 바뀌면 안 된다.
37
- */
38
- placement: 'floor' | 'space' | 'inverted'
39
- /**
40
- * 천장 높이 (mm). 천정 기반 도형을 미리보기에서 어디에 매달 것인가.
41
- *
42
- * 도형은 제가 놓일 방의 높이를 모른다. 도면은 알지만 미리보기에는 도면이 없으므로,
43
- * 저작자가 가정할 값을 여기서 정한다.
44
- */
45
- ceilingHeight: number
46
- /** 좌표 바탕(축 가이드). 원점과 눈금을 보인다. */
47
- grid: boolean
48
- /** 축 이름표(X · Y · Z). 눈금만 두고 글자를 끌 수 있다. */
49
- gridLabels: boolean
50
- /** 판의 바닥면. 좌표 바탕과 겹치므로 기본은 끈다. */
51
- floor: boolean
52
- /** Environment — 반사(IBL). 금속 재질이 무엇을 비출지 정한다. */
53
- environment: EnvironmentName
54
- /** Sky Color — 배경색. */
55
- skyColor: string
56
- /** Key Light Intensity. */
57
- dirLightIntensity: number
58
- /** Key Light — Follow Camera. 끄면 그림자 방향이 고정된다. */
59
- dirLightFollowCamera: boolean
60
- /** Key Light — Cast Shadow. */
61
- dirShadowEnabled: boolean
62
- /** Hemisphere Light Intensity — 그늘진 면을 들어올린다. */
63
- hemiIntensity: number
64
- }
65
-
66
- /**
67
- * 환경 프리셋 — **`scene-manager` 의 `SkyPreset` 과 같은 여덟이다.**
68
- *
69
- * 한동안 다섯만 적었다. 씬은 여덟을 다 받는데 이쪽이 셋을 몰라서 고를 수 없었다.
70
- * operato-board 의 3D 씬 속성 패널도 같은 여덟을 보인다.
71
- */
72
- export const ENVIRONMENTS = [
73
- 'studio',
74
- 'warehouse',
75
- 'factory',
76
- 'office',
77
- 'home',
78
- 'sunny',
79
- 'cloudy',
80
- 'rainy'
81
- ] as const
82
- export type EnvironmentName = (typeof ENVIRONMENTS)[number]
83
-
84
- /**
85
- * 씬에 그대로 넣는 보기 설정 — **좌표 기준과 천장 높이는 빼고.**
86
- *
87
- * 나머지는 전부 `model-layer` 의 상태 이름이라 그대로 넣으면 씬이 알아서 반응한다.
88
- * 그런데 `placement` 는 편집 캔버스에 들어가면 안 되고(거기는 늘 상자 기준이다),
89
- * `ceilingHeight` 는 씬이 모르는 이름이다.
90
- *
91
- * **rest spread 로 넘기지 않으려고 이 함수가 있다.** 캔버스가 한동안
92
- * `const { grid, ..., ...light } = view` 로 나머지를 통째로 넘겼는데, 그러면 설정을
93
- * 하나 더할 때마다 그것이 두 화면에 **말없이** 실린다. 새로 더한 좌표 기준이 바로
94
- * 그 길로 캔버스에 들어갈 뻔했다.
95
- */
96
- export function sceneLook(view: ViewSettings): Record<string, unknown> {
97
- const { placement, ceilingHeight, ...look } = view
98
- return look
99
- }
100
-
101
- /**
102
- * 씬의 좌표 기준 — **씬의 낱말 그대로 셋이다.**
103
- *
104
- * 형식의 `floor` · `center` · `ceiling` 과 짝이 맞지만 철자가 다르다. 옮겨 적는 표를 두지
105
- * 않는 것이 이 파일의 규칙이므로 씬 쪽을 쓴다. 화면에는 둘 다 보인다 — 「천장 기준
106
- * (inverted)」.
107
- */
108
- export const SCENE_REFERENCES = ['floor', 'space', 'inverted'] as const
109
-
110
- /** 배경색 기본값. 회색이라 부품 색이 제 색으로 보인다. */
111
- export const WORKBENCH = '#7c7c7c'
112
-
113
- /**
114
- * 처음 값.
115
- *
116
- * **지금 저작 화면이 실제로 쓰던 값 그대로다.** 설정을 만들면서 화면이 달라지면
117
- * 무엇이 설정 때문이고 무엇이 원래 그랬는지 갈리지 않는다.
118
- */
119
- export const DEFAULT_VIEW: ViewSettings = {
120
- placement: 'floor',
121
- /* 공장 천장의 흔한 높이. 반도체 fab 의 OHT 레일이 대체로 이 언저리다. */
122
- ceilingHeight: 4000,
123
- grid: true,
124
- gridLabels: true,
125
- floor: false,
126
- environment: 'studio',
127
- skyColor: WORKBENCH,
128
- dirLightIntensity: 2.0,
129
- dirLightFollowCamera: true,
130
- dirShadowEnabled: true,
131
- hemiIntensity: 4.0
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
- }
@@ -1,262 +0,0 @@
1
- /*
2
- * Copyright © HatioLab Inc. All rights reserved.
3
- */
4
-
5
- import type { FigureJoint, FigurePart, JointType, Vec3 } from '@hatiolab/figure-model'
6
-
7
- import type { FigureDraft, PartModel } from './figure-source.js'
8
-
9
- /**
10
- * Editing joints and parents (ADR-0066). Pure functions over the draft and the part list, so the inspector
11
- * only renders and dispatches.
12
- *
13
- * A joint is always created together with the parameter that drives it. Values reach a joint only through
14
- * `parameters`, and the preview draws a slider per parameter, so a joint without one could be defined but
15
- * never seen moving.
16
- */
17
-
18
- type Parameter = NonNullable<FigureDraft['parameters']>[number]
19
-
20
- /** The limits a new joint starts with. Revolute in degrees, prismatic in millimetres. */
21
- const NEW_LIMITS: Record<Exclude<JointType, 'continuous'>, { min: number; max: number }> = {
22
- revolute: { min: -90, max: 90 },
23
- prismatic: { min: 0, max: 500 }
24
- }
25
-
26
- /** The span a parameter driving a continuous joint offers: one turn either way. */
27
- const CONTINUOUS_SPAN = { min: -360, max: 360 }
28
-
29
- /** The joint whose child is this part, if any. */
30
- export function jointOf(draft: FigureDraft | undefined, part: string): FigureJoint | undefined {
31
- return draft?.joints?.find(joint => joint.child === part)
32
- }
33
-
34
- /**
35
- * Parts a part may be attached to: every other part except itself and anything attached below it, since
36
- * either would close a loop the format refuses (`parent-cycle`).
37
- */
38
- export function parentChoices(parts: readonly PartModel[], part: string): string[] {
39
- const below = new Set<string>([part])
40
- let grew = true
41
- while (grew) {
42
- grew = false
43
- for (const one of parts) {
44
- if (one.parent !== undefined && below.has(one.parent) && !below.has(one.name)) {
45
- below.add(one.name)
46
- grew = true
47
- }
48
- }
49
- }
50
- return parts.map(one => one.name).filter(name => !!name && !below.has(name))
51
- }
52
-
53
- type Span = { min: number; max: number }
54
-
55
- /** 한 축에서 부품이 차지하는 구간. */
56
- const reachOf = (part: FigurePart, axis: 'x' | 'y' | 'z'): Span => ({
57
- min: part.transform.position[axis] - part.transform.size[axis] / 2,
58
- max: part.transform.position[axis] + part.transform.size[axis] / 2
59
- })
60
-
61
- /**
62
- * 새 관절이 설 자리 — **부품이 붙은 부품과 맞닿는 면의 한가운데.**
63
- *
64
- * 경첩은 두 부품이 닿는 자리에 있다. 팔꿈치는 윗팔과 앞팔이 만나는 곳이고, 문은 문틀에 닿는
65
- * 모서리에 있다. 그래서 처음 값은 그 자리여야 한다.
66
- *
67
- * 한동안 **부품 아랫면 한가운데**를 썼다. 위로 쌓는 부품에는 맞는 자리인데(로봇 팔이 그렇다),
68
- * 옆에 달린 부품에는 틀린다 — 캐비닛 옆에 달린 문의 축이 문 바닥에 생긴다. 저작자는 그것을
69
- * 화면에서 보고 손으로 옮겨야 했고, 어디로 옮길지는 두 부품의 치수를 머릿속에서 빼야 나왔다.
70
- *
71
- * **가장 벌어진 축이 맞닿는 축이다.** 두 상자가 겹치는 축에서는 뺀 값이 음수이고, 마주 보는
72
- * 축에서만 0 이나 양수가 된다. 나머지 두 축은 겹치는 구간의 한가운데를 쓴다 — 문의 축은 문
73
- * 높이의 한가운데에 있지 위아래 끝에 있지 않다.
74
- *
75
- * 붙은 부품이 없으면(Figure 틀에 직접 붙는다) 아랫면 한가운데다. 그때 맞닿는 것은 바닥이다.
76
- *
77
- * ⚠ 부품의 제 회전은 보지 않는다. 비스듬히 세운 부품에서는 축에 나란한 상자로 셈한 자리가
78
- * 나오고, 그것은 **처음 값**이다 — 저작자가 옮길 수 있고, 화면의 부채꼴이 어디인지 보여 준다.
79
- */
80
- export function seamOrigin(part: FigurePart, parent?: FigurePart): Vec3 {
81
- const { position, size } = part.transform
82
- if (!parent) return { x: position.x, y: position.y - size.y / 2, z: position.z }
83
-
84
- const axes = ['x', 'y', 'z'] as const
85
- const gaps = axes.map(axis => {
86
- const mine = reachOf(part, axis)
87
- const theirs = reachOf(parent, axis)
88
- return { axis, gap: Math.max(mine.min - theirs.max, theirs.min - mine.max), mine, theirs }
89
- })
90
-
91
- const meeting = gaps.reduce((widest, one) => (one.gap > widest.gap ? one : widest))
92
- const origin = { x: position.x, y: position.y, z: position.z }
93
-
94
- for (const { axis, mine, theirs } of gaps) {
95
- if (axis === meeting.axis) {
96
- /* 맞닿는 면은 **내 면**이다. 부모 쪽 면을 쓰면 둘 사이가 벌어져 있을 때 축이 허공에 선다. */
97
- origin[axis] = mine.min >= theirs.max ? mine.min : mine.max
98
- continue
99
- }
100
- const from = Math.max(mine.min, theirs.min)
101
- const to = Math.min(mine.max, theirs.max)
102
- origin[axis] = from <= to ? (from + to) / 2 : position[axis]
103
- }
104
-
105
- return { x: round4(origin.x), y: round4(origin.y), z: round4(origin.z) }
106
- }
107
-
108
- const round4 = (value: number) => Math.round(value * 10000) / 10000
109
-
110
- function freeName(taken: Iterable<string>, stem: string): string {
111
- const used = new Set(taken)
112
- if (!used.has(stem)) return stem
113
- for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
114
- }
115
-
116
- /** The span the driving parameter offers for a joint: its limits, or one turn either way when it has none. */
117
- function spanOf(joint: FigureJoint): { min: number; max: number } {
118
- return joint.limits ?? CONTINUOUS_SPAN
119
- }
120
-
121
- function drivingParameter(joint: FigureJoint): Parameter {
122
- const span = spanOf(joint)
123
- return {
124
- name: joint.name,
125
- range: { unit: joint.type === 'prismatic' ? 'mm' : 'deg', min: span.min, max: span.max },
126
- default: 0,
127
- clip: {
128
- channels: [
129
- {
130
- target: joint.name,
131
- keys: [
132
- { at: 0, value: span.min },
133
- { at: 1, value: span.max }
134
- ]
135
- }
136
- ]
137
- }
138
- }
139
- }
140
-
141
- /**
142
- * Whether a parameter is the plain one `setJointType` made for a joint: one channel on that joint, keys at
143
- * 0 and 1 spanning the given limits. Only such a parameter follows the joint when its limits or type change;
144
- * one the author reshaped is left as written.
145
- */
146
- function isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: number; max: number }): boolean {
147
- const channels = parameter.clip?.channels ?? []
148
- if (channels.length !== 1) return false
149
- const [channel] = channels
150
- if (channel!.target !== joint.name || channel!.path !== undefined) return false
151
- const keys = channel!.keys as { at: number; value: number }[]
152
- return (
153
- keys.length === 2 &&
154
- keys[0]!.at === 0 &&
155
- keys[1]!.at === 1 &&
156
- keys[0]!.value === span.min &&
157
- keys[1]!.value === span.max &&
158
- parameter.range?.min === span.min &&
159
- parameter.range?.max === span.max
160
- )
161
- }
162
-
163
- /**
164
- * Sets how a part moves relative to its parent, or clears the joint with `undefined`.
165
- *
166
- * - From none: adds a joint at the seam with the parent (`seamOrigin`), with a vertical axis and starting
167
- * limits, and a parameter that drives it over those limits.
168
- * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel
169
- * is removed too, because the format refuses an empty one.
170
- * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and
171
- * moves a plain driving parameter along with them.
172
- */
173
- export function setJointType(
174
- draft: FigureDraft,
175
- part: FigurePart,
176
- type: JointType | undefined,
177
- parent?: FigurePart
178
- ): FigureDraft {
179
- const joints = draft.joints ?? []
180
- const existing = joints.find(joint => joint.child === part.name)
181
-
182
- if (type === undefined) {
183
- if (!existing) return draft
184
- return withoutJoint(draft, existing.name)
185
- }
186
-
187
- if (!existing) {
188
- const joint: FigureJoint = {
189
- name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),
190
- child: part.name,
191
- type,
192
- origin: seamOrigin(part, parent),
193
- axis: { x: 0, y: 1, z: 0 }
194
- }
195
- if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }
196
-
197
- const parameters = draft.parameters ?? []
198
- const taken = [...parameters.map(one => one.name), ...(draft.animations ?? []).map(one => one.name)]
199
- const driver = drivingParameter(joint)
200
- driver.name = freeName(taken, joint.name)
201
-
202
- return { ...draft, joints: [...joints, joint], parameters: [...parameters, driver] }
203
- }
204
-
205
- if (existing.type === type) return draft
206
-
207
- const next: FigureJoint = { ...existing, type }
208
- if (type === 'continuous') delete next.limits
209
- else if (existing.type === 'continuous' || (existing.type === 'prismatic') !== (type === 'prismatic')) {
210
- next.limits = { ...NEW_LIMITS[type] }
211
- }
212
-
213
- return replaceJoint(draft, existing, next)
214
- }
215
-
216
- /** Changes a joint's origin, axis or limits. A plain driving parameter follows new limits. */
217
- export function patchJoint(
218
- draft: FigureDraft,
219
- name: string,
220
- patch: { origin?: Vec3; axis?: Vec3; limits?: { min: number; max: number } }
221
- ): FigureDraft {
222
- const existing = draft.joints?.find(joint => joint.name === name)
223
- if (!existing) return draft
224
- const next: FigureJoint = { ...existing, ...patch }
225
- if (existing.type === 'continuous') delete next.limits
226
- return replaceJoint(draft, existing, next)
227
- }
228
-
229
- function replaceJoint(draft: FigureDraft, before: FigureJoint, after: FigureJoint): FigureDraft {
230
- const joints = (draft.joints ?? []).map(joint => (joint === before ? after : joint))
231
- const was = spanOf(before)
232
- const unitChanged = (before.type === 'prismatic') !== (after.type === 'prismatic')
233
- const spanChanged = was.min !== spanOf(after).min || was.max !== spanOf(after).max
234
-
235
- if (!unitChanged && !spanChanged) return { ...draft, joints }
236
-
237
- const parameters = (draft.parameters ?? []).map(parameter => {
238
- if (!isPlainDriver(parameter, before, was)) return parameter
239
- const driver = drivingParameter(after)
240
- return { ...parameter, range: driver.range, clip: { ...parameter.clip, channels: driver.clip.channels } }
241
- })
242
- return { ...draft, joints, parameters }
243
- }
244
-
245
- function withoutJoint(draft: FigureDraft, name: string): FigureDraft {
246
- const keep = <T extends { channels?: { target: string }[] }>(one: T) => (one.channels ?? []).filter(channel => channel.target !== name)
247
-
248
- const parameters = (draft.parameters ?? [])
249
- .map(parameter => ({ ...parameter, clip: { ...parameter.clip, channels: keep(parameter.clip) } }) as Parameter)
250
- .filter(parameter => parameter.clip.channels.length > 0)
251
- const animations = (draft.animations ?? [])
252
- .map(clip => ({ ...clip, channels: keep(clip) }) as NonNullable<FigureDraft['animations']>[number])
253
- .filter(clip => clip.channels.length > 0)
254
- const joints = (draft.joints ?? []).filter(joint => joint.name !== name)
255
-
256
- const next: FigureDraft = { ...draft }
257
- if (joints.length > 0) next.joints = joints
258
- else delete next.joints
259
- if (draft.parameters !== undefined) next.parameters = parameters
260
- if (draft.animations !== undefined) next.animations = animations
261
- return next
262
- }