@fmsim/machine 2.0.1 → 3.0.0-beta.2

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
package/src/carrier.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import JSON5 from 'json5'
2
2
  import { getPopupData } from '@fmsim/api'
3
3
 
4
- import { BOUNDS, Component, ComponentNature, Properties, Shape, sceneComponent } from '@hatiolab/things-scene'
5
- import { ANIMATION_DEFAULT, AnimationPreset, AnimationConfig } from './features/animation-default.js'
4
+ import { BOUNDS, ComponentNature, Item, TRANSFER_SLOT_KEY, figureLegendSourcesChanged, registerFigureCapability, sceneComponent } from '@hatiolab/things-scene'
5
+ import { ANIMATION_DEFAULT, CARRIER_FIGURE, type AnimationConfig, type AnimationPreset } from '@fmsim/figure-capabilities'
6
6
  import { ParentObjectMixin } from './features/parent-object-mixin.js'
7
7
  import { Legend, resolveCarrierLegend } from './features/mcs-status-default.js'
8
8
  import { MCSStatusMixin } from './features/mcs-status-mixin.js'
9
+ import { normalizeTypeCode } from './utils/carrier-type-code'
9
10
 
10
11
  const NATURE: ComponentNature = {
11
12
  mutable: false,
@@ -28,12 +29,26 @@ const NATURE: ComponentNature = {
28
29
  */
29
30
  const STATUS_TEXT_COUNTER_ROTATE = true
30
31
 
32
+ /**
33
+ * 캐리어는 아이템이다 (공통 아이템 계약, ADR-0167 6). 컴포넌트 타입은 Carrier 그대로이고, 아이템 타입은 CARRIERTYPE 이다.
34
+ * 3D 는 그 타입이 이름한 피규어(carrier-types)가 그리고, 그리는 피규어가 없으면 fmsim 의 캐리어 상자다. 2D 는 그대로.
35
+ */
31
36
  @sceneComponent('Carrier')
32
- export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
37
+ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Item)) {
33
38
  static get nature() {
34
39
  return NATURE
35
40
  }
36
41
 
42
+ /** 아이템 타입 코드: CARRIERTYPE 을 라이브 데이터 비교 규칙대로 (fmsim 문서 01, 6.8 — trim·대문자) */
43
+ get itemTypeCode(): string {
44
+ return normalizeTypeCode(this.data?.CARRIERTYPE ?? this.state.CARRIERTYPE)
45
+ }
46
+
47
+ /** 3D 가 아이템의 것(피규어)인지: 타입의 피규어가 있을 때만. 없으면 'Carrier' 로 등록된 fmsim 상자가 지어진다 */
48
+ get drawnAsItem(): boolean {
49
+ return !!this.look
50
+ }
51
+
37
52
  get decorators() {
38
53
  return ['decotag']
39
54
  }
@@ -59,34 +74,26 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
59
74
  /** 마지막으로 적용한 (애니메이션 테마 | CARRIERSTATUS) 조합. 같은 조합 재적용을 막는다. */
60
75
  private _appliedAnimationKey?: string
61
76
 
62
- added(parent: any) {
63
- super.added(parent)
64
-
65
- // 새 부모에 맞춰 크기 및 상태 갱신 — width/height 만. depth 는 *절대로
66
- // 건드리지 않는다*. machine-3d.ts 의 CARRIER_CONFIG + MCSRealObject3D.
67
- // effectiveDepth 가 depthRatio(0.5) × min(w,h) 로 자동 결정한다.
68
- // 여기서 state.depth 를 명시 set 하면 그 resolution chain 의 step 1 에
69
- // 걸려 본래 두께가 덮어써진다 (= 사용자 호소 "두께가 줄어들었다" 의 원인).
70
- if (parent) {
71
- const { width, height } = parent.state || {}
72
- if (width && height) {
73
- this.set({ width, height })
74
- }
75
- }
77
+ /**
78
+ * 홀더가 받았다 (공통 아이템 계약 3.5 의 handover 끝). 2D 는 홀더 평면을 덮으므로 width/height 를 새 홀더에 맞춘다.
79
+ * depth 는 건드리지 않는다 — 3D 크기는 홀더가 보이는 치수(itemSeatOf().dims)로 정한다.
80
+ */
81
+ held(holder: any) {
82
+ const { width, height } = holder?.state || {}
83
+ if (width && height) this.set({ width, height })
76
84
 
77
85
  this.syncStateFromData()
78
86
 
79
87
  /* 부모가 정해진 지금 다시 해석한다 — 생성 시(data 선할당) 판정에는 parent 가 없어
80
- * 홀더의 carrierAnimationName 이 반영되지 않았다. reparent 로 홀더가 바뀐 경우도 여기서
88
+ * 홀더의 carrierAnimationName 이 반영되지 않았다. 홀더가 바뀐 경우도 여기서
81
89
  * 새 홀더 테마로 갱신된다. (같은 조합이면 내부 가드로 no-op) */
82
90
  this._applyStatusAnimation()
83
91
 
84
- // 3D 오브젝트 최초 생성 (동기적 — reparent()에서 _realObject 필요)
85
- if ((this.root as any)?.threed && !this._realObject) {
86
- ;(this.root as any).model_layer?._threeCapability?.addObject(this)
87
- }
92
+ /* 범례는 홀더가 물려준다: 새 홀더의 범례로 이 Carrier 의 피규어가 범례를 다시 고른다 (ADR-0166 결정 12) */
93
+ figureLegendSourcesChanged(this)
88
94
 
89
- // reparent 애니메이션은 things-scene의 reparent()가 처리
95
+ // 새 홀더가 보이는 치수로 3D 를 다시 만든다 (자리는 홀더가 앉힌다)
96
+ ;(this._realObject as any)?.update?.()
90
97
  }
91
98
 
92
99
  /**
@@ -103,6 +110,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
103
110
 
104
111
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
105
112
  const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
113
+ const typeChanged = (after.data?.CARRIERTYPE ?? '') !== (before.data?.CARRIERTYPE ?? '')
106
114
 
107
115
  this.setState('id', CARRIERNAME)
108
116
  this.setState('EMPTYTYPE', EMPTYTYPE)
@@ -113,6 +121,13 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
113
121
  * (테마|상태) 키 비교로 하므로 여기서 status 변경 여부를 따지지 않는다 —
114
122
  * 상태는 그대로여도 홀더가 바뀌어(=테마가 바뀌어) 재적용이 필요할 수 있다. */
115
123
  this._applyStatusAnimation()
124
+
125
+ /* 같은 Carrier 의 타입이 바뀌었다 (계약 Q10): 그 타입의 피규어가 모양이 되고, 보이는 치수로 3D 를 다시 만들고, 그 자리를 다시 앉힌다 */
126
+ if (typeChanged) this.refreshLook()
127
+ if (typeChanged && this.parent) {
128
+ ;(this._realObject as any)?.update?.()
129
+ ;(this.parent as any)._reseatSlot?.((this as any)[TRANSFER_SLOT_KEY])
130
+ }
116
131
  }
117
132
 
118
133
  /**
@@ -123,8 +138,8 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
123
138
  *
124
139
  * 호출 시점이 둘인 이유:
125
140
  * 1) onchangeData — 상태(또는 테마) 변화 반영
126
- * 2) added() — **부모가 생긴 직후**. CarrierManager 는 `carrier.data = item` 을
127
- * addComponent 보다 먼저 하므로, 생성 시 첫 판정에는 parent 가 없어 홀더의
141
+ * 2) held() — **홀더가 받은 직후**. 홀더는 data 를 넣어 Carrier 를 만든 뒤
142
+ * 받으므로, 생성 시 첫 판정에는 parent 가 없어 홀더의
128
143
  * carrierAnimationName 이 무시된다(루트 폴백만 보임). 부모가 붙은 뒤 한 번 더
129
144
  * 해석해야 홀더 테마가 반영된다.
130
145
  */
@@ -184,7 +199,7 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
184
199
  * 적용할 애니메이션 테마 이름 — 소유 홀더(부모) 설정이 우선, 없으면 보드(rootModel) 폴백.
185
200
  *
186
201
  * 부모가 아직 없는 시점(생성 직후 data 선할당)에는 홀더 설정을 알 수 없어 루트 폴백만
187
- * 보인다. 그래서 added() 에서 한 번 더 해석한다 — _applyStatusAnimation 참조.
202
+ * 보인다. 그래서 held() 에서 한 번 더 해석한다 — _applyStatusAnimation 참조.
188
203
  */
189
204
  get resolvedCarrierAnimationName(): string {
190
205
  return (
@@ -296,16 +311,12 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
296
311
  return
297
312
  }
298
313
 
299
- // 2D 에서는 stack 의 *맨 위 (= parent.components 의 마지막)* 1개만 그린다.
300
- // ADDITIONS 의 carrier 들도 인스턴스로는 살아있어 조회/reparent 가 가능하지만
301
- // 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (대표 1개만).
302
- // 판단 기준은 *위치* — carrier 간 그룹/종속 정보는 두지 않는다.
303
- const parent = this.parent
304
- if (parent && parent.components && parent.components.length > 1) {
305
- const siblings = parent.components
306
- if (siblings[siblings.length - 1] !== this) {
307
- return
308
- }
314
+ // 2D 에서는 홀더 데이터 stack 의 *맨 위 (MULTICARRIERS 다음 CARRIERNAME 순서의 마지막)* 1개만 그린다.
315
+ // 다른 carrier 들도 인스턴스로는 살아있어 조회·이동이 가능하지만
316
+ // 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (1개만). 판단 기준은 위치 — carrier 간 그룹/종속 정보는 두지 않는다.
317
+ const top = (this.parent as any)?.topCarrier
318
+ if (top && top !== this) {
319
+ return
309
320
  }
310
321
 
311
322
  const { width, height } = this.bounds
@@ -362,11 +373,6 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
362
373
  }
363
374
  }
364
375
 
365
- dispose(): void {
366
- ;(this.root as any)?.carrierManager?.onCarrierDisposed(this.state.id)
367
-
368
- super.dispose()
369
- }
370
376
 
371
377
  async detailInfo() {
372
378
  const { type, id } = this.state
@@ -378,3 +384,6 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
378
384
  return await getPopupData(type, id)
379
385
  }
380
386
  }
