@fmsim/machine 2.0.1 → 3.0.0-beta.1

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 (95) hide show
  1. package/dist/agv.js +2 -3
  2. package/dist/agv.js.map +1 -1
  3. package/dist/carrier.js +53 -43
  4. package/dist/carrier.js.map +1 -1
  5. package/dist/crane.js +37 -2
  6. package/dist/crane.js.map +1 -1
  7. package/dist/editors/index.js +2 -0
  8. package/dist/editors/index.js.map +1 -1
  9. package/dist/editors/load-layout-host.js +96 -0
  10. package/dist/editors/load-layout-host.js.map +1 -0
  11. package/dist/editors/ox-load-layout-panel.js +124 -0
  12. package/dist/editors/ox-load-layout-panel.js.map +1 -0
  13. package/dist/factories/crane-3d.js +77 -18
  14. package/dist/factories/crane-3d.js.map +1 -1
  15. package/dist/factories/machine-3d.js +29 -52
  16. package/dist/factories/machine-3d.js.map +1 -1
  17. package/dist/features/capacity-gauge-mixin.js +2 -3
  18. package/dist/features/capacity-gauge-mixin.js.map +1 -1
  19. package/dist/features/load-layout-holder.js +77 -0
  20. package/dist/features/load-layout-holder.js.map +1 -0
  21. package/dist/features/mcs-status-default.js +30 -7
  22. package/dist/features/mcs-status-default.js.map +1 -1
  23. package/dist/features/mcs-status-mixin.js +3 -3
  24. package/dist/features/mcs-status-mixin.js.map +1 -1
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcs-carrier-holder.js +287 -82
  28. package/dist/mcs-carrier-holder.js.map +1 -1
  29. package/dist/oht.js +4 -0
  30. package/dist/oht.js.map +1 -1
  31. package/dist/port.js +6 -1
  32. package/dist/port.js.map +1 -1
  33. package/dist/scene/root-container-override.js +5 -11
  34. package/dist/scene/root-container-override.js.map +1 -1
  35. package/dist/shelf.js +4 -0
  36. package/dist/shelf.js.map +1 -1
  37. package/dist/shuttle.js +4 -0
  38. package/dist/shuttle.js.map +1 -1
  39. package/dist/stocker-carrier-bar.js +4 -4
  40. package/dist/stocker-carrier-bar.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/dist/utils/carrier-type-code.js +10 -0
  43. package/dist/utils/carrier-type-code.js.map +1 -0
  44. package/dist/utils/crane-forks.js +5 -0
  45. package/dist/utils/crane-forks.js.map +1 -0
  46. package/dist/utils/layout-issue-text.js +16 -0
  47. package/dist/utils/layout-issue-text.js.map +1 -0
  48. package/dist/utils/layout-issue.js +16 -0
  49. package/dist/utils/layout-issue.js.map +1 -0
  50. package/dist/utils/load-address.js +217 -0
  51. package/dist/utils/load-address.js.map +1 -0
  52. package/dist/utils/stocker-gauge.js +3 -2
  53. package/dist/utils/stocker-gauge.js.map +1 -1
  54. package/jest.config.js +9 -1
  55. package/package.json +9 -6
  56. package/src/agv.ts +2 -3
  57. package/src/carrier.ts +50 -41
  58. package/src/crane.ts +39 -2
  59. package/src/editors/index.ts +2 -0
  60. package/src/editors/load-layout-host.ts +121 -0
  61. package/src/editors/ox-load-layout-panel.ts +129 -0
  62. package/src/factories/crane-3d.ts +74 -20
  63. package/src/factories/machine-3d.ts +26 -49
  64. package/src/features/capacity-gauge-mixin.ts +2 -3
  65. package/src/features/load-layout-holder.ts +83 -0
  66. package/src/features/mcs-status-default.ts +29 -10
  67. package/src/features/mcs-status-mixin.ts +3 -7
  68. package/src/index.ts +7 -0
  69. package/src/mcs-carrier-holder.ts +319 -78
  70. package/src/oht.ts +5 -0
  71. package/src/port.ts +7 -1
  72. package/src/scene/root-container-override.ts +4 -14
  73. package/src/shelf.ts +5 -0
  74. package/src/shuttle.ts +5 -0
  75. package/src/stocker-carrier-bar.ts +4 -4
  76. package/src/utils/carrier-type-code.ts +9 -0
  77. package/src/utils/crane-forks.ts +4 -0
  78. package/src/utils/layout-issue-text.ts +18 -0
  79. package/src/utils/layout-issue.ts +41 -0
  80. package/src/utils/load-address.ts +270 -0
  81. package/src/utils/stocker-gauge.ts +3 -3
  82. package/translations/en.json +42 -1
  83. package/translations/ja.json +42 -1
  84. package/translations/ko.json +59 -18
  85. package/translations/ms.json +42 -1
  86. package/translations/zh.json +42 -1
  87. package/dist/carrier-manager.js +0 -191
  88. package/dist/carrier-manager.js.map +0 -1
  89. package/dist/features/animation-default.js +0 -6
  90. package/dist/features/animation-default.js.map +0 -1
  91. package/dist/utils/get-value-on-ranges.js +0 -21
  92. package/dist/utils/get-value-on-ranges.js.map +0 -1
  93. package/src/carrier-manager.ts +0 -231
  94. package/src/features/animation-default.ts +0 -13
  95. package/src/utils/get-value-on-ranges.ts +0 -23
