@fmsim/machine 2.0.0-beta.17 → 2.0.0-beta.19

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.17",
5
+ "version": "2.0.0-beta.19",
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": "59cdf8c2138451e3aa443cdbc2d0d2c4b560a9ba"
58
+ "gitHead": "29a0fc9444f6eec0961401d979fd21fdfd34e041"
59
59
  }
package/src/carrier.ts CHANGED
@@ -163,8 +163,14 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
163
163
  }
164
164
 
165
165
  // 캐시에 없으면 파싱하고 저장
166
- const theme = (this.root as any)?.style?.[carrierAnimationName]
166
+ let theme = (this.root as any)?.style?.[carrierAnimationName]
167
167
  if (theme) {
168
+ // theme이 string이면 먼저 파싱
169
+ if (typeof theme === 'string') {
170
+ try { theme = JSON5.parse(theme) } catch { return ANIMATION_DEFAULT }
171
+ }
172
+ if (typeof theme !== 'object' || theme === null) return ANIMATION_DEFAULT
173
+
168
174
  // 문자열 형태의 설정들을 미리 파싱
169
175
  const parsedTheme = {}
170
176
  for (const [status, config] of Object.entries(theme)) {
@@ -59,9 +59,19 @@ export class AGV3D extends RealObjectGroup {
59
59
  return DEFAULT_BODY_COLOR
60
60
  }
61
61
 
62
+ private _dataHandler?: () => void
63
+
62
64
  build() {
63
65
  super.build()
64
66
 
67
+ // data 변경 감지 (배터리 잔량 업데이트)
68
+ if (!this._dataHandler) {
69
+ this._dataHandler = (_after: any, _before: any) => {
70
+ if (_after.data !== undefined) this.updateBattery()
71
+ }
72
+ this.component.on('change', this._dataHandler)
73
+ }
74
+
65
75
  const { width = 10, height = 10 } = this.component.state
66
76
  const w = Math.abs(width)
67
77
  const h = Math.abs(height)
@@ -118,9 +128,127 @@ export class AGV3D extends RealObjectGroup {
118
128
  plat.receiveShadow = true
119
129
 
120
130
  this._ownMeshes = [bumper, body, plat]
131
+
132
+ // ── 배터리 실린더 (바디 뒷면, BATTERYRATE 데이터가 있을 때만) ──
133
+ if (this.getBatteryRate() > 0) {
134
+ const batteryMeshes = this.buildBattery(w, h, bumperH, bodyH)
135
+ this._ownMeshes.push(...batteryMeshes)
136
+ }
137
+
121
138
  for (const m of this._ownMeshes) this.object3d.add(m)
122
139
  }
123
140
 
141
+ private _batteryFillMesh?: THREE.Mesh
142
+ private _batteryLen = 0
143
+ private _batteryY = 0
144
+ private _batteryZ = 0
145
+
146
+ private buildBattery(agvW: number, agvH: number, bodyBottomY: number, bodyH: number): THREE.Mesh[] {
147
+ const radius = Math.min(agvW, agvH) * 0.15
148
+ this._batteryLen = agvW * 0.7
149
+ this._batteryY = bodyBottomY + bodyH * 0.5
150
+ this._batteryZ = -agvH / 2 - radius * 1.5
151
+
152
+ const rate = this.getBatteryRate()
153
+ const batteryColor = this.getBatteryColor(rate)
154
+ const innerRadius = radius * 0.85
155
+ const capWidth = radius * 0.3
156
+ const meshes: THREE.Mesh[] = []
157
+
158
+ // 투명 외장 (유리 쉘, 양단 막힘)
159
+ const shellGeo = new THREE.CylinderGeometry(radius, radius, this._batteryLen, 20)
160
+ shellGeo.rotateZ(Math.PI / 2)
161
+ const shellMat = new THREE.MeshStandardMaterial({
162
+ color: 0xffffff,
163
+ transparent: true,
164
+ opacity: 0.25,
165
+ roughness: 0.1,
166
+ metalness: 0.1,
167
+ side: THREE.DoubleSide,
168
+ depthWrite: false,
169
+ })
170
+ const shell = new THREE.Mesh(shellGeo, shellMat)
171
+ shell.position.set(0, this._batteryY, this._batteryZ)
172
+ meshes.push(shell)
173
+
174
+ // 좌측 캡 (금속)
175
+ const capGeo = new THREE.CylinderGeometry(radius * 0.6, radius, capWidth, 20)
176
+ capGeo.rotateZ(Math.PI / 2)
177
+ const capMat = new THREE.MeshStandardMaterial({
178
+ color: batteryColor, roughness: 0.3, metalness: 0.7,
179
+ emissive: batteryColor, emissiveIntensity: 0.2,
180
+ })
181
+ const leftCap = new THREE.Mesh(capGeo, capMat)
182
+ leftCap.position.set(-this._batteryLen / 2 - capWidth / 2, this._batteryY, this._batteryZ)
183
+ meshes.push(leftCap)
184
+
185
+ // 우측 캡 (금속)
186
+ const rightCapGeo = capGeo.clone()
187
+ const rightCap = new THREE.Mesh(rightCapGeo, capMat)
188
+ rightCap.position.set(this._batteryLen / 2 + capWidth / 2, this._batteryY, this._batteryZ)
189
+ rightCap.rotation.z = Math.PI
190
+ meshes.push(rightCap)
191
+
192
+ // 내부 잔량/빈 부분
193
+ const fillLen = Math.max(this._batteryLen * rate / 100, 0.1)
194
+ const emptyLen = Math.max(this._batteryLen - fillLen, 0.1)
195
+
196
+ // 빈 부분 (짙은 회색, 불투명, 양단 닫힘)
197
+ const emptyGeo = new THREE.CylinderGeometry(innerRadius, innerRadius, emptyLen, 16)
198
+ emptyGeo.rotateZ(Math.PI / 2)
199
+ const emptyMat = new THREE.MeshStandardMaterial({
200
+ color: 0x333333,
201
+ roughness: 0.6,
202
+ metalness: 0.2,
203
+ side: THREE.DoubleSide,
204
+ })
205
+ const emptyMesh = new THREE.Mesh(emptyGeo, emptyMat)
206
+ emptyMesh.position.set(
207
+ (this._batteryLen - emptyLen) / 2,
208
+ this._batteryY, this._batteryZ
209
+ )
210
+ meshes.push(emptyMesh)
211
+
212
+ // 잔량 채움 (상태 컬러)
213
+ const fillGeo = new THREE.CylinderGeometry(innerRadius * 0.98, innerRadius * 0.98, fillLen, 16)
214
+ fillGeo.rotateZ(Math.PI / 2)
215
+ const fillMat = new THREE.MeshStandardMaterial({
216
+ color: batteryColor,
217
+ roughness: 0.3,
218
+ metalness: 0.1,
219
+ emissive: batteryColor,
220
+ emissiveIntensity: 0.4,
221
+ side: THREE.DoubleSide,
222
+ })
223
+ this._batteryFillMesh = new THREE.Mesh(fillGeo, fillMat)
224
+ this._batteryFillMesh.position.set(
225
+ -(this._batteryLen - fillLen) / 2,
226
+ this._batteryY, this._batteryZ
227
+ )
228
+ meshes.push(this._batteryFillMesh)
229
+
230
+ return meshes
231
+ }
232
+
233
+ private getBatteryRate(): number {
234
+ const data = (this.component as any).data
235
+ return typeof data?.BATTERYRATE === 'number' ? data.BATTERYRATE : 0
236
+ }
237
+
238
+ private getBatteryColor(rate: number): number {
239
+ try {
240
+ const comp = this.component as any
241
+ if (comp.getBatteryColor) {
242
+ const color = comp.getBatteryColor(rate)
243
+ if (color) return new THREE.Color(color).getHex()
244
+ }
245
+ } catch { /* fallback */ }
246
+ // 기본: 녹색 > 노랑 > 빨강
247
+ if (rate > 50) return 0x4caf50
248
+ if (rate > 20) return 0xffc107
249
+ return 0xf44336
250
+ }
251
+
124
252
  updateDimension() {}
125
253
 
126
254
  update() {
@@ -150,12 +278,19 @@ export class AGV3D extends RealObjectGroup {
150
278
 
151
279
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
152
280
  if ('width' in after || 'height' in after || 'depth' in after ||
153
- 'fillStyle' in after || 'strokeStyle' in after || 'id' in after) {
281
+ 'fillStyle' in after || 'strokeStyle' in after || 'id' in after ||
282
+ 'EMPTYTYPE' in after || 'CARRIERSTATUS' in after) {
154
283
  this.update()
155
284
  return
156
285
  }
286
+
157
287
  super.onchange(after, before)
158
288
  }
289
+
290
+ private updateBattery() {
291
+ // 전체 rebuild로 정확한 geometry 생성
292
+ this.update()
293
+ }
159
294
  }
160
295
 
161
296
  registerRealObjectFactory('AGV', (component: Component) => new AGV3D(component))
@@ -30,7 +30,7 @@ interface MCS3DConfig {
30
30
  /** statusColor 미사용 시 기본 색상 */
31
31
  defaultColor: number
32
32
  /** 'box' | 'hexagon' */
33
- shape: 'box' | 'hexagon'
33
+ shape: 'box' | 'hexagon' | 'plane'
34
34
  /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
35
35
  isVisible?: (component: Component) => boolean
36
36
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
@@ -41,6 +41,10 @@ interface MCS3DConfig {
41
41
  matteTop?: boolean
42
42
  /** 상판에 2D 렌더링 텍스처 적용 */
43
43
  texturedTop?: boolean
44
+ /** 자체 발광 강도 (0~1). 조명 약한 면에서도 색상 유지 */
45
+ emissiveIntensity?: number
46
+ /** 바닥면 유지 (기본 false — 바닥면 제거) */
47
+ keepBottomFace?: boolean
44
48
  }
45
49
 
46
50
  /**
@@ -163,7 +167,7 @@ class MCSRealObject3D extends RealObjectGroup {
163
167
 
164
168
  private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
165
169
  const geo = new THREE.BoxGeometry(w, d, h)
166
- return this.removeBottomFace(geo)
170
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
167
171
  }
168
172
 
169
173
  private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
@@ -194,6 +198,12 @@ class MCSRealObject3D extends RealObjectGroup {
194
198
  return this.removeBottomFace(geo)
195
199
  }
196
200
 
201
+ private buildPlaneGeometry(w: number, h: number): THREE.BufferGeometry {
202
+ const geo = new THREE.PlaneGeometry(w, h)
203
+ geo.rotateX(-Math.PI / 2)
204
+ return geo
205
+ }
206
+
197
207
  // ── Build ──
198
208
 
199
209
  protected _ownMesh?: THREE.Mesh
@@ -217,19 +227,31 @@ class MCSRealObject3D extends RealObjectGroup {
217
227
  const { opacity, metalness, roughness, shape } = this._config
218
228
 
219
229
  const geometry =
220
- shape === 'hexagon'
221
- ? this.buildHexagonGeometry(w, depth, h)
222
- : this.buildBoxGeometry(w, depth, h)
223
-
224
- const material = new THREE.MeshStandardMaterial({
225
- color,
226
- metalness,
227
- roughness,
228
- opacity,
229
- transparent: opacity < 1,
230
- side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
231
- depthWrite: opacity >= 0.9,
232
- })
230
+ shape === 'plane'
231
+ ? this.buildPlaneGeometry(w, h)
232
+ : shape === 'hexagon'
233
+ ? this.buildHexagonGeometry(w, depth, h)
234
+ : this.buildBoxGeometry(w, depth, h)
235
+
236
+ const isPlane = shape === 'plane'
237
+ const material = isPlane
238
+ ? new THREE.MeshBasicMaterial({
239
+ color,
240
+ opacity,
241
+ transparent: opacity < 1,
242
+ side: THREE.DoubleSide,
243
+ depthWrite: opacity >= 0.9,
244
+ })
245
+ : new THREE.MeshStandardMaterial({
246
+ color,
247
+ metalness,
248
+ roughness,
249
+ opacity,
250
+ transparent: opacity < 1,
251
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
252
+ depthWrite: opacity >= 0.9,
253
+ ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}),
254
+ })
233
255
  // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
234
256
  material.polygonOffset = true
235
257
  material.polygonOffsetFactor = -1
@@ -254,9 +276,9 @@ class MCSRealObject3D extends RealObjectGroup {
254
276
  }
255
277
 
256
278
  this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
257
- // sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), 아니면 자체 중심 (depth/2)
258
- this._ownMesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
259
- this._ownMesh.castShadow = true
279
+ // sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), plane은 살짝 띄움
280
+ this._ownMesh.position.y = isPlane ? 1 : ratio < 1 ? rawDepth / 2 : depth / 2
281
+ this._ownMesh.castShadow = !isPlane
260
282
  this._ownMesh.receiveShadow = true
261
283
  this.object3d.add(this._ownMesh)
262
284
  }
@@ -610,16 +632,16 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
610
632
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
611
633
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
612
634
 
613
- // Port: 상부 개방 5면 박스
635
+ // Port: 바닥판 (PlaneGeometry)
614
636
  const PORT_CONFIG: MCS3DConfig = {
615
637
  ...DEFAULTS,
616
- depthRatio: 0.5,
638
+ depthRatio: 0.01,
617
639
  defaultZPosFromParent: true,
618
- opacity: 0.5,
619
- metalness: 0.4,
620
- roughness: 0.6,
640
+ opacity: 1,
641
+ metalness: 0.3,
642
+ roughness: 0.8,
621
643
  defaultColor: 0xb0a090,
622
- openTop: true,
644
+ shape: 'plane',
623
645
  }
624
646
  registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
625
647
 
@@ -640,6 +662,7 @@ const CARRIER_CONFIG: MCS3DConfig = {
640
662
  isVisible: (c: Component) => !!(c.state as any).id,
641
663
  sizeRatio: 0.8,
642
664
  texturedTop: true,
665
+ emissiveIntensity: 0.4,
643
666
  }
644
667
 
645
668
  class CarrierRealObject3D extends MCSRealObject3D {