@fmsim/machine 1.0.88 → 2.0.0-beta.100

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 (165) hide show
  1. package/dist/agv-line.js.map +1 -1
  2. package/dist/agv.js +6 -2
  3. package/dist/agv.js.map +1 -1
  4. package/dist/buffer.js.map +1 -1
  5. package/dist/carrier-manager.js +183 -0
  6. package/dist/carrier-manager.js.map +1 -0
  7. package/dist/carrier.js +105 -14
  8. package/dist/carrier.js.map +1 -1
  9. package/dist/conveyor-join.js +8 -2
  10. package/dist/conveyor-join.js.map +1 -1
  11. package/dist/conveyor.js +9 -1
  12. package/dist/conveyor.js.map +1 -1
  13. package/dist/crane-rail.js +53 -0
  14. package/dist/crane-rail.js.map +1 -0
  15. package/dist/crane.js +144 -1
  16. package/dist/crane.js.map +1 -1
  17. package/dist/equipment.js.map +1 -1
  18. package/dist/factories/agv-3d.js +147 -0
  19. package/dist/factories/agv-3d.js.map +1 -0
  20. package/dist/factories/conveyor-3d.js +345 -0
  21. package/dist/factories/conveyor-3d.js.map +1 -0
  22. package/dist/factories/crane-3d.js +186 -0
  23. package/dist/factories/crane-3d.js.map +1 -0
  24. package/dist/factories/crane-rail-3d.js +95 -0
  25. package/dist/factories/crane-rail-3d.js.map +1 -0
  26. package/dist/factories/machine-3d.js +973 -0
  27. package/dist/factories/machine-3d.js.map +1 -0
  28. package/dist/factories/oht-3d.js +177 -0
  29. package/dist/factories/oht-3d.js.map +1 -0
  30. package/dist/factories/shuttle-3d.js +159 -0
  31. package/dist/factories/shuttle-3d.js.map +1 -0
  32. package/dist/factories/z-priority-defaults.js +33 -0
  33. package/dist/factories/z-priority-defaults.js.map +1 -0
  34. package/dist/factories/z-priority.js +54 -0
  35. package/dist/factories/z-priority.js.map +1 -0
  36. package/dist/features/conveyor-mixin.js.map +1 -1
  37. package/dist/features/mcs-status-default.js +15 -1
  38. package/dist/features/mcs-status-default.js.map +1 -1
  39. package/dist/features/mcs-status-mixin.js +22 -0
  40. package/dist/features/mcs-status-mixin.js.map +1 -1
  41. package/dist/features/mcs-zone-mixin.js.map +1 -1
  42. package/dist/features/parent-object-mixin.js +15 -7
  43. package/dist/features/parent-object-mixin.js.map +1 -1
  44. package/dist/floating-container.js +5 -0
  45. package/dist/floating-container.js.map +1 -1
  46. package/dist/index.js +58 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/mcs-carrier-holder.js +56 -17
  49. package/dist/mcs-carrier-holder.js.map +1 -1
  50. package/dist/mcs-machine.js +39 -0
  51. package/dist/mcs-machine.js.map +1 -1
  52. package/dist/mcs-unit.js +20 -0
  53. package/dist/mcs-unit.js.map +1 -1
  54. package/dist/mcs-vehicle.js +10 -4
  55. package/dist/mcs-vehicle.js.map +1 -1
  56. package/dist/node.js +10 -3
  57. package/dist/node.js.map +1 -1
  58. package/dist/oht-line.js.map +1 -1
  59. package/dist/oht.js +4 -0
  60. package/dist/oht.js.map +1 -1
  61. package/dist/port-flow.js +4 -0
  62. package/dist/port-flow.js.map +1 -1
  63. package/dist/port.js +4 -0
  64. package/dist/port.js.map +1 -1
  65. package/dist/scanner.js +30 -0
  66. package/dist/scanner.js.map +1 -0
  67. package/dist/scene/root-container-override.js +160 -96
  68. package/dist/scene/root-container-override.js.map +1 -1
  69. package/dist/shelf.js +4 -0
  70. package/dist/shelf.js.map +1 -1
  71. package/dist/shuttle.js.map +1 -1
  72. package/dist/static-container.js +5 -0
  73. package/dist/static-container.js.map +1 -1
  74. package/dist/stocker-abnormal-bar.js +184 -0
  75. package/dist/stocker-abnormal-bar.js.map +1 -0
  76. package/dist/stocker-capacity-bar.js +253 -6
  77. package/dist/stocker-capacity-bar.js.map +1 -1
  78. package/dist/stocker-carrier-bar.js +198 -0
  79. package/dist/stocker-carrier-bar.js.map +1 -0
  80. package/dist/stocker.js +7 -1
  81. package/dist/stocker.js.map +1 -1
  82. package/dist/templates/backdrop.js +30 -0
  83. package/dist/templates/backdrop.js.map +1 -0
  84. package/dist/templates/decal.js +21 -0
  85. package/dist/templates/decal.js.map +1 -0
  86. package/dist/templates/index.js +7 -1
  87. package/dist/templates/index.js.map +1 -1
  88. package/dist/templates/scanner.js +20 -0
  89. package/dist/templates/scanner.js.map +1 -0
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/dist/utils/crane-position.js +83 -0
  92. package/dist/utils/crane-position.js.map +1 -0
  93. package/dist/utils/data-value.js +21 -0
  94. package/dist/utils/data-value.js.map +1 -0
  95. package/dist/utils/fit-gauge-scale.js +28 -0
  96. package/dist/utils/fit-gauge-scale.js.map +1 -0
  97. package/dist/utils/get-value-on-ranges.js +2 -4
  98. package/dist/utils/get-value-on-ranges.js.map +1 -1
  99. package/dist/utils/stocker-gauge.js +78 -0
  100. package/dist/utils/stocker-gauge.js.map +1 -0
  101. package/dist/zone-capacity-bar.js +3 -3
  102. package/dist/zone-capacity-bar.js.map +1 -1
  103. package/icons/backdrop.png +0 -0
  104. package/icons/decal.png +0 -0
  105. package/jest.config.js +10 -0
  106. package/package.json +7 -6
  107. package/src/agv-line.ts +1 -1
  108. package/src/agv.ts +10 -5
  109. package/src/buffer.ts +1 -1
  110. package/src/carrier-manager.ts +223 -0
  111. package/src/carrier.ts +111 -18
  112. package/src/conveyor-join.ts +6 -3
  113. package/src/conveyor.ts +10 -2
  114. package/src/crane-rail.ts +58 -0
  115. package/src/crane.ts +182 -2
  116. package/src/equipment.ts +1 -1
  117. package/src/factories/agv-3d.ts +160 -0
  118. package/src/factories/conveyor-3d.ts +398 -0
  119. package/src/factories/crane-3d.ts +217 -0
  120. package/src/factories/crane-rail-3d.ts +115 -0
  121. package/src/factories/machine-3d.ts +1192 -0
  122. package/src/factories/oht-3d.ts +196 -0
  123. package/src/factories/shuttle-3d.ts +179 -0
  124. package/src/factories/z-priority-defaults.ts +38 -0
  125. package/src/factories/z-priority.ts +60 -0
  126. package/src/features/conveyor-mixin.ts +1 -1
  127. package/src/features/mcs-status-default.ts +15 -1
  128. package/src/features/mcs-status-mixin.ts +26 -4
  129. package/src/features/mcs-zone-mixin.ts +1 -1
  130. package/src/features/parent-object-mixin.ts +17 -8
  131. package/src/floating-container.ts +5 -0
  132. package/src/index.ts +66 -2
  133. package/src/mcs-carrier-holder.ts +58 -29
  134. package/src/mcs-machine.ts +43 -1
  135. package/src/mcs-unit.ts +25 -2
  136. package/src/mcs-vehicle.ts +12 -9
  137. package/src/node.ts +9 -3
  138. package/src/oht-line.ts +1 -1
  139. package/src/oht.ts +7 -2
  140. package/src/port-flow.ts +6 -1
  141. package/src/port.ts +6 -1
  142. package/src/scanner.ts +29 -0
  143. package/src/scene/root-container-override.ts +156 -105
  144. package/src/shelf.ts +6 -1
  145. package/src/shuttle.ts +1 -1
  146. package/src/static-container.ts +5 -0
  147. package/src/stocker-abnormal-bar.ts +247 -0
  148. package/src/stocker-capacity-bar.ts +301 -10
  149. package/src/stocker-carrier-bar.ts +262 -0
  150. package/src/stocker.ts +10 -3
  151. package/src/templates/backdrop.ts +30 -0
  152. package/src/templates/decal.ts +21 -0
  153. package/src/templates/index.ts +7 -1
  154. package/src/templates/scanner.ts +20 -0
  155. package/src/utils/crane-position.ts +105 -0
  156. package/src/utils/data-value.ts +19 -0
  157. package/src/utils/fit-gauge-scale.ts +35 -0
  158. package/src/utils/get-value-on-ranges.ts +2 -4
  159. package/src/utils/stocker-gauge.ts +111 -0
  160. package/src/zone-capacity-bar.ts +5 -5
  161. package/translations/en.json +26 -1
  162. package/translations/ja.json +26 -1
  163. package/translations/ko.json +26 -1
  164. package/translations/ms.json +26 -1
  165. package/translations/zh.json +26 -1
