@fmsim/machine 2.0.0-beta.86 → 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/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.86",
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": "2e7fe85f5e286c28c9fb3b5c095a23c8c150b41d"
58
+ "gitHead": "3d1cc31e42d90dc514506aa1e85b34660144d89f"
59
59
  }
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. 캐리지 포크 (마스트 40% 높이, 양쪽으로 뻗는 포크 암) ──
119
- const forkY = mastBaseY + mastH * 0.4
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, forkY + plateH / 2, 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), forkY + armH / 2, zSign * (plateD / 2 + armD / 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
- inner.add(forkMesh)
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
  }