@@ -1,105 +1,346 @@
1
1
  import { themesColorMap, themesAnimationMap } from '@fmsim/api'
2
+ import JSON5 from 'json5'
3
+ import {
4
+ Capacity,
5
+ Holdable,
6
+ ItemsDataBinding,
7
+ Model,
8
+ TRANSFER_SLOT_KEY,
9
+ figureLegendSourcesChanged,
10
+ itemTypeEntryOf,
11
+ itemTypeTableOf,
12
+ registerFigureCapability,
13
+ registerFigureLegendResolver,
14
+ type ItemType
15
+ } from '@hatiolab/things-scene'
16
+ import { HOLDER_ANCHOR, type ItemDims, type ResolvedSlot } from '@hatiolab/figure-model'
17
+ import { CARRIER_HOLDER, carrierHolderProperties } from '@fmsim/figure-capabilities'
2
18
  import MCSUnit from './mcs-unit.js'
3
- import { parseIfJsonString } from './utils/data-value'
19
+ import { incomingFromV2 } from './utils/load-address'
4
20
 
5
21
  /**
6
- * Holder.data 변경 감지 시 carrierManager 에 전달할 4 키 묶음 + multiCarriers 배열.
7
- * 비교 결과 단 하나라도 다르면 carrierManager.update 호출.
8
- * (데이터 키는 모두 대문자 전제 — 케이스 정규화 없이 직접 읽는다)
22
+ * A holder's slots outside its layout (common item contract 3.4): every carrier of a holder without a layout stands in
23
+ * one pile, and a carrier whose address is unknown or wrong stands in the exception area beside the holder.
9
24
  */