@@ -0,0 +1,223 @@
1
+ import { Model } from '@hatiolab/things-scene'
2
+ import type Carrier from './carrier'
3
+ import type MCSCarrierHolder from './mcs-carrier-holder'
4
+ import { parseIfJsonString } from './utils/data-value'
5
+
6
+ // 사라진 carrier 의 dispose 지연. 같은 macrotask 안에서 holder A 의 사라짐
7
+ // 보고와 holder B 의 등장 보고가 순차로 들어오는 reparent 시나리오 (= 같은
8
+ // frame snapshot 의 여러 record 적용) 를 보호하기 위해 setTimeout(0) 의
9
+ // next-macrotask delay 만 둔다. 과거 1000ms grace 가 있었으나 seek 시 2단계
10
+ // 갱신 (1초 후 carrier 사라짐) 부작용이 명확하고 frame 누락 bouncing 깜빡임
11
+ // 보호 효용은 작다 판단되어 0 으로 단축 (2026-06-09).
12
+ const DYING_TIMEOUT = 0
13
+
14
+ /**
15
+ * Carrier 가 들고 있는 4 키 묶음. data.CARRIERNAME 의 형제 키 (CARRIERTYPE,
16
+ * EMPTYTYPE, CARRIERSTATUS) 를 한 item 으로 묶는다. holder.data 의 *대표*
17
+ * 와 ADDITIONS 의 *각 항목* 모두 동일 shape 으로 정규화돼 stack diff 에
18
+ * 들어간다.
19
+ *
20
+ * 주의 — 외부 시스템이 ADDITIONS 의 각 항목 키를 다른 이름으로 (예:
21
+ * NAME / TYPE / EMPTY / STATUS) 줄 가능성에 대비해 이 build 함수가 단일
22
+ * 정규화 지점이 된다. 키 매핑 변경은 여기 한 곳만 손대면 된다.
23
+ */
24
+ export interface CarrierStackItem {
25
+ CARRIERNAME: string
26
+ CARRIERTYPE?: string
27
+ EMPTYTYPE?: string
28
+ CARRIERSTATUS?: string
29
+ }
30
+
31
+ export default class CarrierManager {
32
+ private carriers: Map<string, Carrier> = new Map()
33
+ private dyingTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
34
+ private root: any
35
+
36
+ constructor(root: any) {
37
+ this.root = root
38
+ }
39
+
40
+ get(carrierName: string): Carrier | undefined {
41
+ return this.carriers.get(carrierName)
42
+ }
43
+
44
+ /**
45
+ * Holder 의 data 변경에 대응해 carrier pool 을 동기화.
46
+ *
47
+ * 정책:
48
+ * - holder.data 가 *single source of truth*. 대표(CARRIERNAME) +
49
+ * ADDITIONS[] 를 하나의 *stack list* 로 정규화한 뒤 before/after 의
50
+ * 이름 set diff 로 추가/제거/유지 분기.
51
+ * - "대표" 와 "ADDITIONS" 는 *holder.data 안의 키 위치 차이일 뿐* 이고,
52
+ * carrier 인스턴스끼리는 **종속/그룹 관계 없음 — 모두 독립 동등**.
53
+ * stack 안의 위치 (stackIndex) 만 carrier.state 에 주입한다. 2D render
54
+ * skip 등의 판정은 *위치 (= parent.components 의 마지막 여부)* 로 한다.
55
+ * - 동일 CARRIERNAME 이 *다른 holder* 에 새로 등장하면 reparent (애니메이션).
56
+ * *해당 carrier 만 이동* — 같은 stack 의 다른 carrier 는 따라가지 않는다.
57
+ * - 사라진 carrier 는 next-macrotask 에 dispose. 같은 macrotask 안에서 다른
58
+ * holder 가 같은 이름을 보고하면 cancel 되어 reparent 로 전환.
59
+ */
60
+ update(holder: MCSCarrierHolder, after: Record<string, any>, before: Record<string, any>): void {
61
+ const afterStack = this._buildStack(after)
62
+ const beforeStack = this._buildStack(before)
63
+
64
+ const afterNames = new Set(afterStack.map(i => i.CARRIERNAME))
65
+ const beforeNames = new Set(beforeStack.map(i => i.CARRIERNAME))
66
+
67
+ // 1) 사라진 것 → dying schedule (자기 holder 의 자식인 carrier 만)
68
+ for (const name of beforeNames) {
69
+ if (afterNames.has(name)) continue
70
+ const carrier = this.carriers.get(name)
71
+ if (carrier && carrier.parent === holder) {
72
+ this.scheduleDying(name)
73
+ }
74
+ }
75
+
76
+ // 2) afterStack 의 *순서대로* 처리 — bottom (ADDITIONS[0]) 부터 top (대표)
77
+ // 그래야 parent.components 가 자연스럽게 같은 순서로 정렬되어 stack index
78
+ // 가 일관된다.
79
+ afterStack.forEach((item, idx) => {
80
+ const name = item.CARRIERNAME
81
+ this.cancelDying(name)
82
+
83
+ let carrier = this.carriers.get(name)
84
+ const { width, height } = holder.state
85
+
86
+ if (!carrier) {
87
+ carrier = Model.compile(
88
+ {
89
+ id: name,
90
+ type: 'Carrier',
91
+ left: 0,
92
+ top: 0,
93
+ width,
94
+ height,
95
+ showText: holder.getState('showCarrierText') || false,
96
+ stackIndex: idx
97
+ },
98
+ this.root.app
99
+ ) as unknown as Carrier
100
+
101
+ this.carriers.set(name, carrier)
102
+ carrier.data = item
103
+ holder.addComponent(carrier, { silent: true })
104
+ } else if (carrier.parent !== holder && !beforeNames.has(name)) {
105
+ // 다른 holder 에 있던 carrier 가 이번 update 에서 *새로 등장* 한 경우만
106
+ // reparent. before 에도 같은 이름이 있었으면 ping-pong (옛 시점의
107
+ // 반복 보고) 이므로 trigger 하지 않는다. (F05 의 bouncing 방지)
108
+ ;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
109
+ }
110
+
111
+ if (carrier.parent === holder) {
112
+ carrier.setState('stackIndex', idx)
113
+ carrier.data = item
114
+ }
115
+ })
116
+
117
+ // *_모든 carrier 추가 완료 후* — 각자의 transform 강제 갱신. 한 줄로 처리
118
+ // 안 되는 이유: cz override 의 offset 계산이 *형제 carrier 의 _realObject.
119
+ // effectiveDepth 누적*에 의존하는데, 첫 iteration 시점엔 자기 뒤 carrier 의
120
+ // _realObject 가 아직 없어 offset 이 부족하게 평가될 수 있다. 모두 빌드된
121
+ // 뒤 한 번 더 갱신해야 적층 정확.
122
+ afterStack.forEach(item => {
123
+ const carrier = this.carriers.get(item.CARRIERNAME)
124
+ if (carrier && carrier.parent === holder) {
125
+ ;(carrier as any)._realObject?.updateTransform?.()
126
+ }
127
+ })
128
+ }
129
+
130
+ /**
131
+ * data.CARRIERNAME (대표) + data.MULTICARRIERS[] 를 하나의 stack list 로 정규화.
132
+ * 적층 순서: multiCarriers[0..n-1] (bottom → up) + 대표 (top, 맨 마지막).
133
+ * (데이터 키는 모두 대문자 전제 — 케이스 정규화 없이 직접 읽는다)
134
+ */
135
+ private _buildStack(data: any): CarrierStackItem[] {
136
+ if (!data) return []
137
+
138
+ const stack: CarrierStackItem[] = []
139
+
140
+ // MULTICARRIERS 값이 JSON 문자열로 올 수 있어 파싱 후 처리.
141
+ const list = parseIfJsonString(data.MULTICARRIERS)
142
+ if (Array.isArray(list)) {
143
+ for (const a of list) {
144
+ if (a && typeof a === 'object' && a.CARRIERNAME) {
145
+ // 항목 *원본 그대로* — 외부 시스템이 임의 키를 추가로 보낼 수 있고,
146
+ // 누락된 4 키는 default 없이 그대로 비워둔다 (carrier.data 에 흘리는
147
+ // 가공 최소화 — F03 같은 기존 테스트의 raw shape 기대와도 정합).
148
+ stack.push({ ...a } as CarrierStackItem)
149
+ }
150
+ }
151
+ }
152
+
153
+ if (data.CARRIERNAME) {
154
+ // 대표는 *holder.data 의 carrier 관련 키만 추출* — showText / carrierDepth
155
+ // 같은 holder 자체 속성은 carrier 로 흘리지 않는다. 명시되지 않은 키는
156
+ // 생략 (default 안 채움).
157
+ const repr: any = { CARRIERNAME: data.CARRIERNAME }
158
+ if ('CARRIERTYPE' in data) repr.CARRIERTYPE = data.CARRIERTYPE
159
+ if ('EMPTYTYPE' in data) repr.EMPTYTYPE = data.EMPTYTYPE
160
+ if ('CARRIERSTATUS' in data) repr.CARRIERSTATUS = data.CARRIERSTATUS
161
+ stack.push(repr)
162
+ }
163
+
164
+ return stack
165
+ }
166
+
167
+ /**
168
+ * carrier dispose 를 *next macrotask* 로 미룬다. 동기 즉시 dispose 가 아닌
169
+ * 이유: 같은 macrotask 안에서 holder A 의 사라짐 보고가 step 1 에 들어오고
170
+ * holder B 의 등장 보고가 (별도 carrier-manager.update 호출의) step 2 에
171
+ * 들어오는 reparent 시나리오를 보호하기 위함. 즉시 dispose 면 step 1 에서
172
+ * carrier 인스턴스가 파괴돼 step 2 의 reparent 분기가 *새 인스턴스 생성*
173
+ * 으로 떨어지고, 3D mesh 재구축 + 이동 애니메이션 손실이 발생.
174
+ *
175
+ * 같은 macrotask 안에서 cancelDying 호출이 도달하면 clearTimeout 으로
176
+ * dispose 회수 → reparent 정상.
177
+ */
178
+ private scheduleDying(carrierName: string): void {
179
+ this.cancelDying(carrierName)
180
+
181
+ const timer = setTimeout(() => {
182
+ this.dyingTimers.delete(carrierName)
183
+
184
+ const carrier = this.carriers.get(carrierName)
185
+ if (carrier) {
186
+ this.carriers.delete(carrierName)
187
+ if (!carrier.disposed) {
188
+ carrier.removeSelf(true)
189
+ }
190
+ }
191
+ }, DYING_TIMEOUT)
192
+
193
+ this.dyingTimers.set(carrierName, timer)
194
+ }
195
+
196
+ private cancelDying(carrierName: string): void {
197
+ const timer = this.dyingTimers.get(carrierName)
198
+ if (timer) {
199
+ clearTimeout(timer)
200
+ this.dyingTimers.delete(carrierName)
201
+ }
202
+ }
203
+
204
+ onCarrierDisposed(carrierName: string): void {
205
+ this.cancelDying(carrierName)
206
+ this.carriers.delete(carrierName)
207
+ }
208
+
209
+ dispose(): void {
210
+ for (const timer of this.dyingTimers.values()) {
211
+ clearTimeout(timer)
212
+ }
213
+ this.dyingTimers.clear()
214
+
215
+ for (const carrier of this.carriers.values()) {
216
+ if (!carrier.disposed) {
217
+ carrier.removeSelf(true)
218
+ }
219
+ }
220
+
221
+ this.carriers.clear()
222
+ }
223
+ }
package/src/carrier.ts CHANGED
@@ -4,7 +4,7 @@ import { getPopupData } from '@fmsim/api'
4
4
  import { BOUNDS, Component, ComponentNature, Properties, Shape, sceneComponent } from '@hatiolab/things-scene'