387
+
388
+ /* fmsim.Carrier (ADR-0167 12): what it gives a figure is its tables, handed by its descriptor -- no mixin of its own */
389
+ registerFigureCapability(CARRIER_FIGURE, base => class extends base {})
package/src/crane.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Component, ComponentNature, sceneComponent, frameClock } from '@hatiolab/things-scene'
2
2
  import MCSVehicle from './mcs-vehicle'
3
3
  import { LEGEND_CRANE } from './features/mcs-status-default'
4
+ import { LoadLayoutHolder } from './features/load-layout-holder'
5
+ import { HOLDER_ANCHOR, forkAnchorId } from '@hatiolab/figure-model'
6
+ import { craneForkCount } from './utils/crane-forks'
4
7
  import {
5
8
  axisRatio,
6
9
  mapCraneLocalPosition,
@@ -12,9 +15,18 @@ const NATURE: ComponentNature = {
12
15
  mutable: false,
13
16
  resizable: true,
14
17
  rotatable: true,
15
- properties: [...MCSVehicle.properties]
18
+ properties: [
19
+ ...MCSVehicle.properties,
20
+ {
21
+ type: 'select',
22
+ label: 'fork-count',
23
+ name: 'forkCount',
24
+ property: { options: ['1', '2'] }
25
+ }
26
+ ]
16
27
  }
17
28
 
29
+
18
30
  const GAP = 2
19
31
 
20
32
  /** 좌표 보간 기본 duration (ms). data.DURATION_MS 가 없으면 이 값 사용. */
@@ -41,11 +53,36 @@ interface CranePositionAnimator {
41
53
  }
42
54
 
43
55
  @sceneComponent('Crane')
44
- export default class Crane extends MCSVehicle {
56
+ export default class Crane extends LoadLayoutHolder(MCSVehicle) {
45
57
  static get nature() {
46
58
  return NATURE
47
59
  }
48
60
 
61
+ /**
62
+ * 크레인이 주는 Anchor (설계 02번 3.7.2절): 홀더 원점과 포크마다 하나(fork_1, fork_2). 포크가 하나뿐인 장비에
63
+ * fork_2 에 선 Tier 가 있으면 레이아웃 전체가 잘못된 값이다: Carrier 를 자리 없이 한 무더기로 쌓고 패널에 오류를 보인다
64
+ * (ADR-0051 4.1절 결정 8).
65
+ */
66
+ slotAnchors(): string[] {
67
+ const forks = craneForkCount((this.state as any).forkCount)
68
+ return [HOLDER_ANCHOR, ...Array.from({ length: forks }, (_, i) => forkAnchorId(i + 1))]
69
+ }
70
+
71
+ /** 포크 Anchor 는 크레인 3D 의 그 포크 판 윗면 노드다. 포크가 오르내리고 뻗으면 그 위의 Carrier 가 함께 간다 */
72
+ anchorObject3D(anchorId: string): any {
73
+ return (this as any)._realObject?.forkAnchor?.(anchorId) ?? super.anchorObject3D(anchorId)
74
+ }
75
+
76
+ /** 포크 수가 바뀌면 주는 Anchor 가 바뀌므로, 데이터가 다시 오지 않아도 지금 데이터로 자리를 다시 배정한다 */
77
+ onchange(after: Record<string, any>, before: Record<string, any>): void {
78
+ super.onchange(after, before)
79
+ if ('forkCount' in after && craneForkCount(after.forkCount) !== craneForkCount(before?.forkCount)) {
80
+ ;(this as any)._carrierRecordsTaken = undefined
81
+ ;(this as any).syncCarriers(this.data || {})
82
+ ;(this as any)._carriersSeatedAgain()
83
+ }
84
+ }
85
+
49
86
  get auxColor() {
50
87
  return 'black'
51
88
  }
@@ -1,5 +1,7 @@
1
1
  import './ox-input-nodes'
2
2
  import './ox-property-editor-nodes'
3
+ // 속성 패널의 "Carrier 적재 자리" 구역 (board specifics 가 적재 레이아웃 홀더를 선택했을 때 쓴다)
4
+ import './ox-load-layout-panel'
3
5
 
4
6
  export default [
5
7
  {
@@ -0,0 +1,121 @@
1
+ /*
2
+ * fmsim 이 공통 편집기(@operato/slot-layout-editor)에 주는 것 (설계 02번 3.7.1절)
3
+ * Tier·Slot 편집, 검증, 미리보기, 적용 전 주소 영향은 공통 편집기가 한다. 기종 프리셋·실효 레이아웃·검증은 figure-model 의
4
+ * 것이다(ADR-0051 4.1절 결정 8). 이 패널은 도면 노드의 배치 덮어쓰기 화면이고, fmsim 은 자기 몫만 넘긴다:
5
+ * 프리셋 이름, 홀더가 주는 Anchor, 값의 출처·도면 단위 안내, 실시간 주소 읽기 설정과 그 검증, fmsim 고유 오류 문구.
6
+ * 화면 요소를 만들지 않는 순수 모듈이다 — 문구 함수(t)와 공통 문구 함수는 부르는 쪽이 준다.
7
+ */
8
+
9
+ import type { SlotLayoutEditorOptions, SlotLayoutPreset } from '@operato/slot-layout-editor'
10
+ import {
11
+ figureSlotLayoutIssues,
12
+ slotLayoutPreset,
13
+ SLOT_LAYOUT_PRESET_KEYS,
14
+ SLOT_LAYOUT_PRESET_PARAMS,
15
+ type LayoutIssue as SlotLayoutIssue,
16
+ type SlotLayoutPresetKey,
17
+ type SlotMount
18
+ } from '@hatiolab/figure-model'
19
+ import { LoadAddressing, LoadLayout, validateAddressing } from '../utils/load-address'
20
+ import { LayoutIssue, LayoutIssueCode } from '../utils/layout-issue'
21
+ import { ISSUE_TEXT } from '../utils/layout-issue-text'
22
+
23
+ export type Translate = (key: string, fallback: string, options?: Record<string, unknown>) => string
24
+
25
+ /** 문제 하나: 코드·값의 위치·매개변수 */
26
+ export type IssueLike = { code: string; path: string; params?: Record<string, string | number> }
27
+
28
+ /** 공통 편집기의 문제 문구 (층·자리 검증 코드) */
29
+ export type CommonIssueText = (issue: IssueLike) => string
30
+
31
+ // ── 기종 프리셋 (3.7절, figure-model) ─────────────────────────────────────────
32
+
33
+ const PRESET_LABELS: Record<SlotLayoutPresetKey, string> = {
34
+ single: 'Single slot',
35
+ agvTandem: 'AGV tandem (front/rear)',
36
+ agvParallel: 'AGV parallel (left/right)',
37
+ agv2x2: 'AGV 2×2',
38
+ bufferPort2x2: 'Buffer port 2×2',
39
+ multiBufferPort: 'Multi-buffer port (4)',
40
+ dualTierConveyor: 'Dual-tier conveyor',
41
+ craneTwinFork: 'Crane twin fork',
42
+ floorBlock: 'Floor block'
43
+ }
44
+
45
+ const PARAM_LABELS: Record<string, string> = {
46
+ upperElevation: 'Upper tier elevation (mm)',
47
+ rows: 'Rows',
48
+ cols: 'Columns',
49
+ stackLimit: 'Stack limit'
50
+ }
51
+
52
+ /** 공통 편집기의 프리셋 목록. 홀더 원점·포크 Anchor 위에 펼치고, 단일 자리는 홀더의 적재 방식으로 만든다 */
53
+ export function fmsimPresets(mount: SlotMount, t: Translate): SlotLayoutPreset<LoadLayout>[] {
54
+ return SLOT_LAYOUT_PRESET_KEYS.map(key => ({
55
+ key,
56
+ label: t(`preset-${key}`, PRESET_LABELS[key]),
57
+ params: SLOT_LAYOUT_PRESET_PARAMS[key].map(p => ({ key: p.key, label: t(`param-${p.key}`, PARAM_LABELS[p.key]), integer: p.integer })),
58
+ build: (size, params) => slotLayoutPreset(key, size, params, { mount })
59
+ }))
60
+ }
61
+
62
+ // ── 실시간 주소 읽기 (3.6절) ────────────────────────────────────────────────
63
+
64
+ export function setAddressing(layout: LoadLayout, addressing: LoadAddressing | undefined): LoadLayout {
65
+ const next = structuredClone(layout)
66
+ if (addressing) next.addressing = structuredClone(addressing)
67
+ else delete next.addressing
68
+ return next
69
+ }
70
+
71
+ // ── 편집기 옵션 ─────────────────────────────────────────────────────────────
72
+
73
+ /** fmsim 고유 오류(주소 읽기 등)의 문구. fmsim 코드가 아니면 undefined — 공통 편집기 문구를 쓴다 */
74
+ export function fmsimIssueText(issue: IssueLike, t: Translate): string | undefined {
75
+ const fallback = ISSUE_TEXT[issue.code as LayoutIssueCode]
76
+ if (!fallback) return undefined
77
+ const text = t(`issue.${issue.code}`, fallback, { ...issue.params, interpolation: { escapeValue: false } })
78
+ return issue.path ? `${issue.path}: ${text}` : text
79
+ }
80
+
81
+ /**
82
+ * 공통 편집기가 보는 것 밖의 검증: 실효 레이아웃이 덮어쓰기에 거는 figure 규칙(받는 타입은 생략하면 모든 아이템, 적으면
83
+ * 비지 않은 목록, ADR-0051 4.1절 결정 5·7)과 주소 읽기 설정. 공통 검증 결과는 편집기가 이미 보이므로 figure 고유 코드만 더한다.
84
+ */
85
+ export function holderLayoutIssues(layout: LoadLayout, anchors: readonly string[]): (SlotLayoutIssue | LayoutIssue)[] {
86
+ return [...figureSlotLayoutIssues(layout, anchors).filter(i => i.code === 'ACCEPTS_EMPTY'), ...validateAddressing(layout.addressing)]
87
+ }
88
+
89
+ /** 선택한 홀더(LoadLayoutHolder)에서 공통 편집기 옵션을 만든다. value 는 도면 노드의 개별 설정 */
90
+ export function holderEditorOptions(holder: any, value: LoadLayout | undefined, t: Translate, commonText: CommonIssueText): SlotLayoutEditorOptions<LoadLayout> {
91
+ const sel = holder.effectiveLayout
92
+ const mm = holder.mmPerUnit
93
+ const state = holder.state || {}
94
+ const anchors: string[] = holder.slotAnchors?.() ?? []
95
+ const source: Record<string, string> = {
96
+ override: t('source-node', 'Set on this holder'),
97
+ figure: t('source-builtin', 'Built-in default'),
98
+ none: t('source-none', 'Not defined (one slot, stacked)')
99
+ }
100
+ const notes: SlotLayoutEditorOptions['notes'] = [{ text: `${t('source', 'Source')}: ${source[sel.source]}` }]
101
+ if (sel.status === 'invalid') {
102
+ notes.push({ level: 'error', text: t('invalid', 'The saved setting is invalid. Shown as one slot until fixed.') })
103
+ for (const e of sel.issues as SlotLayoutIssue[]) notes.push({ level: 'error', text: commonText(e) })
104
+ }
105
+ notes.push(
106
+ mm?.known
107
+ ? { text: t('unit-ok', 'Drawing unit 1 = {mm} mm', { mm: mm.mmPerUnit }) }
108
+ : { level: 'error', text: t('unit-missing', 'Drawing unit is not set (mmPerUnit). Physical placement and fit checks are unavailable.') }
109
+ )
110
+ return {
111
+ // 개별 설정이 없으면 실효 레이아웃(기본값)에서 편집을 시작한다
112
+ effective: value === undefined && sel.status === 'valid' ? sel.layout : undefined,
113
+ canUseDefault: value !== undefined,
114
+ presets: fmsimPresets(holder.loadMount ?? 'ON', t),
115
+ presetSize: mm?.known ? { width: Math.abs(state.width || 0) * mm.mmPerUnit, depth: Math.abs(state.height || 0) * mm.mmPerUnit } : {},
116
+ notes,
117
+ anchors: anchors.map(id => ({ id })),
118
+ validate: (layout: LoadLayout) => holderLayoutIssues(layout, anchors),
119
+ issueText: issue => fmsimIssueText(issue, t)
120
+ }
121
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @license Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * "Carrier 적재 자리" 구역 (설계 02번 3.7.1절)
5
+ * 공통 편집기(@operato/slot-layout-editor 의 ox-slot-layout-editor)를 띄우고 fmsim 의 몫만 넘긴다:
6
+ * 프리셋 이름, 홀더가 주는 Anchor, 값의 출처·도면 단위, 실시간 주소 읽기 설정, fmsim 고유 오류 문구 (load-layout-host).
7
+ * 이 화면은 도면 노드의 배치 덮어쓰기다. 프리셋·실효 레이아웃·검증은 figure-model 의 것이다 (ADR-0051 4.1절 결정 8).
8
+ * [적용]이면 고친 값을, [기본값 사용]이면 개별 설정을 지운 값(undefined)을 도면 노드에 넘긴다. 실행 중 점유는 저장하지 않는다.
9
+ *
10
+ * 속성 패널(board specifics)에서 custom-editor · value-key="slotLayout" 로 쓴다. value 를 바꾸고 change 를 보낸다.
11
+ */
12
+
13
+ import { css, html, nothing, LitElement } from 'lit'
14
+ import { customElement, property } from 'lit/decorators.js'
15
+ import { i18next, localize } from '@operato/i18n'
16
+ // 패키지 루트가 ox-slot-layout-editor 를 정의한다 (fmsim 의 moduleResolution 은 하위 경로 exports 를 풀지 않는다)
17
+ import { issueText as commonIssueText } from '@operato/slot-layout-editor'
18
+
19
+ import { LoadAddressing, LoadLayout } from '../utils/load-address'
20
+ import { holderEditorOptions, setAddressing } from './load-layout-host'
21
+
22
+ const t = (key: string, fallback: string, opts: Record<string, unknown> = {}) =>
23
+ i18next.t(`label.load-layout.${key}`, { defaultValue: fallback, ...opts })
24
+
25
+ @customElement('ox-load-layout-panel')
26
+ export class OxLoadLayoutPanel extends localize(i18next)(LitElement) {
27
+ static styles = css`
28
+ :host {
29
+ display: block;
30
+ font-size: 12px;
31
+ }
32
+ h4 {
33
+ margin: 6px 0 2px;
34
+ font-size: 12px;
35
+ }
36
+ .row {
37
+ display: grid;
38
+ grid-template-columns: 40% 1fr;
39
+ align-items: center;
40
+ gap: 4px;
41
+ }
42
+ input,
43
+ select {
44
+ font-size: 12px;
45
+ min-width: 0;
46
+ }
47
+ `
48
+
49
+ /** 선택한 홀더 (LoadLayoutHolder) */
50
+ @property({ attribute: false }) holder: any
51
+ /** 도면 노드의 개별 설정 (state.slotLayout) */
52
+ @property({ attribute: false }) value: LoadLayout | undefined
53
+
54
+ render() {
55
+ if (!this.holder?.effectiveLayout) return nothing
56
+ const options = {
57
+ ...holderEditorOptions(this.holder, this.value, t, commonIssueText),
58
+ renderExtra: (draft: LoadLayout, edit: (next: LoadLayout) => void) => this._renderAddressing(draft, edit)
59
+ }
60
+ return html`<ox-slot-layout-editor
61
+ .value=${this.value}
62
+ .options=${options}
63
+ @change=${this._apply}
64
+ @slot-layout-use-default=${this._useDefault}
65
+ ></ox-slot-layout-editor>`
66
+ }
67
+
68
+ /** 공통 편집기의 change 는 여기서 받고, 속성 패널에는 이 요소의 change 로 다시 알린다 (value-key 가 이 요소에 있다) */
69
+ private _apply(e: Event) {
70
+ e.stopPropagation()
71
+ this.value = structuredClone((e.target as any).value)
72
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
73
+ }
74
+
75
+ /** 기본값 사용: 개별 설정을 지운다. 값을 비우는 편집과 다르다 */
76
+ private _useDefault(e: Event) {
77
+ e.stopPropagation()
78
+ this.value = undefined
79
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
80
+ }
81
+
82
+ /** 실시간 데이터에서 주소를 읽는 규칙 (3.6절). 공통 편집기의 renderExtra 자리에 그린다 */
83
+ private _renderAddressing(draft: LoadLayout, edit: (next: LoadLayout) => void) {
84
+ const a = draft.addressing
85
+ const set = (next: LoadAddressing | undefined) => edit(setAddressing(draft, next))
86
+ const text = (field: string, label: string, value: string | undefined, onChange: (v: string) => void, placeholder = '') => html`
87
+ <div class="row">
88
+ <label>${label}</label>
89
+ <input
90
+ data-field=${field}
91
+ .value=${value ?? ''}
92
+ placeholder=${placeholder}
93
+ @change=${(e: Event) => onChange((e.target as HTMLInputElement).value.trim())}
94
+ />
95
+ </div>
96
+ `
97
+ return html`
98
+ <h4>${t('addressing', 'Address in live data')}</h4>
99
+ <div class="row">
100
+ <label>${t('addressing-by', 'Read by')}</label>
101
+ <select
102
+ data-field="addressing-by"
103
+ @change=${(e: Event) => {
104
+ const by = (e.target as HTMLSelectElement).value
105
+ set(
106
+ by === 'FIELDS'
107
+ ? { by, tierField: 'TIER', slotField: 'SLOT' }
108
+ : by === 'PATTERN'
109
+ ? { by, field: 'CARRIERLOC', pattern: '{slot}' }
110
+ : undefined
111
+ )
112
+ }}
113
+ >
114
+ <option value="" ?selected=${!a}>${t('addressing-none', 'Not read (all shown as address unknown)')}</option>
115
+ <option value="FIELDS" ?selected=${a?.by === 'FIELDS'}>${t('addressing-fields', 'Fields')}</option>
116
+ <option value="PATTERN" ?selected=${a?.by === 'PATTERN'}>${t('addressing-pattern', 'Location pattern')}</option>
117
+ </select>
118
+ </div>
119
+ ${a?.by === 'FIELDS'
120
+ ? html`${text('tier-field', t('tier-field', 'Tier field'), a.tierField, v => set({ ...a, tierField: v || undefined }), t('tier-field-empty', 'Empty = tier 1'))}
121
+ ${text('slot-field', t('slot-field', 'Slot field'), a.slotField, v => set({ ...a, slotField: v }))}`
122
+ : nothing}
123
+ ${a?.by === 'PATTERN'
124
+ ? html`${text('field', t('field', 'Field'), a.field, v => set({ ...a, field: v }))}
125
+ ${text('pattern', t('pattern-text', 'Pattern'), a.pattern, v => set({ ...a, pattern: v }), '{rack}-{slot:02}-{tier:02}')}`
126
+ : nothing}
127
+ `
128
+ }
129
+ }
@@ -16,6 +16,8 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti
16
16
  import { RealObjectGroup, registerRealObjectFactory, MaterialBank } from '@hatiolab/things-scene'
17
17
  import type { Component } from '@hatiolab/things-scene'
18
18
  import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
19
+ import { craneForkCount } from '../utils/crane-forks'
20
+ import { forkAnchorId } from '@hatiolab/figure-model'
19
21
 
20
22
  const CLAMP_COLOR = 0x444444
21
23
  const FORK_COLOR = 0x999999
@@ -28,6 +30,12 @@ export class Crane3D extends RealObjectGroup {
28
30
  private forkGroup?: THREE.Group
29
31
  private forkMastBaseY = 0
30
32
  private forkMastH = 0
33
+ /** 포크별 Anchor 노드 (fork_1, fork_2). 캐리지(forkGroup) 아래에서 각자 뻗는다 */
34
+ private forkNodes = new Map<string, THREE.Group>()
35
+ /** 포크 판 윗면 높이 (포크 노드 로컬). Carrier 는 이 면에 놓인다 */
36
+ private forkSurfaceY = 0
37
+ /** 포크가 끝까지(100) 뻗었을 때의 거리 (+Z, 도면 y 방향) */
38
+ private forkReach = 0
31
39
 
32
40
  get effectiveDepth(): number {
33
41
  const { depth } = this.component.state
@@ -45,6 +53,35 @@ export class Crane3D extends RealObjectGroup {
45
53
  this.forkGroup.position.y = this.forkMastBaseY + (clamped / 100) * this.forkMastH
46
54
  }
47
55
 
56
+ /** 포크 n 을 0~100 만큼 뻗는다 (다른 포크는 그대로) */
57
+ applyForkExtend(fork: number, extend: number): void {
58
+ const node = this.forkNodes.get(forkAnchorId(fork))
59
+ if (!node) return
60
+ const clamped = Math.max(0, Math.min(100, Number(extend) || 0))
61
+ node.position.z = (clamped / 100) * this.forkReach
62
+ }
63
+
64
+ /** 포크 Anchor 노드: 포크 판 윗면의 중심. 포크 노드 아래에 있어 포크가 오르내리고 뻗으면 함께 간다 */
65
+ private forkAnchors = new Map<string, THREE.Group>()
66
+
67
+ /**
68
+ * A fork's anchor (common item contract 3.4): a node at the centre of its plate's top, under the fork, so what it
69
+ * holds goes up, down and out with it. Null when the crane has no such fork.
70
+ */
71
+ forkAnchor(anchorId: string): THREE.Object3D | null {
72
+ const fork = this.forkNodes.get(anchorId)
73
+ if (!fork) return null
74
+ let node = this.forkAnchors.get(anchorId)
75
+ if (!node || node.parent !== fork) {
76
+ node = new THREE.Group()
77
+ node.name = `${anchorId}-anchor`
78
+ fork.add(node)
79
+ this.forkAnchors.set(anchorId, node)
80
+ }
81
+ node.position.set(0, this.forkSurfaceY, 0)
82
+ return node
83
+ }
84
+
48
85
  private resolveColor(): number {
49
86
  const comp = this.component as any
50
87
 
@@ -131,45 +168,60 @@ export class Crane3D extends RealObjectGroup {
131
168
  // 일 때 mast 바닥 자리에. forkZ 0~100 에 따라 group.position.y 만 변경하면
132
169
  // mast 따라 위/아래 이동. mesh 재구축 불필요.
133
170
  const forkMat = MaterialBank.material('crane|fork', { roughness: 0.4, metalness: 0.5 }, FORK_COLOR)
171
+ const forkCount = craneForkCount((this.component.state as any).forkCount)
134
172
 
135
- const forkGeometries: THREE.BufferGeometry[] = []
136
-
137
- const plateW = w * 0.9
138
173
  const plateH = d * 0.08
139
174
  const plateD = pillarThick * 0.8
140
- const centerPlate = new THREE.BoxGeometry(plateW, plateH, plateD)
141
- centerPlate.translate(0, plateH / 2, 0) // local 0 = mast 바닥
142
- forkGeometries.push(centerPlate)
143
-
144
175
  const armW = pillarThick * 0.6
145
176
  const armH = plateH * 0.4
146
177
  const armD = h * 0.6
147
- const armSpacing = w * 0.5
148
178
 
149
- for (const zSign of [-1, 1]) {
150
- for (const xSign of [-1, 1]) {
151
- const arm = new THREE.BoxGeometry(armW, armH, armD)
152
- arm.translate(xSign * (armSpacing / 2), armH / 2, zSign * (plateD / 2 + armD / 2))
153
- forkGeometries.push(arm)
179
+ /* 포크 하나: 가로 판 + 판 앞뒤로 뻗은 팔 두 쌍. 로컬 0 = 판 바닥, 중심 x = 0 */
180
+ const buildFork = (plateW: number) => {
181
+ const parts: THREE.BufferGeometry[] = []
182
+ const plate = new THREE.BoxGeometry(plateW, plateH, plateD)
183
+ plate.translate(0, plateH / 2, 0)
184
+ parts.push(plate)
185
+ const armSpacing = plateW * 0.55
186
+ for (const zSign of [-1, 1]) {
187
+ for (const xSign of [-1, 1]) {
188
+ const arm = new THREE.BoxGeometry(armW, armH, armD)
189
+ arm.translate(xSign * (armSpacing / 2), armH / 2, zSign * (plateD / 2 + armD / 2))
190
+ parts.push(arm)
191
+ }
154
192
  }
193
+ const mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(parts), forkMat)
194
+ mesh.castShadow = true
195
+ return mesh
155
196
  }
156
197
 
157
- const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat)
158
- forkMesh.castShadow = true
159
-
198
+ // 캐리지: forkZ 로 오르내린다. 포크는 캐리지 안에서 각자 Z 로 뻗는다 (트윈 포크의 독립 이동)
160
199
  const forkGroup = new THREE.Group()
161
- forkGroup.add(forkMesh)
162
200
  inner.add(forkGroup)
201
+ this.forkNodes.clear()
202
+ const forkW = forkCount === 2 ? w * 0.42 : w * 0.9
203
+ for (let i = 1; i <= forkCount; i++) {
204
+ const node = new THREE.Group()
205
+ node.name = forkAnchorId(i)
206
+ node.position.x = forkCount === 2 ? (i === 1 ? -1 : 1) * w * 0.24 : 0
207
+ node.add(buildFork(forkW))
208
+ forkGroup.add(node)
209
+ this.forkNodes.set(node.name, node)
210
+ }
163
211
  this.forkGroup = forkGroup
164
212
  this.forkMastBaseY = mastBaseY
213
+ this.forkSurfaceY = plateH
214
+ this.forkReach = h * 0.8
165
215
  // forkZ=100 시점에 mast 의 *_위쪽 한계*. 포크 자체 두께 (plateH) 만큼은 마스트
166
216
  // 안에 머무르도록 limit 보정 — 그렇지 않으면 forkZ=100 일 때 포크가 상단
167
217
  // 클램프와 충돌. mastH * 0.92 로 약간 보수적 (= 마스트 본체 92% 까지만 이동).
168
218
  this.forkMastH = mastH * 0.92
169
219
 
170
- // 초기 forkZ 반영 — state.forkZ 가 이미 set 되어 있으면 그 위치에 배치
171
- const initialForkZ = Number((this.component.state as any).forkZ)
220
+ // 초기 forkZ·포크 뻗음 반영 — state 에 이미 있으면 그 위치에 배치
221
+ const state = this.component.state as any
222
+ const initialForkZ = Number(state.forkZ)
172
223
  this.applyForkZ(Number.isFinite(initialForkZ) ? initialForkZ : 0)
224
+ for (let i = 1; i <= forkCount; i++) this.applyForkExtend(i, Number(state[`forkExtend${i}`]) || 0)
173
225
 
174
226
  // ── 4. 상단 클램프 (천장 레일에 물리는 가이드부) ──
175
227
  const topClampGeo = new THREE.BoxGeometry(clampW, clampH, clampD)
@@ -188,14 +240,16 @@ export class Crane3D extends RealObjectGroup {
188
240
  /** build() 직전 호출. forkGroup 참조를 깨끗이 비워둔다 — 새 build 가 다시 set. */
189
241
  clear() {
190
242
  this.forkGroup = undefined
243
+ this.forkNodes.clear()
191
244
  super.clear()
192
245
  }
193
246
 
194
247
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
195
- if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
248
+ if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after || 'forkCount' in after) {
196
249
  this.update()
197
250
  return
198
251
  }
252
+ for (const fork of [1, 2]) if (`forkExtend${fork}` in after) this.applyForkExtend(fork, Number(after[`forkExtend${fork}`]) || 0)
199
253
  // forkZ 는 mesh 재구축 없이 group.position.y 만 갱신. 같은 onchange 에서
200
254
  // left/top 도 함께 변경될 수 있으므로 super 도 반드시 호출해 본체 transform
201
255
  // 갱신 path (state.left/top → obj3d.position.x/z) 가 동작하게 함.