10
- const CARRIER_DATA_KEYS = ['CARRIERNAME', 'CARRIERTYPE', 'EMPTYTYPE', 'CARRIERSTATUS'] as const
11
-
12
- function multiCarriersChanged(a: any, b: any): boolean {
13
- // MULTICARRIERS 값이 JSON 문자열로 올 수 있어 파싱 후 비교. shallow JSON 비교로 충분 —
14
- // 각 항목이 4 키 primitive 만 가짐. 빈/누락은 같은 '[]' 직렬화로 동치 취급.
15
- return (
16
- JSON.stringify(parseIfJsonString(a?.MULTICARRIERS) ?? []) !==
17
- JSON.stringify(parseIfJsonString(b?.MULTICARRIERS) ?? [])
18
- )
25
+ export const PILE_SLOT = 'pile'
26
+ export const EXCEPTION_SLOT = 'exception'
27
+
28
+ /** Where the exception area starts, past the holder's +X edge: a gap of this much of the holder's width */
29
+ const EXCEPTION_GAP_RATIO = 0.1
30
+
31
+ /** A carrier shown in a pile is this much of its holder's plan, and half its smaller side high (fmsim v2) */
32
+ const PILE_PLAN_RATIO = 0.8
33
+ const PILE_HEIGHT_RATIO = 0.5
34
+
35
+ /** The record of one carrier the holder's data names: its id, the slot it stands in, what it is told */
36
+ export interface CarrierRecord {
37
+ slotId: string
38
+ itemId: string
39
+ itemState: { data: Record<string, any>; showText: boolean }
19
40
  }
20
41
 
21
- export default class MCSCarrierHolder extends MCSUnit {
22
- static get properties(): any {
23
- return [
24
- ...MCSUnit.properties,
25
- {
26
- type: 'select',
27
- label: 'carrier-legend-name',
28
- name: 'carrierLegendName',
29
- property: {
30
- options: themesColorMap
31
- }
32
- },
33
- {
34
- type: 'select',
35
- label: 'carrier-animation-name',
36
- name: 'carrierAnimationName',
37
- property: {
38
- options: themesAnimationMap
39
- }
40
- },
41
- {
42
- type: 'boolean',
43
- label: 'show-carrier-text',
44
- name: 'showCarrierText'
42
+ /** The properties a carrier holder adds, offering the legends this board has (ADR-0167 decision 11, 미결 1) */
43
+ export const CARRIER_HOLDER_PROPERTIES = carrierHolderProperties({ legends: themesColorMap, animations: themesAnimationMap })
44
+
45
+ /**
46
+ * A holder of MCS carriers, as a mixin over what holds things (`fmsim.CarrierHolder`). What it holds is the common item
47
+ * contract's: carriers are its items (`_items`), found by their CARRIERNAME in the root (itemById), made, moved between
48
+ * holders and let go by the one reconcile by id. MCS's part is here: reading the holder data into one record per
49
+ * carrier, looking its CARRIERTYPE up in the domain's carrier type table (ADR-0155, theme carrier-types), and how a
50
+ * carrier -- whose size MCS does not say -- is shown.
51
+ */
52
+ export function CarrierHolder<T extends new (...args: any[]) => any>(Base: T) {
53
+ return class extends Base {
54
+ /** Whatever wears this is a carrier holder: the legend resolver finds it up a carrier's parents. */
55
+ get isCarrierHolder(): true {
56
+ return true
57
+ }
58
+
59
+ /** The records the reconcile took last, as JSON: the same again is not reconciled */
60
+ _carrierRecordsTaken?: string
61
+
62
+ get showOverflow() {
63
+ return true
64
+ }
65
+
66
+ get hierarchy() {
67
+ return {
68
+ ...super.hierarchy,
69
+ components: []
45
70
  }
46
- // 적층 두께는 carrier 측 (Carrier.added) 에서 자기 default depth 를 결정.
47
- // holder 측에는 별도 carrierDepth 속성을 두지 않는다.
48
- ]
49
- }
71
+ }
50
72
 
51
- get showOverflow() {
52
- return true
53
- }
73
+ // ── items: the holder data is the holder's, the records are worked out from it ─────────────
54
74
 
55
- get hierarchy() {
56
- return {
57
- ...super.hierarchy,
58
- components: []
75
+ _itemsFromData(): boolean {
76
+ return false
59
77
  }
60
- }
61
78
 
62
- dispose(): void {
63
- ;(this.root as any)?.carrierManager?.update(this, {}, this.data)
64
- super.dispose()
65
- }
79
+ _keyedById(): boolean {
80
+ return true
81
+ }
66
82
 
67
- async ready() {
68
- super.ready()
83
+ /** A carrier the data moves here from another holder travels for two seconds */
84
+ itemMoveAnimation() {
85
+ return { duration: 2000, ease: 'inout' }
86
+ }
69
87
 
70
- if (this._hasCarrierData(this.data)) {
71
- ;(this.root as any).carrierManager.update(this, this.data || {}, undefined)
88
+ _createItemFromRecord(record: CarrierRecord): any {
89
+ const { width, height } = (this as any).state
90
+ return Model.compile(
91
+ { id: record.itemId, type: 'Carrier', left: 0, top: 0, width, height, ...record.itemState },
92
+ (this as any).root?.app
93
+ )
72
94
  }
73
- }
74
95
 
75
- onchangeData(after: Record<string, any>, before: Record<string, any>): void {
76
- const afterData = after.data || {}
77
- const beforeData = before.data || {}
96
+ /**
97
+ * One record per carrier the data names, bottom first: MULTICARRIERS in order, then the representative. Without a
98
+ * layout they all stand in the pile. A carrier is told its own keys and whether to show its text.
99
+ */
100
+ carrierRecords(data: Record<string, any> | undefined): CarrierRecord[] {
101
+ return incomingFromV2(data).map(c => ({
102
+ slotId: PILE_SLOT,
103
+ itemId: c.name,
104
+ itemState: this.carrierStateOf(c.record, c.record === data)
105
+ }))
106
+ }
107
+
108
+ /** What a carrier is told: its own keys (the representative's are picked out of the holder data), and its text */
109
+ carrierStateOf(record: Record<string, any>, representative: boolean): CarrierRecord['itemState'] {
110
+ let data: Record<string, any> = { ...record }
111
+ if (representative) {
112
+ data = { CARRIERNAME: record.CARRIERNAME }
113
+ for (const k of ['CARRIERTYPE', 'EMPTYTYPE', 'CARRIERSTATUS']) if (k in record) data[k] = record[k]
114
+ }
115
+ return { data, showText: !!(this as any).getState('showCarrierText') }
116
+ }
117
+
118
+ /**
119
+ * The carrier 2D draws (fmsim v2, a rule of position): the top of the data's stack -- MULTICARRIERS bottom first, the
120
+ * holder's own CARRIERNAME last -- when the holder holds it, else the last it holds.
121
+ */
122
+ get topCarrier(): any {
123
+ const items: any[] = (this as any)._items
124
+ const names = incomingFromV2((this as any).data).map(c => c.name)
125
+ for (let i = names.length - 1; i >= 0; i--) {
126
+ const held = items.find(c => c.state.id === names[i])
127
+ if (held) return held
128
+ }
129
+ return items[items.length - 1]
130
+ }
131
+
132
+ /** The holder's carriers made to be what its data says now. Records the same as last time change nothing */
133
+ syncCarriers(data: Record<string, any> | undefined) {
134
+ const records = this.carrierRecords(data)
135
+ const taken = JSON.stringify(records)
136
+ if (taken === this._carrierRecordsTaken) return
137
+ this._carrierRecordsTaken = taken
138
+ ;(this as any)._reconcileItems(records)
139
+ }
140
+
141
+ async ready() {
142
+ await super.ready()
143
+ this.syncCarriers((this as any).data)
144
+ }
145
+
146
+ onchange(after: Record<string, any>, before: Record<string, any>): void {
147
+ super.onchange(after, before)
148
+ /* Its size moves its bottom and changes the size a pile shows carriers at: they take its plan, made and seated again. */
149
+ if ('width' in after || 'height' in after || 'depth' in after) {
150
+ const { width, height } = (this as any).state
151
+ for (const carrier of (this as any)._items) if (width && height) carrier.set({ width, height })
152
+ this._carriersSeatedAgain()
153
+ }
154
+ /* 이 홀더의 Carrier 들이 물려받는 범례가 바뀌었다: 그 피규어들이 범례를 다시 고른다 (ADR-0166 결정 12) */
155
+ if ('carrierLegendName' in after || 'carrierAnimationName' in after) figureLegendSourcesChanged(this)
156
+ if ('showCarrierText' in after || 'slotLayout' in after) {
157
+ this._carrierRecordsTaken = undefined
158
+ this.syncCarriers((this as any).data)
159
+ if ('slotLayout' in after) this._carriersSeatedAgain()
160
+ }
161
+ }
162
+
163
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
164
+ this.syncCarriers(after.data || {})
165
+ super.onchangeData(after, before)
166
+ }
167
+
168
+ // ── the domain's item types ────────────────────────────────────────────────
169
+
170
+ /**
171
+ * A carrier's type (ADR-0155): the code it says (its CARRIERTYPE, trimmed and upper-cased -- doc 01, 6.8), in the
172
+ * domain's carrier type table the scene was given when it opened. None when the table does not have it, or the scene
173
+ * was given none.
174
+ */
175
+ carrierItemType(carrier: any): ItemType | null {
176
+ const table = itemTypeTableOf(this)
177
+ return (table && itemTypeEntryOf(table, carrier)) || null
178
+ }
179
+
180
+ /** A carrier's item type: its code, when the domain's table has it; else null */
181
+ itemTypeOf(carrier: any): string | null {
182
+ return this.carrierItemType(carrier)?.code ?? null
183
+ }
184
+
185
+ /**
186
+ * A carrier's size in mm, as judged: none. MCS data does not say a carrier's size and a carrier type has none
187
+ * (ADR-0155 amendment 1 C); a carrier is shown at the size of the place it stands in (itemShownDimsOf).
188
+ */
189
+ itemDimsOf(_carrier: any): ItemDims | null {
190
+ return null
191
+ }
78
192
 
79
- let changed = false
80
- for (const k of CARRIER_DATA_KEYS) {
81
- if (afterData[k] !== beforeData[k]) {
82
- changed = true
83
- break
193
+ /**
194
+ * The size a carrier of unknown size is shown at, in mm (for showing only, never judged). In the pile, most of the
195
+ * holder's plan and half its smaller side high; elsewhere the box filling its slot, its height shared by the stack.
196
+ */
197
+ itemShownDimsOf(_carrier: any, slot: ResolvedSlot, slotId: string): ItemDims | null {
198
+ /* Elsewhere outside the layout, a holder that keeps carriers there says (the exception area: LoadLayoutHolder). */
199
+ if (slotId === PILE_SLOT) {
200
+ const k = (this as any).mmPerSceneUnit
201
+ const { width = 0, height = 0 } = (this as any).state
202
+ const w = Math.abs(width) * PILE_PLAN_RATIO
203
+ const d = Math.abs(height) * PILE_PLAN_RATIO
204
+ return w > 0 && d > 0 ? { width: w * k, depth: d * k, height: Math.min(w, d) * PILE_HEIGHT_RATIO * k } : null
84
205
  }
206
+ const layers = Number.isFinite(slot.stackLimit) && slot.stackLimit > 0 ? slot.stackLimit : 1
207
+ return Number.isFinite(slot.allowed.width)
208
+ ? { width: slot.allowed.width, depth: slot.allowed.depth, height: slot.allowed.height / layers }
209
+ : null
85
210
  }
86
- if (!changed) {
87
- changed = multiCarriersChanged(afterData, beforeData)
211
+
212
+ /** A carrier's size as it is shown, in mm: its own, or the size its slot shows an unknown one at (its seat's) */
213
+ carrierShownDimsOf(carrier: any): ItemDims | null {
214
+ return (this as any).itemSeatOf(carrier)?.dims ?? null
215
+ }
216
+
217
+ /** Every carrier made again at the size it is shown at, and every slot seated again */
218
+ _carriersSeatedAgain() {
219
+ const items: any[] = (this as any)._items
220
+ for (const carrier of items) carrier._realObject?.update?.()
221
+ const slots = new Set(items.map(c => c[TRANSFER_SLOT_KEY]))
222
+ for (const slotId of slots) (this as any)._reseatSlot(slotId)
223
+ }
224
+
225
+ // ── where carriers stand ──────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Where a holder's carriers rest, in coordinates above the holder's bottom, and whether they stand on it or hang from
229
+ * it: on the transport surface by default (fmsim v2). A Port, Shelf or Shuttle holds them at its bottom; an OHT hangs
230
+ * them below.
231
+ */
232
+ get carrierRest(): { height: number; mount: 'ON' | 'HANG' } {
233
+ return { height: 30, mount: 'ON' }
234
+ }
235
+
236
+ /** The pile, and the exception area beside the holder's +X edge. Frames in mm on the holder's own anchor (its bottom) */
237
+ outsideSlots() {
238
+ const k = (this as any).mmPerSceneUnit
239
+ const { width = 0 } = (this as any).state
240
+ const w = Math.abs(width)
241
+ const rest = this.carrierRest
242
+ return [
243
+ {
244
+ id: PILE_SLOT,
245
+ anchorId: HOLDER_ANCHOR,
246
+ frame: { position: { x: 0, y: rest.height * k, z: 0 }, rotation: 0 },
247
+ mount: rest.mount
248
+ },
249
+ {
250
+ id: EXCEPTION_SLOT,
251
+ anchorId: HOLDER_ANCHOR,
252
+ frame: {
253
+ position: { x: (w / 2 + w * EXCEPTION_GAP_RATIO) * k + this.exceptionHalfWidth, y: 0, z: 0 },
254
+ rotation: 0
255
+ }
256
+ }
257
+ ]
88
258
  }
89
259
 
90
- if (changed) {
91
- ;(this.root as any).carrierManager.update(this, afterData, beforeData)
260
+ /** Half the width of a carrier in the exception area, in mm: its edge stands past the gap */
261
+ get exceptionHalfWidth(): number {
262
+ return 0
92
263
  }
93
264
 
94
- super.onchangeData(after, before)
265
+ /**
266
+ * The holder's own anchor is its bottom's centre: a node on the frame at its 3D's origin (the box centre), half its
267
+ * 3D's depth down, turned and moved with it. Others by the layer below.
268
+ */
269
+ anchorObject3D(anchorId: string): any {
270
+ const frame = super.anchorObject3D(anchorId)
271
+ if (anchorId !== HOLDER_ANCHOR || !frame) return frame
272
+ let node = frame.userData.carrierAnchor
273
+ if (!node || node.parent !== frame) {
274
+ node = new frame.constructor()
275
+ node.name = 'carrier-anchor'
276
+ frame.add(node)
277
+ frame.userData.carrierAnchor = node
278
+ }
279
+ node.position.set(0, -(Number((this as any)._realObject?.effectiveDepth) || 0) / 2, 0)
280
+ return node
281
+ }
95
282
  }
283
+ }
284
+
285
+ /* The mixins of things-scene are typed loosely: the holder is still an MCSUnit. */
286
+ interface MCSCarrierHolder extends MCSUnit {}
287
+
288
+ /** fmsim's own carrier holder: an MCS unit that holds things, wearing `fmsim.CarrierHolder`. */
289
+ class MCSCarrierHolder extends CarrierHolder(ItemsDataBinding(Holdable(Capacity(MCSUnit as any)))) {
290
+ static get properties(): any {
291
+ return [...MCSUnit.properties, ...CARRIER_HOLDER_PROPERTIES]
292
+ }
293
+ }
294
+
295
+ export default MCSCarrierHolder
96
296
 
97
- /** data 안에 carrier 정보 (대표 또는 multiCarriers) 가 하나라도 있는지. */
98
- private _hasCarrierData(data: any): boolean {
99
- if (!data) return false
100
- if (data.CARRIERNAME) return true
101
- const list = parseIfJsonString(data.MULTICARRIERS)
102
- if (Array.isArray(list) && list.some((a: any) => a?.CARRIERNAME)) return true
103
- return false
297
+ /*
298
+ The legend a carrier figure shows its tables by (ADR-0166 7, 12; ADR-0167 11): the nearest carrier holder up its
299
+ parents that chose one for the role -- carrier-legend its carrier legend, carrier-animation its carrier animation --
300
+ then the board root's same choice; none chosen, no answer (the figure's own table, fmsim's default). Colour and effect
301
+ only: a carrier figure's thickness is its own fixed table (ADR-0167 12).
302
+ */
303
+ const ROLE_PROPERTY: Record<string, string> = {
304
+ 'carrier-legend': 'carrierLegendName',
305
+ 'carrier-animation': 'carrierAnimationName'
306
+ }
307
+
308
+ export function carrierHolderLegend({ component, role }: { component: any; role: string }): unknown {
309
+ const property = ROLE_PROPERTY[role]
310
+ if (!property) return undefined
311
+ let name: unknown
312
+ for (let at = component?.parent; at && !name; at = at.parent) if (at.isCarrierHolder) name = at.state?.[property]
313
+ name ||= component?.root?.rootModel?.state?.[property]
314
+ if (typeof name !== 'string' || !name) return undefined
315
+ let legend = component?.root?.style?.[name]
316
+ if (typeof legend === 'string') {
317
+ try {
318
+ legend = JSON5.parse(legend)
319
+ } catch {
320
+ return undefined
321
+ }
104
322
  }
323
+ return legend && typeof legend === 'object' ? colourAndEffect(legend) : undefined
105
324
  }
325
+
326
+ /** A legend as a figure takes it from fmsim: each entry's colour and effect; a colour alone stays the colour; an entry's JSON5 text read. */
327
+ function colourAndEffect(legend: Record<string, unknown>): Record<string, unknown> {
328
+ return Object.fromEntries(
329
+ Object.entries(legend).map(([code, raw]) => {
330
+ let entry = raw
331
+ if (typeof entry === 'string' && entry.trim().startsWith('{')) {
332
+ try {
333
+ entry = JSON5.parse(entry)
334
+ } catch {
335
+ return [code, raw]
336
+ }
337
+ }
338
+ if (!entry || typeof entry !== 'object') return [code, entry]
339
+ const { color, effect } = entry as { color?: unknown; effect?: unknown }
340
+ return [code, { ...(color !== undefined ? { color } : {}), ...(effect !== undefined ? { effect } : {}) }]
341
+ })
342
+ )
343
+ }
344
+
345
+ registerFigureLegendResolver(carrierHolderLegend)
346
+ registerFigureCapability({ ...CARRIER_HOLDER, properties: CARRIER_HOLDER_PROPERTIES as never }, base => CarrierHolder(base) as never)
package/src/oht.ts CHANGED
@@ -16,6 +16,11 @@ export default class OHT extends MCSVehicle {
16
16
  return NATURE
17
17
  }
18
18
 
19
+ /** Carriers hang below the OHT's bottom, the gripper's (fmsim v2) */
20
+ get carrierRest(): { height: number; mount: 'ON' | 'HANG' } {
21
+ return { height: 0, mount: 'HANG' }
22
+ }
23
+
19
24
  get auxColor() {
20
25
  return 'black'
21
26
  }
package/src/port.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Component, ComponentNature, sceneComponent } from '@hatiolab/things-scene'
2
2
  import { MCSZoneMixin, MCSZoneMixinProperties } from './features/mcs-zone-mixin'
3
3
  import MCSCarrierHolder from './mcs-carrier-holder'
4
+ import { LoadLayoutHolder } from './features/load-layout-holder'
4
5
 
5
6
  const NATURE: ComponentNature = {
6
7
  mutable: false,
@@ -10,11 +11,16 @@ const NATURE: ComponentNature = {
10
11
  }
11
12
 
12
13
  @sceneComponent('Port')
13
- export default class Port extends MCSZoneMixin(MCSCarrierHolder) {
14
+ export default class Port extends LoadLayoutHolder(MCSZoneMixin(MCSCarrierHolder)) {
14
15
  static get nature() {
15
16
  return NATURE
16
17
  }
17
18
 
19
+ /** Carriers stand at the Port's bottom (fmsim v2) */
20
+ get carrierRest(): { height: number; mount: 'ON' | 'HANG' } {
21
+ return { height: 0, mount: 'ON' }
22
+ }
23
+
18
24
  get auxColor() {
19
25
  return 'black'
20
26
  }
@@ -1,7 +1,6 @@
1
- import { Component, RootContainer } from '@hatiolab/things-scene'
1
+ import { Component, RootContainer, figureLegendSourcesChanged } from '@hatiolab/things-scene'
2
2
  import { themesColorMap, themesColorRange, themesAnimationMap } from '@fmsim/api'
3
3
  import { Legend } from '../features/mcs-status-default'
4
- import CarrierManager from '../carrier-manager'
5
4
 
6
5
  // RootContainer 클래스 타입 확장
7
6
  declare module '@hatiolab/things-scene' {
@@ -31,8 +30,6 @@ declare module '@hatiolab/things-scene' {
31
30
  _refreshStockerGauges(): void
32
31
  // 캐시 관련 속성 추가
33
32
  _animationConfigCache?: Map<string, any>
34
- _carrierManager?: CarrierManager
35
- carrierManager: CarrierManager
36
33
  }
37
34
  }
38
35
 
@@ -263,6 +260,8 @@ ModelLayer.prototype.onchange = function (after: any) {
263
260
  })
264
261
  }
265
262
  }
263
+ /* 보드의 Carrier 범례가 바뀌었다: Carrier 피규어들이 범례를 다시 고른다 (ADR-0166 결정 12) */
264
+ if (after && ['carrierLegendName', 'carrierAnimationName'].some(k => Object.prototype.hasOwnProperty.call(after, k))) figureLegendSourcesChanged()
266
265
  return _origModelLayerOnchange.call(this, after)
267
266
  }
268
267
 
@@ -272,7 +271,7 @@ ModelLayer.prototype.onchange = function (after: any) {
272
271
  // 형태에 따라 (a) 정상 object 또는 (b) JSON 문자열 둘 다로 나타난다 (2026-06-09
273
272
  // 진단 — consumer 인 carrier.ts:animationPreset 이 같은 이유로 typeof string
274
273
  // 분기 + JSON5.parse 처리하는 선례 존재). LegendTheme getter 들이 이 분기를
275
- // 두지 않아 string 통과 → getValueOnRanges 매칭 실패 → 3D gauge 의 색상이
274
+ // 두지 않아 string 통과 → 구간 조회 매칭 실패 → 3D gauge 의 색상이
276
275
  // fallback 으로만 결정되던 증상.
277
276
  //
278
277
  // fix 는 getter 한 곳에서 lazy parse. WeakMap<state, Map<rawString, parsed>>
@@ -327,15 +326,6 @@ Object.defineProperty(RootContainer.prototype, 'vehicleAnimation', {
327
326
  }
328
327
  })
329
328
 
330
- Object.defineProperty(RootContainer.prototype, 'carrierManager', {
331
- get: function () {
332
- if (!this._carrierManager) {
333
- this._carrierManager = new CarrierManager(this)
334
- }
335
- return this._carrierManager
336
- }
337
- })
338
-
339
329
  // === 스토커 게이지 표시 모드 (런타임 전용 transient) ===
340
330
  //
341
331
  // 모델(state)에 저장하지 않는다 — nature 속성이 아니므로 보드 직렬화에 포함되지 않고,
package/src/shelf.ts CHANGED
@@ -15,6 +15,11 @@ export default class Shelf extends MCSZoneMixin(MCSCarrierHolder) {
15
15
  return NATURE
16
16
  }
17
17
 
18
+ /** Carriers stand at the Shelf's bottom (fmsim v2) */
19
+ get carrierRest(): { height: number; mount: 'ON' | 'HANG' } {
20
+ return { height: 0, mount: 'ON' }
21
+ }
22
+
18
23
  get auxColor() {
19
24
  return 'black'
20
25
  }
package/src/shuttle.ts CHANGED
@@ -15,6 +15,11 @@ export default class Shuttle extends MCSVehicle {
15
15
  return NATURE
16
16
  }
17
17
 
18
+ /** Carriers stand at the Shuttle's bottom (fmsim v2) */
19
+ get carrierRest(): { height: number; mount: 'ON' | 'HANG' } {
20
+ return { height: 0, mount: 'ON' }
21
+ }
22
+
18
23
  get auxColor() {
19
24
  return 'black'
20
25
  }
@@ -6,7 +6,7 @@ import {
6
6
  Shape,
7
7
  sceneComponent
8
8
  } from '@hatiolab/things-scene'
9
- import { LEGEND_CARRIER } from './features/mcs-status-default'
9
+ import { LEGEND_CARRIER, legendColor } from './features/mcs-status-default'
10
10
  import { safeRound } from './utils/safe-round'
11
11
  import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin'
12
12
 
@@ -57,9 +57,9 @@ const NATURE: ComponentNature = {
57
57
  }
58
58
 
59
59
  const DEFAULT_COLORS = {
60
- full: LEGEND_CARRIER['FULL'] || '#4AA7FE',
61
- empty: LEGEND_CARRIER['EMPTY'] || '#8BDA5B',
62
- emptyEmpty: LEGEND_CARRIER['EMPTYEMPTY'] || '#019D59'
60
+ full: legendColor(LEGEND_CARRIER['FULL']) || '#4AA7FE',
61
+ empty: legendColor(LEGEND_CARRIER['EMPTY']) || '#8BDA5B',
62
+ emptyEmpty: legendColor(LEGEND_CARRIER['EMPTYEMPTY']) || '#019D59'
63
63
  }
64
64
 
65
65
  var controlHandler = {
@@ -0,0 +1,9 @@
1
+ /*
2
+ * How live data's CARRIERTYPE is compared (fmsim doc 01, 6.8): trimmed and upper-cased. The code it makes is looked up
3
+ * in the domain's item type table (ADR-0155), whose codes fmsim writes in that form.
4
+ */
5
+ export function normalizeTypeCode(code: unknown): string {
6
+ return String(code ?? '')
7
+ .trim()
8
+ .toUpperCase()
9
+ }
@@ -0,0 +1,4 @@
1
+ /* 크레인 포크 수 (설계 02번 3.2.1절 트윈 포크). 1(단일) 또는 2(트윈). 그 밖의 값은 1 */
2
+ export function craneForkCount(value: unknown): 1 | 2 {
3
+ return Number(value) === 2 ? 2 : 1
4
+ }
@@ -0,0 +1,18 @@
1
+ /*
2
+ * fmsim 고유 오류 코드의 기본 문구 (영어). 화면은 label.load-layout.issue.{코드} 번역을 먼저 쓰고, 없으면 이 문구를 쓴다.
3
+ * 층·자리 검증 코드의 문구는 공통 편집기(@operato/slot-layout-editor)의 것을 쓴다.
4
+ */
5
+
6
+ import { LayoutIssueCode } from './layout-issue'
7
+
8
+ /** 오류 코드의 기본 문구 (번역이 없을 때). 매개변수는 {name} 으로 넣는다. 자리표시자가 중괄호라서 문구에 중괄호 글자를 쓰지 않는다 */
9
+ export const ISSUE_TEXT: Record<LayoutIssueCode, string> = {
10
+ ADDRESSING_BY_INVALID: 'Must be fields or a location pattern',
11
+ FIELD_REQUIRED: 'A field name is required',
12
+ ADDRESS_PATTERN_EMPTY: 'The pattern is empty',
13
+ ADDRESS_PATTERN_SYNTAX: 'Broken token near "{text}"',
14
+ ADDRESS_PATTERN_TOKEN_REPEATED: 'The {token} token may appear only once',
15
+ ADDRESS_PATTERN_WIDTH_NOT_ALLOWED: 'Digits are allowed only on the tier and slot tokens, not on {token}',
16
+ ADDRESS_PATTERN_SLOT_REQUIRED: 'The pattern needs a slot token',
17
+ DUPLICATE_CARRIER: 'The same carrier came twice: {name}'
18
+ }
@@ -0,0 +1,41 @@
1
+ /*
2
+ * fmsim 고유 검증 결과: 실시간 주소 읽기 규칙과 점유 데이터 (설계 02번 3.6절, 4.2절)
3
+ * 층·자리 레이아웃의 검증은 figure-model 이 한다. 검증 함수는 문장을 만들지 않고 코드·필드 경로·매개변수만 돌려준다.
4
+ */
5
+
6
+ export type LayoutIssueCode =
7
+ /** FIELDS·PATTERN 중 하나 */
8
+ | 'ADDRESSING_BY_INVALID'
9
+ /** 필드 이름이 필요하다 */
10
+ | 'FIELD_REQUIRED'
11
+ /** 위치 문자열 패턴이 비었다 */
12
+ | 'ADDRESS_PATTERN_EMPTY'
13
+ /** 토큰 문법 오류 (params.text) */
14
+ | 'ADDRESS_PATTERN_SYNTAX'
15
+ /** {tier}·{slot} 은 한 번만 (params.token) */
16
+ | 'ADDRESS_PATTERN_TOKEN_REPEATED'
17
+ /** {tier}·{slot} 밖의 토큰에는 자릿수를 쓰지 않는다 (params.token) */
18
+ | 'ADDRESS_PATTERN_WIDTH_NOT_ALLOWED'
19
+ /** {slot} 이 없다 */
20
+ | 'ADDRESS_PATTERN_SLOT_REQUIRED'
21
+ /** 한 홀더에 같은 Carrier 가 두 번 왔다 (params.name) */
22
+ | 'DUPLICATE_CARRIER'
23
+
24
+ export interface LayoutIssue {
25
+ code: LayoutIssueCode
26
+ /** 값의 위치. 예 'addressing.pattern'. 값 전체이면 빈 문자열 */
27
+ path: string
28
+ params?: Record<string, string | number>
29
+ }
30
+
31
+ export function issue(code: LayoutIssueCode, path: string, params?: Record<string, string | number>): LayoutIssue {
32
+ return params ? { code, path, params } : { code, path }
33
+ }
34
+
35
+ /** 위치 문자열 패턴 문법 오류. issues 에 모든 문제가 들어 있다 */
36
+ export class AddressPatternError extends Error {
37
+ constructor(readonly issues: LayoutIssue[]) {
38
+ super(issues.map(i => i.code).join(', '))
39
+ this.name = 'AddressPatternError'
40
+ }
41
+ }