5
5
  import { ANIMATION_DEFAULT, AnimationPreset, AnimationConfig } from './features/animation-default.js'
6
6
  import { ParentObjectMixin } from './features/parent-object-mixin.js'
7
- import { LEGEND_CARRIER, Legend } from './features/mcs-status-default.js'
7
+ import { Legend, resolveCarrierLegend } from './features/mcs-status-default.js'
8
8
  import { MCSStatusMixin } from './features/mcs-status-mixin.js'
9
9
 
10
10
  const NATURE: ComponentNature = {
@@ -13,12 +13,31 @@ const NATURE: ComponentNature = {
13
13
  rotatable: false
14
14
  }
15
15
 
16
+
17
+ /**
18
+ * Feature flag — keep status text (F / E / X) screen-axis-aligned even
19
+ * when the carrier (or its parent holder, or an in-flight reparent
20
+ * tween) is rotated. Counter-rotates ONLY the text; the polygon path
21
+ * still rotates with the carrier as before.
22
+ *
23
+ * ROLLBACK procedure (if customer decides to revert):
24
+ * 1. Flip this flag to `false` — text rotates with carrier again.
25
+ * 2. Or delete this constant and the — fully strip the feature: keep
26
+ * `if (STATUS_TEXT_COUNTER_ROTATE)` only the `else` branch in
27
+ * branch in `render()`. `render()` (the original code).
28
+ */
29
+ const STATUS_TEXT_COUNTER_ROTATE = true
30
+
16
31
  @sceneComponent('Carrier')
17
32
  export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
18
33
  static get nature() {
19
34
  return NATURE
20
35
  }
21
36
 
37
+ get decorators() {
38
+ return ['decotag']
39
+ }
40
+
22
41
  get path() {
23
42
  if (!this.parent) {
24
43
  return []
@@ -37,7 +56,45 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
37
56
 
38
57
  set path(path) {}
39
58
 
40
- onchangeData(after: Properties, before: Properties): void {
59
+
60
+ added(parent: any) {
61
+ super.added(parent)
62
+
63
+ // 새 부모에 맞춰 크기 및 상태 갱신 — width/height 만. depth 는 *절대로
64
+ // 건드리지 않는다*. machine-3d.ts 의 CARRIER_CONFIG + MCSRealObject3D.
65
+ // effectiveDepth 가 depthRatio(0.5) × min(w,h) 로 자동 결정한다.
66
+ // 여기서 state.depth 를 명시 set 하면 그 resolution chain 의 step 1 에
67
+ // 걸려 본래 두께가 덮어써진다 (= 사용자 호소 "두께가 줄어들었다" 의 원인).
68
+ if (parent) {
69
+ const { width, height } = parent.state || {}
70
+ if (width && height) {
71
+ this.set({ width, height })
72
+ }
73
+ }
74
+
75
+ this.syncStateFromData()
76
+
77
+ // 3D 오브젝트 최초 생성 (동기적 — reparent()에서 _realObject 필요)
78
+ if ((this.root as any)?.threed && !this._realObject) {
79
+ ;(this.root as any).model_layer?._threeCapability?.addObject(this)
80
+ }
81
+
82
+ // reparent 애니메이션은 things-scene의 reparent()가 처리
83
+ }
84
+
85
+ /**
86
+ * data로부터 carrier 상태를 동기화 (생성 시, 부모 변경 시 공통 사용)
87
+ */
88
+ private syncStateFromData(): void {
89
+ const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
90
+
91
+ this.setState('id', CARRIERNAME)
92
+ this.setState('EMPTYTYPE', EMPTYTYPE)
93
+ this.setState('CARRIERTYPE', CARRIERTYPE)
94
+ this.setState('CARRIERSTATUS', CARRIERSTATUS)
95
+ }
96
+
97
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
41
98
  const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
42
99
 
43
100
  this.setState('id', CARRIERNAME)
@@ -46,7 +103,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
46
103
  this.setState('CARRIERSTATUS', CARRIERSTATUS)
47
104
 
48
105
  // TODO carrierstatus에 따라서 매핑되는 애니메이션 테마 수행
49
- if (after.data.CARRIERSTATUS !== before.data?.CARRIERSTATUS) {
106
+ if (after.data?.CARRIERSTATUS !== before.data?.CARRIERSTATUS) {
50
107
  const { CARRIERSTATUS: lastCarrierStatus } = before.data || {}
51
108
  const lastAnimationConfig = lastCarrierStatus && this.getAnimationConfig(lastCarrierStatus)
52
109
 
@@ -99,13 +156,8 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
99
156
  }
100
157
 
101
158
  get legend(): Legend {
102
- const { carrierLegendName } = this.parent?.state || {}
103
-
104
- if (carrierLegendName) {
105
- return this.root?.style[carrierLegendName]
106
- }
107
-
108
- return this.root?.carrierLegendTheme || LEGEND_CARRIER
159
+ // carrier·스토커 게이지(2D/3D) 공유 단일 소스.
160
+ return resolveCarrierLegend(this)
109
161
  }
110
162
 
111
163
  getAnimationConfig(carrierStatus): AnimationConfig | null {
@@ -119,26 +171,32 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
119
171
 
120
172
  if (carrierAnimationName) {
121
173
  // 캐시에서 먼저 확인
122
- if (!this.root._animationConfigCache) {
123
- this.root._animationConfigCache = new Map<string, AnimationPreset>()
174
+ if (!(this.root as any)._animationConfigCache) {
175
+ (this.root as any)._animationConfigCache = new Map<string, AnimationPreset>()
124
176
  }
125
177
 
126
- const cached = this.root._animationConfigCache.get(carrierAnimationName)
178
+ const cached = (this.root as any)._animationConfigCache.get(carrierAnimationName)
127
179
  if (cached) {
128
180
  return cached
129
181
  }
130
182
 
131
183
  // 캐시에 없으면 파싱하고 저장
132
- const theme = this.root?.style[carrierAnimationName]
184
+ let theme = (this.root as any)?.style?.[carrierAnimationName]
133
185
  if (theme) {
186
+ // theme이 string이면 먼저 파싱
187
+ if (typeof theme === 'string') {
188
+ try { theme = JSON5.parse(theme) } catch { return ANIMATION_DEFAULT }
189
+ }
190
+ if (typeof theme !== 'object' || theme === null) return ANIMATION_DEFAULT
191
+
134
192
  // 문자열 형태의 설정들을 미리 파싱
135
193
  const parsedTheme = {}
136
194
  for (const [status, config] of Object.entries(theme)) {
137
- if (typeof config === 'string') {
195
+ if (typeof config === 'string' && config.trim()) {
138
196
  try {
139
197
  parsedTheme[status] = JSON5.parse(config)
140
198
  } catch (e) {
141
- console.error('JSON5 parse error:', e)
199
+ console.error('JSON5 parse error:', e, 'input:', JSON.stringify(config), 'status:', status, 'theme:', carrierAnimationName)
142
200
  parsedTheme[status] = config
143
201
  }
144
202
  } else {
@@ -146,7 +204,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
146
204
  }
147
205
  }
148
206
 
149
- this.root._animationConfigCache.set(carrierAnimationName, parsedTheme)
207
+ ;(this.root as any)._animationConfigCache.set(carrierAnimationName, parsedTheme)
150
208
  return parsedTheme
151
209
  }
152
210
 
@@ -212,6 +270,18 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
212
270
  return
213
271
  }
214
272
 
273
+ // 2D 에서는 stack 의 *맨 위 (= parent.components 의 마지막)* 1개만 그린다.
274
+ // ADDITIONS 의 carrier 들도 인스턴스로는 살아있어 조회/reparent 가 가능하지만
275
+ // 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (대표 1개만).
276
+ // 판단 기준은 *위치* — carrier 간 그룹/종속 정보는 두지 않는다.
277
+ const parent = this.parent
278
+ if (parent && parent.components && parent.components.length > 1) {
279
+ const siblings = parent.components
280
+ if (siblings[siblings.length - 1] !== this) {
281
+ return
282
+ }
283
+ }
284
+
215
285
  const { width, height } = this.bounds
216
286
  const path = this.path
217
287
  const round = Math.round(Math.min(width, height) * 0.1)
@@ -244,11 +314,34 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
244
314
  ctx.textAlign = 'center'
245
315
  ctx.textBaseline = 'middle'
246
316
 
247
- ctx.fillText(text, cx - width / 8, cy + height / 8)
317
+ if (STATUS_TEXT_COUNTER_ROTATE) {
318
+ // Counter-rotate so the status label stays screen-axis-aligned
319
+ // even when the carrier (or its parent holder, or an in-flight
320
+ // reparent transition) is rotated. Reading the current cumulative
321
+ // rotation from the canvas transform handles all sources of
322
+ // rotation uniformly (state.rotation, parent transforms,
323
+ // animation tweens) — no need to enumerate them.
324
+ const m = ctx.getTransform()
325
+ const cumulRotation = Math.atan2(m.b, m.a)
326
+ ctx.save()
327
+ ctx.translate(cx, cy)
328
+ ctx.rotate(-cumulRotation)
329
+ ctx.fillText(text, -width / 8, height / 8)
330
+ ctx.restore()
331
+ } else {
332
+ // Original — text rotates with the carrier.
333
+ ctx.fillText(text, cx - width / 8, cy + height / 8)
334
+ }
248
335
  }
249
336
  }
250
337
  }
251
338
 
339
+ dispose(): void {
340
+ ;(this.root as any)?.carrierManager?.onCarrierDisposed(this.state.id)
341
+
342
+ super.dispose()
343
+ }
344
+
252
345
  async detailInfo() {
253
346
  const { type, id } = this.state
254
347
 
@@ -99,7 +99,7 @@ export default class ConveyorJoin extends ConveyorMixin(MCSTransport) {
99
99
  }
100
100
 
101
101
  getLegendFallback() {
102
- return this.root.conveyorLegendTheme || super.getLegendFallback()
102
+ return (this.root as any).conveyorLegendTheme || super.getLegendFallback()
103
103
  }
104
104
 
105
105
  containable(component: Component) {
@@ -119,8 +119,11 @@ export default class ConveyorJoin extends ConveyorMixin(MCSTransport) {
119
119
  if (normx * normx + normy * normy < 0.25 && ratiox * ratiox + ratioy * ratioy > 0.25) {
120
120
  const angle = normalizeAngle(Math.atan2(-normy, normx) - Math.PI / 2)
121
121
 
122
- if (angle >= startAngle && angle <= endAngle) {
123
- return true
122
+ if (startAngle <= endAngle) {
123
+ if (angle >= startAngle && angle <= endAngle) return true
124
+ } else {
125
+ // wraparound: startAngle > endAngle (예: π → 0)
126
+ if (angle >= startAngle || angle <= endAngle) return true
124
127
  }
125
128
  }
126
129
 
package/src/conveyor.ts CHANGED
@@ -11,7 +11,15 @@ const NATURE: ComponentNature = {
11
11
  label: 'conveyor-type',
12
12
  name: 'conveyorType',
13
13
  property: {
14
- options: ['', 'conveyor', 'rail']
14
+ options: ['', 'conveyor', 'belt', 'rail']
15
+ }
16
+ },
17
+ {
18
+ type: 'select',
19
+ label: 'conveyor-orientation',
20
+ name: 'orientation',
21
+ property: {
22
+ options: ['', 'horizontal', 'vertical']
15
23
  }
16
24
  },
17
25
  ...MCSTransport.properties
@@ -29,7 +37,7 @@ export default class Conveyor extends MCSTransport {
29
37
  }
30
38
 
31
39
  getLegendFallback() {
32
- return this.root.conveyorLegendTheme || super.getLegendFallback()
40
+ return (this.root as any).conveyorLegendTheme || super.getLegendFallback()
33
41
  }
34
42
 
35
43
  containable(component: Component) {
@@ -0,0 +1,58 @@
1
+ import { Component, ComponentNature, sceneComponent } from '@hatiolab/things-scene'
2
+ import MCSTransport from './mcs-transport'
3
+
4
+ const NATURE: ComponentNature = {
5
+ mutable: false,
6
+ resizable: true,
7
+ rotatable: true,
8
+ properties: [...MCSTransport.properties]
9
+ }
10
+
11
+ @sceneComponent('CraneRail')
12
+ export default class CraneRail extends MCSTransport {
13
+ static get nature() {
14
+ return NATURE
15
+ }
16
+
17
+ containable(component: Component) {
18
+ return ['Crane', 'Node'].includes(component.state.type)
19
+ }
20
+
21
+ get auxColor() {
22
+ return '#555'
23
+ }
24
+
25
+ getLegendFallback() {
26
+ return (this.root as any).craneRailLegendTheme || super.getLegendFallback()
27
+ }
28
+
29
+ render(ctx: CanvasRenderingContext2D) {
30
+ const { left, top, width, height } = this.bounds
31
+
32
+ ctx.translate(left, top)
33
+ ctx.beginPath()
34
+
35
+ ctx.fillStyle = this.statusColor!
36
+ ctx.strokeStyle = this.auxColor
37
+ ctx.lineWidth = 2
38
+ ctx.setLineDash([])
39
+
40
+ ctx.fillRect(0, 0, width, height)
41
+
42
+ // 양쪽 레일 (실선)
43
+ if (width > height) {
44
+ ctx.moveTo(0, 0)
45
+ ctx.lineTo(width, 0)
46
+ ctx.moveTo(0, height)
47
+ ctx.lineTo(width, height)
48
+ } else {
49
+ ctx.moveTo(0, 0)
50
+ ctx.lineTo(0, height)
51
+ ctx.moveTo(width, 0)
52
+ ctx.lineTo(width, height)
53
+ }
54
+ ctx.stroke()
55
+
56
+ ctx.translate(-left, -top)
57
+ }
58
+ }