@fmsim/machine 2.0.0-beta.85 → 2.0.0-beta.87
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.
- package/dist/carrier-manager.js +123 -37
- package/dist/carrier-manager.js.map +1 -1
- package/dist/carrier.js +16 -1
- package/dist/carrier.js.map +1 -1
- package/dist/crane.js +134 -1
- package/dist/crane.js.map +1 -1
- package/dist/factories/crane-3d.js +45 -7
- package/dist/factories/crane-3d.js.map +1 -1
- package/dist/factories/machine-3d.js +41 -2
- package/dist/factories/machine-3d.js.map +1 -1
- package/dist/features/mcs-status-default.js +2 -1
- package/dist/features/mcs-status-default.js.map +1 -1
- package/dist/mcs-carrier-holder.js +39 -26
- package/dist/mcs-carrier-holder.js.map +1 -1
- package/dist/scene/root-container-override.js +55 -100
- package/dist/scene/root-container-override.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/carrier-manager.ts +143 -41
- package/src/carrier.ts +18 -1
- package/src/crane.ts +171 -1
- package/src/factories/crane-3d.ts +50 -7
- package/src/factories/machine-3d.ts +39 -1
- package/src/features/mcs-status-default.ts +2 -1
- package/src/mcs-carrier-holder.ts +40 -27
- package/src/scene/root-container-override.ts +48 -113
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@fmsim/machine",
|
|
3
3
|
"description": "Layout View를 구성하는 설비(machine)들과 유닛을 구현한 모듈입니다.",
|
|
4
4
|
"author": "heartyoh",
|
|
5
|
-
"version": "2.0.0-beta.
|
|
5
|
+
"version": "2.0.0-beta.87",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.js",
|
|
8
8
|
"things-scene": true,
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"prettier --write"
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "3d1cc31e42d90dc514506aa1e85b34660144d89f"
|
|
59
59
|
}
|
package/src/carrier-manager.ts
CHANGED
|
@@ -2,7 +2,30 @@ import { Model } from '@hatiolab/things-scene'
|
|
|
2
2
|
import type Carrier from './carrier'
|
|
3
3
|
import type MCSCarrierHolder from './mcs-carrier-holder'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// 사라진 carrier 의 dispose 지연. 같은 macrotask 안에서 holder A 의 사라짐
|
|
6
|
+
// 보고와 holder B 의 등장 보고가 순차로 들어오는 reparent 시나리오 (= 같은
|
|
7
|
+
// frame snapshot 의 여러 record 적용) 를 보호하기 위해 setTimeout(0) 의
|
|
8
|
+
// next-macrotask delay 만 둔다. 과거 1000ms grace 가 있었으나 seek 시 2단계
|
|
9
|
+
// 갱신 (1초 후 carrier 사라짐) 부작용이 명확하고 frame 누락 bouncing 깜빡임
|
|
10
|
+
// 보호 효용은 작다 판단되어 0 으로 단축 (2026-06-09).
|
|
11
|
+
const DYING_TIMEOUT = 0
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Carrier 가 들고 있는 4 키 묶음. data.CARRIERNAME 의 형제 키 (CARRIERTYPE,
|
|
15
|
+
* EMPTYTYPE, CARRIERSTATUS) 를 한 item 으로 묶는다. holder.data 의 *대표*
|
|
16
|
+
* 와 ADDITIONS 의 *각 항목* 모두 동일 shape 으로 정규화돼 stack diff 에
|
|
17
|
+
* 들어간다.
|
|
18
|
+
*
|
|
19
|
+
* 주의 — 외부 시스템이 ADDITIONS 의 각 항목 키를 다른 이름으로 (예:
|
|
20
|
+
* NAME / TYPE / EMPTY / STATUS) 줄 가능성에 대비해 이 build 함수가 단일
|
|
21
|
+
* 정규화 지점이 된다. 키 매핑 변경은 여기 한 곳만 손대면 된다.
|
|
22
|
+
*/
|
|
23
|
+
export interface CarrierStackItem {
|
|
24
|
+
CARRIERNAME: string
|
|
25
|
+
CARRIERTYPE?: string
|
|
26
|
+
EMPTYTYPE?: string
|
|
27
|
+
CARRIERSTATUS?: string
|
|
28
|
+
}
|
|
6
29
|
|
|
7
30
|
export default class CarrierManager {
|
|
8
31
|
private carriers: Map<string, Carrier> = new Map()
|
|
@@ -17,58 +40,137 @@ export default class CarrierManager {
|
|
|
17
40
|
return this.carriers.get(carrierName)
|
|
18
41
|
}
|
|
19
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Holder 의 data 변경에 대응해 carrier pool 을 동기화.
|
|
45
|
+
*
|
|
46
|
+
* 정책:
|
|
47
|
+
* - holder.data 가 *single source of truth*. 대표(CARRIERNAME) +
|
|
48
|
+
* ADDITIONS[] 를 하나의 *stack list* 로 정규화한 뒤 before/after 의
|
|
49
|
+
* 이름 set diff 로 추가/제거/유지 분기.
|
|
50
|
+
* - "대표" 와 "ADDITIONS" 는 *holder.data 안의 키 위치 차이일 뿐* 이고,
|
|
51
|
+
* carrier 인스턴스끼리는 **종속/그룹 관계 없음 — 모두 독립 동등**.
|
|
52
|
+
* stack 안의 위치 (stackIndex) 만 carrier.state 에 주입한다. 2D render
|
|
53
|
+
* skip 등의 판정은 *위치 (= parent.components 의 마지막 여부)* 로 한다.
|
|
54
|
+
* - 동일 CARRIERNAME 이 *다른 holder* 에 새로 등장하면 reparent (애니메이션).
|
|
55
|
+
* *해당 carrier 만 이동* — 같은 stack 의 다른 carrier 는 따라가지 않는다.
|
|
56
|
+
* - 사라진 carrier 는 next-macrotask 에 dispose. 같은 macrotask 안에서 다른
|
|
57
|
+
* holder 가 같은 이름을 보고하면 cancel 되어 reparent 로 전환.
|
|
58
|
+
*/
|
|
20
59
|
update(holder: MCSCarrierHolder, after: Record<string, any>, before: Record<string, any>): void {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
60
|
+
const afterStack = this._buildStack(after)
|
|
61
|
+
const beforeStack = this._buildStack(before)
|
|
62
|
+
|
|
63
|
+
const afterNames = new Set(afterStack.map(i => i.CARRIERNAME))
|
|
64
|
+
const beforeNames = new Set(beforeStack.map(i => i.CARRIERNAME))
|
|
23
65
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
66
|
+
// 1) 사라진 것 → dying schedule (자기 holder 의 자식인 carrier 만)
|
|
67
|
+
for (const name of beforeNames) {
|
|
68
|
+
if (afterNames.has(name)) continue
|
|
69
|
+
const carrier = this.carriers.get(name)
|
|
27
70
|
if (carrier && carrier.parent === holder) {
|
|
28
|
-
this.scheduleDying(
|
|
71
|
+
this.scheduleDying(name)
|
|
29
72
|
}
|
|
30
|
-
return
|
|
31
73
|
}
|
|
32
74
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
75
|
+
// 2) afterStack 의 *순서대로* 처리 — bottom (ADDITIONS[0]) 부터 top (대표)
|
|
76
|
+
// 그래야 parent.components 가 자연스럽게 같은 순서로 정렬되어 stack index
|
|
77
|
+
// 가 일관된다.
|
|
78
|
+
afterStack.forEach((item, idx) => {
|
|
79
|
+
const name = item.CARRIERNAME
|
|
80
|
+
this.cancelDying(name)
|
|
81
|
+
|
|
82
|
+
let carrier = this.carriers.get(name)
|
|
83
|
+
const { width, height } = holder.state
|
|
84
|
+
|
|
85
|
+
if (!carrier) {
|
|
86
|
+
carrier = Model.compile(
|
|
87
|
+
{
|
|
88
|
+
id: name,
|
|
89
|
+
type: 'Carrier',
|
|
90
|
+
left: 0,
|
|
91
|
+
top: 0,
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
showText: holder.getState('showCarrierText') || false,
|
|
95
|
+
stackIndex: idx
|
|
96
|
+
},
|
|
97
|
+
this.root.app
|
|
98
|
+
) as unknown as Carrier
|
|
99
|
+
|
|
100
|
+
this.carriers.set(name, carrier)
|
|
101
|
+
carrier.data = item
|
|
102
|
+
holder.addComponent(carrier, { silent: true })
|
|
103
|
+
} else if (carrier.parent !== holder && !beforeNames.has(name)) {
|
|
104
|
+
// 다른 holder 에 있던 carrier 가 이번 update 에서 *새로 등장* 한 경우만
|
|
105
|
+
// reparent. before 에도 같은 이름이 있었으면 ping-pong (옛 시점의
|
|
106
|
+
// 반복 보고) 이므로 trigger 하지 않는다. (F05 의 bouncing 방지)
|
|
107
|
+
;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (carrier.parent === holder) {
|
|
111
|
+
carrier.setState('stackIndex', idx)
|
|
112
|
+
carrier.data = item
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// *_모든 carrier 추가 완료 후* — 각자의 transform 강제 갱신. 한 줄로 처리
|
|
117
|
+
// 안 되는 이유: cz override 의 offset 계산이 *형제 carrier 의 _realObject.
|
|
118
|
+
// effectiveDepth 누적*에 의존하는데, 첫 iteration 시점엔 자기 뒤 carrier 의
|
|
119
|
+
// _realObject 가 아직 없어 offset 이 부족하게 평가될 수 있다. 모두 빌드된
|
|
120
|
+
// 뒤 한 번 더 갱신해야 적층 정확.
|
|
121
|
+
afterStack.forEach(item => {
|
|
122
|
+
const carrier = this.carriers.get(item.CARRIERNAME)
|
|
123
|
+
if (carrier && carrier.parent === holder) {
|
|
124
|
+
;(carrier as any)._realObject?.updateTransform?.()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
36
128
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
},
|
|
56
|
-
this.root.app
|
|
57
|
-
) as unknown as Carrier
|
|
58
|
-
|
|
59
|
-
this.carriers.set(afterName, carrier)
|
|
60
|
-
carrier.data = after
|
|
61
|
-
holder.addComponent(carrier, { silent: true })
|
|
62
|
-
} else if (carrier.parent !== holder && (!beforeName && afterName)) {
|
|
63
|
-
// CARRIERNAME이 새로 나타남 (이전엔 없었음) → 다른 holder로 이동
|
|
64
|
-
;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
|
|
129
|
+
/**
|
|
130
|
+
* data.CARRIERNAME (대표) + data.ADDITIONS[] 를 하나의 stack list 로 정규화.
|
|
131
|
+
* 적층 순서: ADDITIONS[0..n-1] (bottom → up) + 대표 (top, 맨 마지막).
|
|
132
|
+
*/
|
|
133
|
+
private _buildStack(data: any): CarrierStackItem[] {
|
|
134
|
+
if (!data) return []
|
|
135
|
+
|
|
136
|
+
const stack: CarrierStackItem[] = []
|
|
137
|
+
|
|
138
|
+
if (Array.isArray(data.ADDITIONS)) {
|
|
139
|
+
for (const a of data.ADDITIONS) {
|
|
140
|
+
if (a && typeof a === 'object' && a.CARRIERNAME) {
|
|
141
|
+
// 항목 *원본 그대로* — 외부 시스템이 임의 키를 추가로 보낼 수 있고,
|
|
142
|
+
// 누락된 4 키는 default 없이 그대로 비워둔다 (carrier.data 에 흘리는
|
|
143
|
+
// 가공 최소화 — F03 같은 기존 테스트의 raw shape 기대와도 정합).
|
|
144
|
+
stack.push({ ...a } as CarrierStackItem)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
65
147
|
}
|
|
66
148
|
|
|
67
|
-
if (
|
|
68
|
-
|
|
149
|
+
if (data.CARRIERNAME) {
|
|
150
|
+
// 대표는 *holder.data 의 carrier 관련 키만 추출* — showText / carrierDepth
|
|
151
|
+
// 같은 holder 자체 속성은 carrier 로 흘리지 않는다. 명시되지 않은 키는
|
|
152
|
+
// 생략 (default 안 채움).
|
|
153
|
+
const repr: any = { CARRIERNAME: data.CARRIERNAME }
|
|
154
|
+
if ('CARRIERTYPE' in data) repr.CARRIERTYPE = data.CARRIERTYPE
|
|
155
|
+
if ('EMPTYTYPE' in data) repr.EMPTYTYPE = data.EMPTYTYPE
|
|
156
|
+
if ('CARRIERSTATUS' in data) repr.CARRIERSTATUS = data.CARRIERSTATUS
|
|
157
|
+
stack.push(repr)
|
|
69
158
|
}
|
|
159
|
+
|
|
160
|
+
return stack
|
|
70
161
|
}
|
|
71
162
|
|
|
163
|
+
/**
|
|
164
|
+
* carrier dispose 를 *next macrotask* 로 미룬다. 동기 즉시 dispose 가 아닌
|
|
165
|
+
* 이유: 같은 macrotask 안에서 holder A 의 사라짐 보고가 step 1 에 들어오고
|
|
166
|
+
* holder B 의 등장 보고가 (별도 carrier-manager.update 호출의) step 2 에
|
|
167
|
+
* 들어오는 reparent 시나리오를 보호하기 위함. 즉시 dispose 면 step 1 에서
|
|
168
|
+
* carrier 인스턴스가 파괴돼 step 2 의 reparent 분기가 *새 인스턴스 생성*
|
|
169
|
+
* 으로 떨어지고, 3D mesh 재구축 + 이동 애니메이션 손실이 발생.
|
|
170
|
+
*
|
|
171
|
+
* 같은 macrotask 안에서 cancelDying 호출이 도달하면 clearTimeout 으로
|
|
172
|
+
* dispose 회수 → reparent 정상.
|
|
173
|
+
*/
|
|
72
174
|
private scheduleDying(carrierName: string): void {
|
|
73
175
|
this.cancelDying(carrierName)
|
|
74
176
|
|
package/src/carrier.ts
CHANGED
|
@@ -13,6 +13,7 @@ const NATURE: ComponentNature = {
|
|
|
13
13
|
rotatable: false
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
/**
|
|
17
18
|
* Feature flag — keep status text (F / E / X) screen-axis-aligned even
|
|
18
19
|
* when the carrier (or its parent holder, or an in-flight reparent
|
|
@@ -59,7 +60,11 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
59
60
|
added(parent: any) {
|
|
60
61
|
super.added(parent)
|
|
61
62
|
|
|
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
|
+
// 걸려 본래 두께가 덮어써진다 (= 사용자 호소 "두께가 줄어들었다" 의 원인).
|
|
63
68
|
if (parent) {
|
|
64
69
|
const { width, height } = parent.state || {}
|
|
65
70
|
if (width && height) {
|
|
@@ -270,6 +275,18 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
|
|
|
270
275
|
return
|
|
271
276
|
}
|
|
272
277
|
|
|
278
|
+
// 2D 에서는 stack 의 *맨 위 (= parent.components 의 마지막)* 1개만 그린다.
|
|
279
|
+
// ADDITIONS 의 carrier 들도 인스턴스로는 살아있어 조회/reparent 가 가능하지만
|
|
280
|
+
// 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (대표 1개만).
|
|
281
|
+
// 판단 기준은 *위치* — carrier 간 그룹/종속 정보는 두지 않는다.
|
|
282
|
+
const parent = this.parent
|
|
283
|
+
if (parent && parent.components && parent.components.length > 1) {
|
|
284
|
+
const siblings = parent.components
|
|
285
|
+
if (siblings[siblings.length - 1] !== this) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
273
290
|
const { width, height } = this.bounds
|
|
274
291
|
const path = this.path
|
|
275
292
|
const round = Math.round(Math.min(width, height) * 0.1)
|
package/src/crane.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Component, ComponentNature, sceneComponent } from '@hatiolab/things-scene'
|
|
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
4
|
|
|
@@ -11,6 +11,33 @@ const NATURE: ComponentNature = {
|
|
|
11
11
|
|
|
12
12
|
const GAP = 2
|
|
13
13
|
|
|
14
|
+
/** 좌표 보간 기본 duration (ms). data.DURATION_MS 가 없으면 이 값 사용. */
|
|
15
|
+
const DEFAULT_DURATION_MS = 1000
|
|
16
|
+
|
|
17
|
+
function clamp01(v: number): number {
|
|
18
|
+
return Math.max(0, Math.min(100, Number(v) || 0))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lerp(a: number, b: number, t: number): number {
|
|
22
|
+
return a + (b - a) * t
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function easeInOut(t: number): number {
|
|
26
|
+
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CranePositionAnimator {
|
|
30
|
+
fromLeft: number
|
|
31
|
+
fromTop: number
|
|
32
|
+
fromForkZ: number
|
|
33
|
+
toLeft: number
|
|
34
|
+
toTop: number
|
|
35
|
+
toForkZ: number
|
|
36
|
+
startTime: number
|
|
37
|
+
duration: number
|
|
38
|
+
unsubscribe: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
@sceneComponent('Crane')
|
|
15
42
|
export default class Crane extends MCSVehicle {
|
|
16
43
|
static get nature() {
|
|
@@ -25,6 +52,149 @@ export default class Crane extends MCSVehicle {
|
|
|
25
52
|
return (this.root as any).craneLegendTheme || super.getLegendFallback()
|
|
26
53
|
}
|
|
27
54
|
|
|
55
|
+
private _posAnimator?: CranePositionAnimator
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* data.POSITION = { X, Y, Z } (각 0~100 정규화) 분기. Stocker 부모 기준으로
|
|
59
|
+
* 실 좌표 (state.left/top) 와 forkZ 로 변환해 lerp 시작. 보간 중 새 값이
|
|
60
|
+
* 들어오면 *현재 보간 위치* 를 from 으로 잡고 새 target 으로 새 lerp 시작.
|
|
61
|
+
* NODEID 경로는 super (MCSVehicle.onchangeData) 가 처리 — POSITION 만 있는
|
|
62
|
+
* 데이터에선 NODEID 가 없어 자연스럽게 noop.
|
|
63
|
+
*/
|
|
64
|
+
onchangeData(after: Record<string, any>, before: Record<string, any>): void {
|
|
65
|
+
const afterData = after?.data
|
|
66
|
+
const beforeData = before?.data
|
|
67
|
+
const afterPos = afterData?.POSITION
|
|
68
|
+
if (afterPos && typeof afterPos === 'object') {
|
|
69
|
+
const beforePos = beforeData?.POSITION
|
|
70
|
+
const changed =
|
|
71
|
+
afterPos.X !== beforePos?.X ||
|
|
72
|
+
afterPos.Y !== beforePos?.Y ||
|
|
73
|
+
afterPos.Z !== beforePos?.Z
|
|
74
|
+
if (changed) {
|
|
75
|
+
this._startPositionAnimation(afterPos, afterData?.DURATION_MS)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
super.onchangeData(after, before)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dispose() {
|
|
82
|
+
if (this._posAnimator) {
|
|
83
|
+
this._posAnimator.unsubscribe()
|
|
84
|
+
this._posAnimator = undefined
|
|
85
|
+
}
|
|
86
|
+
super.dispose()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private _startPositionAnimation(
|
|
90
|
+
pos: { X: number; Y: number; Z: number },
|
|
91
|
+
durationMs?: number
|
|
92
|
+
): void {
|
|
93
|
+
const target = this._normalizedToReal(pos)
|
|
94
|
+
if (!target) return // 부모가 Stocker 아니면 변환 skip
|
|
95
|
+
|
|
96
|
+
const now = frameClock.simTime
|
|
97
|
+
const duration = Math.max(1, Number(durationMs) || DEFAULT_DURATION_MS)
|
|
98
|
+
|
|
99
|
+
// 현재 위치 — 보간 중이면 그 시점 보간값을 from 으로
|
|
100
|
+
let fromLeft = Number(this.state.left) || 0
|
|
101
|
+
let fromTop = Number(this.state.top) || 0
|
|
102
|
+
let fromForkZ = Number((this.state as any).forkZ) || 0
|
|
103
|
+
|
|
104
|
+
if (this._posAnimator) {
|
|
105
|
+
const a = this._posAnimator
|
|
106
|
+
const t = Math.min(1, Math.max(0, (now - a.startTime) / a.duration))
|
|
107
|
+
const e = easeInOut(t)
|
|
108
|
+
fromLeft = lerp(a.fromLeft, a.toLeft, e)
|
|
109
|
+
fromTop = lerp(a.fromTop, a.toTop, e)
|
|
110
|
+
fromForkZ = lerp(a.fromForkZ, a.toForkZ, e)
|
|
111
|
+
a.unsubscribe()
|
|
112
|
+
this._posAnimator = undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const unsubscribe = frameClock.subscribe(
|
|
116
|
+
(_dt: number, simTime: number) => this._tickPositionAnimation(simTime),
|
|
117
|
+
{ description: 'crane-position', component: this }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
this._posAnimator = {
|
|
121
|
+
fromLeft,
|
|
122
|
+
fromTop,
|
|
123
|
+
fromForkZ,
|
|
124
|
+
toLeft: target.left,
|
|
125
|
+
toTop: target.top,
|
|
126
|
+
toForkZ: target.forkZ,
|
|
127
|
+
startTime: now,
|
|
128
|
+
duration,
|
|
129
|
+
unsubscribe
|
|
130
|
+
}
|
|
131
|
+
// 즉시 첫 frame 반영 (t=0) — set 한 번 호출. paused 환경에서도 from→target
|
|
132
|
+
// 진입 시점이 명확.
|
|
133
|
+
this._tickPositionAnimation(now)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private _tickPositionAnimation(simTime: number): void {
|
|
137
|
+
const a = this._posAnimator
|
|
138
|
+
if (!a) return
|
|
139
|
+
const t = Math.min(1, Math.max(0, (simTime - a.startTime) / a.duration))
|
|
140
|
+
const e = easeInOut(t)
|
|
141
|
+
this.set({
|
|
142
|
+
left: lerp(a.fromLeft, a.toLeft, e),
|
|
143
|
+
top: lerp(a.fromTop, a.toTop, e),
|
|
144
|
+
forkZ: lerp(a.fromForkZ, a.toForkZ, e)
|
|
145
|
+
})
|
|
146
|
+
if (t >= 1) {
|
|
147
|
+
a.unsubscribe()
|
|
148
|
+
this._posAnimator = undefined
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 0~100 정규화 좌표 → state.left/top + forkZ.
|
|
154
|
+
*
|
|
155
|
+
* 부모 오브젝트 (보통은 Stocker 이지만 다른 컨테이너일 수도) 의 width/height
|
|
156
|
+
* 만 사용해 좌표 매핑 — 타입 무관 일반화. 가상 rail 은 host 의 긴쪽 중심부
|
|
157
|
+
* 가정:
|
|
158
|
+
* - 데이터.X → host 의 긴쪽 (자동 판별: max(width, height))
|
|
159
|
+
* - 데이터.Y → 짧은쪽 (rail 중심 = 50 근처가 일반적)
|
|
160
|
+
* - 데이터.Z → forkZ (state 에 0~100 그대로 저장, 3D 측에서 mast 비율로 매핑)
|
|
161
|
+
*
|
|
162
|
+
* 참조는 ParentObjectMixin 이 제공하는 `parentObject` 사용 —
|
|
163
|
+
* state.parentObjectId 명시 시 그것, 미명시 시 this.parent 자동 fallback.
|
|
164
|
+
*/
|
|
165
|
+
private _normalizedToReal(
|
|
166
|
+
pos: { X: number; Y: number; Z: number }
|
|
167
|
+
): { left: number; top: number; forkZ: number } | undefined {
|
|
168
|
+
const host: any = (this as any).parentObject
|
|
169
|
+
if (!host) return undefined
|
|
170
|
+
|
|
171
|
+
const hw = Number(host.state?.width) || 0
|
|
172
|
+
const hh = Number(host.state?.height) || 0
|
|
173
|
+
const cw = Number(this.state.width) || 0
|
|
174
|
+
const ch = Number(this.state.height) || 0
|
|
175
|
+
if (!hw || !hh) return undefined
|
|
176
|
+
|
|
177
|
+
const longIsWidth = hw >= hh
|
|
178
|
+
const longLen = longIsWidth ? hw : hh
|
|
179
|
+
const shortLen = longIsWidth ? hh : hw
|
|
180
|
+
const craneLongSize = longIsWidth ? cw : ch
|
|
181
|
+
const craneShortSize = longIsWidth ? ch : cw
|
|
182
|
+
|
|
183
|
+
const X = clamp01(pos.X)
|
|
184
|
+
const Y = clamp01(pos.Y)
|
|
185
|
+
|
|
186
|
+
// crane 의 *center* 가 (X/100)*longLen, (Y/100)*shortLen 에 오도록.
|
|
187
|
+
// state.left/top 은 top-left 기준이므로 size/2 차감.
|
|
188
|
+
const longPos = (X / 100) * longLen - craneLongSize / 2
|
|
189
|
+
const shortPos = (Y / 100) * shortLen - craneShortSize / 2
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
left: longIsWidth ? longPos : shortPos,
|
|
193
|
+
top: longIsWidth ? shortPos : longPos,
|
|
194
|
+
forkZ: clamp01(pos.Z)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
28
198
|
render(ctx: CanvasRenderingContext2D) {
|
|
29
199
|
var { left, top, width, height } = this.bounds
|
|
30
200
|
const { x: cx, y: cy } = this.center
|
|
@@ -22,11 +22,29 @@ const FORK_COLOR = 0x999999
|
|
|
22
22
|
const DEFAULT_BODY_COLOR = 0xcc8844
|
|
23
23
|
|
|
24
24
|
export class Crane3D extends RealObjectGroup {
|
|
25
|
+
/** 캐리지 포크 group — Crane.state.forkZ (0~100) 가 변하면 position.y 만 lerp.
|
|
26
|
+
* build() 시 inner 에 add 되고, *_mastBaseY 와 mastH 를 함께 보관* 해서
|
|
27
|
+
* setForkZ 가 비율 계산에 사용. */
|
|
28
|
+
private forkGroup?: THREE.Group
|
|
29
|
+
private forkMastBaseY = 0
|
|
30
|
+
private forkMastH = 0
|
|
31
|
+
|
|
25
32
|
get effectiveDepth(): number {
|
|
26
33
|
const { depth } = this.component.state
|
|
27
34
|
return depth || 5 * TRANSPORT_SURFACE_HEIGHT - 2
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 0~100 정규화 z (= state.forkZ) 를 mast 높이 비율로 변환해서 포크 group 의
|
|
39
|
+
* three-up y 위치 갱신. build() 가 안 끝났으면 noop — build 가 forkZ 를 다시
|
|
40
|
+
* 반영하면서 끝난다.
|
|
41
|
+
*/
|
|
42
|
+
applyForkZ(z: number): void {
|
|
43
|
+
if (!this.forkGroup) return
|
|
44
|
+
const clamped = Math.max(0, Math.min(100, Number(z) || 0))
|
|
45
|
+
this.forkGroup.position.y = this.forkMastBaseY + (clamped / 100) * this.forkMastH
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
private resolveColor(): number {
|
|
31
49
|
const comp = this.component as any
|
|
32
50
|
|
|
@@ -115,8 +133,10 @@ export class Crane3D extends RealObjectGroup {
|
|
|
115
133
|
mastMesh.castShadow = true
|
|
116
134
|
inner.add(mastMesh)
|
|
117
135
|
|
|
118
|
-
// ── 3. 캐리지 포크 (
|
|
119
|
-
|
|
136
|
+
// ── 3. 캐리지 포크 (별도 group — state.forkZ 변경 시 group.position.y lerp) ──
|
|
137
|
+
// 포크 geometry 는 *_mastBaseY 기준 local 0 에 둠* — 즉 group.position.y = 0
|
|
138
|
+
// 일 때 mast 바닥 자리에. forkZ 0~100 에 따라 group.position.y 만 변경하면
|
|
139
|
+
// mast 따라 위/아래 이동. mesh 재구축 불필요.
|
|
120
140
|
const forkMat = new THREE.MeshStandardMaterial({
|
|
121
141
|
color: FORK_COLOR,
|
|
122
142
|
roughness: 0.4,
|
|
@@ -125,15 +145,13 @@ export class Crane3D extends RealObjectGroup {
|
|
|
125
145
|
|
|
126
146
|
const forkGeometries: THREE.BufferGeometry[] = []
|
|
127
147
|
|
|
128
|
-
// 캐리지 중심 플레이트 (마스트에 부착, 양쪽 포크의 베이스)
|
|
129
148
|
const plateW = w * 0.9
|
|
130
149
|
const plateH = d * 0.08
|
|
131
150
|
const plateD = pillarThick * 0.8
|
|
132
151
|
const centerPlate = new THREE.BoxGeometry(plateW, plateH, plateD)
|
|
133
|
-
centerPlate.translate(0,
|
|
152
|
+
centerPlate.translate(0, plateH / 2, 0) // local 0 = mast 바닥
|
|
134
153
|
forkGeometries.push(centerPlate)
|
|
135
154
|
|
|
136
|
-
// 양쪽 포크 암 (좌/우 선반 방향으로 뻗음)
|
|
137
155
|
const armW = pillarThick * 0.6
|
|
138
156
|
const armH = plateH * 0.4
|
|
139
157
|
const armD = h * 0.6
|
|
@@ -142,14 +160,27 @@ export class Crane3D extends RealObjectGroup {
|
|
|
142
160
|
for (const zSign of [-1, 1]) {
|
|
143
161
|
for (const xSign of [-1, 1]) {
|
|
144
162
|
const arm = new THREE.BoxGeometry(armW, armH, armD)
|
|
145
|
-
arm.translate(xSign * (armSpacing / 2),
|
|
163
|
+
arm.translate(xSign * (armSpacing / 2), armH / 2, zSign * (plateD / 2 + armD / 2))
|
|
146
164
|
forkGeometries.push(arm)
|
|
147
165
|
}
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat)
|
|
151
169
|
forkMesh.castShadow = true
|
|
152
|
-
|
|
170
|
+
|
|
171
|
+
const forkGroup = new THREE.Group()
|
|
172
|
+
forkGroup.add(forkMesh)
|
|
173
|
+
inner.add(forkGroup)
|
|
174
|
+
this.forkGroup = forkGroup
|
|
175
|
+
this.forkMastBaseY = mastBaseY
|
|
176
|
+
// forkZ=100 시점에 mast 의 *_위쪽 한계*. 포크 자체 두께 (plateH) 만큼은 마스트
|
|
177
|
+
// 안에 머무르도록 limit 보정 — 그렇지 않으면 forkZ=100 일 때 포크가 상단
|
|
178
|
+
// 클램프와 충돌. mastH * 0.92 로 약간 보수적 (= 마스트 본체 92% 까지만 이동).
|
|
179
|
+
this.forkMastH = mastH * 0.92
|
|
180
|
+
|
|
181
|
+
// 초기 forkZ 반영 — state.forkZ 가 이미 set 되어 있으면 그 위치에 배치
|
|
182
|
+
const initialForkZ = Number((this.component.state as any).forkZ)
|
|
183
|
+
this.applyForkZ(Number.isFinite(initialForkZ) ? initialForkZ : 0)
|
|
153
184
|
|
|
154
185
|
// ── 4. 상단 클램프 (천장 레일에 물리는 가이드부) ──
|
|
155
186
|
const topClampGeo = new THREE.BoxGeometry(clampW, clampH, clampD)
|
|
@@ -162,11 +193,23 @@ export class Crane3D extends RealObjectGroup {
|
|
|
162
193
|
updateDimension() {}
|
|
163
194
|
updateAlpha() {}
|
|
164
195
|
|
|
196
|
+
/** build() 직전 호출. forkGroup 참조를 깨끗이 비워둔다 — 새 build 가 다시 set. */
|
|
197
|
+
clear() {
|
|
198
|
+
this.forkGroup = undefined
|
|
199
|
+
super.clear()
|
|
200
|
+
}
|
|
201
|
+
|
|
165
202
|
onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
|
|
166
203
|
if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
|
|
167
204
|
this.update()
|
|
168
205
|
return
|
|
169
206
|
}
|
|
207
|
+
// forkZ 는 mesh 재구축 없이 group.position.y 만 갱신. 같은 onchange 에서
|
|
208
|
+
// left/top 도 함께 변경될 수 있으므로 super 도 반드시 호출해 본체 transform
|
|
209
|
+
// 갱신 path (state.left/top → obj3d.position.x/z) 가 동작하게 함.
|
|
210
|
+
if ('forkZ' in after) {
|
|
211
|
+
this.applyForkZ(Number(after.forkZ) || 0)
|
|
212
|
+
}
|
|
170
213
|
super.onchange(after, before)
|
|
171
214
|
}
|
|
172
215
|
}
|
|
@@ -770,7 +770,7 @@ class StockerRealObject3D extends MCSRealObject3D {
|
|
|
770
770
|
const max = Number(data.MAXCAPACITY) || 100
|
|
771
771
|
const fullup = data.FULLUP === 'T'
|
|
772
772
|
const usage = Math.round((cur / max) * 100)
|
|
773
|
-
const legend = (this.component as any).root?.
|
|
773
|
+
const legend = (this.component as any).root?.stockerCapacityLegendTheme || LEGEND_CAPACITY
|
|
774
774
|
const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
|
|
775
775
|
return {
|
|
776
776
|
segments: [{ ratio: usage / 100, color }],
|
|
@@ -852,10 +852,48 @@ const CARRIER_CONFIG: MCS3DConfig = {
|
|
|
852
852
|
// Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정.
|
|
853
853
|
}
|
|
854
854
|
|
|
855
|
+
/**
|
|
856
|
+
* 적층 시 다음 carrier 의 z-position 이 *_이전 carrier 의 effectiveDepth × N*
|
|
857
|
+
* 만큼 위에 놓인다. N=1 이면 carrier 들이 맞붙어 적층 (기존), N<1 이면 부분
|
|
858
|
+
* overlap (3D box 들이 겹쳐서 적층). 사용자 의도 — carrier 두께는 두꺼워도
|
|
859
|
+
* 적층 시 *_compact 하게 보이도록* — N 을 작게.
|
|
860
|
+
*/
|
|
861
|
+
const CARRIER_STACK_STEP_RATIO = 0.9
|
|
862
|
+
|
|
855
863
|
class CarrierRealObject3D extends MCSRealObject3D {
|
|
856
864
|
constructor(component: Component) {
|
|
857
865
|
super(component, CARRIER_CONFIG)
|
|
858
866
|
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* cz override — *carrier 자기 결정* 의 적층 위치.
|
|
870
|
+
* parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth
|
|
871
|
+
* 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고,
|
|
872
|
+
* carrier 가 자기 stack 위치를 알아서 결정.
|
|
873
|
+
*/
|
|
874
|
+
override get cz(): number {
|
|
875
|
+
const base = super.cz
|
|
876
|
+
const parent = this.component.parent as any
|
|
877
|
+
if (!parent?.components) return base
|
|
878
|
+
const siblings = parent.components as any[]
|
|
879
|
+
const idx = siblings.indexOf(this.component)
|
|
880
|
+
if (idx <= 0) return base
|
|
881
|
+
let offset = 0
|
|
882
|
+
for (let i = 0; i < idx; i++) {
|
|
883
|
+
const ro = siblings[i]?._realObject
|
|
884
|
+
const eff = Number(ro?.effectiveDepth)
|
|
885
|
+
const d = Number.isFinite(eff) && eff > 0 ? eff : 1
|
|
886
|
+
offset += d * CARRIER_STACK_STEP_RATIO
|
|
887
|
+
}
|
|
888
|
+
// 적층 방향 — placement (= 부모 holder 타입) 에 따라 결정.
|
|
889
|
+
// OHT 처럼 그리퍼 아래에 매달리는 holder 는 carrier 가 *_아래로_* 누적,
|
|
890
|
+
// 그 외 (Port / Shelf / Shuttle / Vehicle) 는 위로 누적.
|
|
891
|
+
// MCSRealObject3D.syncZPosOffset 의 OHT 분기 (-effectiveDepth + halfD) 와
|
|
892
|
+
// 같은 방향성 일관 적용.
|
|
893
|
+
const parentType = parent?.state?.type
|
|
894
|
+
const dir = parentType === 'OHT' ? -1 : 1
|
|
895
|
+
return base + dir * offset
|
|
896
|
+
}
|
|
859
897
|
}
|
|
860
898
|
|
|
861
899
|
registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
|
|
@@ -64,7 +64,8 @@ export const LEGEND_CARRIER: Legend = {
|
|
|
64
64
|
export const LEGEND_CAPACITY: Legend = {
|
|
65
65
|
'0~20': '#5630D0',
|
|
66
66
|
'20~80': '#8BDA5B',
|
|
67
|
-
'80~
|
|
67
|
+
'80~': '#FD9B00', // 80% 이상 (= 100% 초과도 포함). 'from~' 형식이라
|
|
68
|
+
// getValueOnRanges 가 value >= 80 매칭.
|
|
68
69
|
default: '#F0F0F0'
|
|
69
70
|
}
|
|
70
71
|
|