@fmsim/machine 2.0.0-beta.3 → 2.0.0-beta.6
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.js +2 -2
- package/dist/carrier.js.map +1 -1
- package/dist/factories/agv-3d.js +33 -6
- package/dist/factories/agv-3d.js.map +1 -1
- package/dist/factories/machine-3d.js +395 -56
- package/dist/factories/machine-3d.js.map +1 -1
- package/dist/factories/oht-3d.js +34 -8
- package/dist/factories/oht-3d.js.map +1 -1
- package/dist/factories/shuttle-3d.js +32 -6
- package/dist/factories/shuttle-3d.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/carrier.ts +2 -2
- package/src/factories/agv-3d.ts +32 -6
- package/src/factories/machine-3d.ts +446 -40
- package/src/factories/oht-3d.ts +32 -8
- package/src/factories/shuttle-3d.ts +30 -6
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as THREE from 'three'
|
|
12
|
-
import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
|
|
12
|
+
import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
|
|
13
13
|
import type { Component } from '@hatiolab/things-scene'
|
|
14
|
+
import { LEGEND_CAPACITY } from '../features/mcs-status-default'
|
|
15
|
+
import { getVaueOnRanges } from '../utils/get-value-on-ranges'
|
|
14
16
|
|
|
15
17
|
// ── 설정 타입 ────────────────────────────────────────────
|
|
16
18
|
|
|
@@ -33,6 +35,12 @@ interface MCS3DConfig {
|
|
|
33
35
|
isVisible?: (component: Component) => boolean
|
|
34
36
|
/** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
|
|
35
37
|
sizeRatio?: number
|
|
38
|
+
/** 상부 면 제거 (5면 개방 박스) */
|
|
39
|
+
openTop?: boolean
|
|
40
|
+
/** 상판 무반사 (MeshBasicMaterial) */
|
|
41
|
+
matteTop?: boolean
|
|
42
|
+
/** 상판에 2D 렌더링 텍스처 적용 */
|
|
43
|
+
texturedTop?: boolean
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/**
|
|
@@ -62,7 +70,7 @@ class MCSRealObject3D extends RealObjectGroup {
|
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// width, height 절대값 (음수 값은 양수로 취급)
|
|
65
|
-
|
|
73
|
+
protected get absSize(): { w: number; h: number } {
|
|
66
74
|
const { width = 10, height = 10 } = this.component.state
|
|
67
75
|
return { w: Math.abs(width), h: Math.abs(height) }
|
|
68
76
|
}
|
|
@@ -92,7 +100,7 @@ class MCSRealObject3D extends RealObjectGroup {
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
// 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
|
|
95
|
-
|
|
103
|
+
protected resolveColor(): number {
|
|
96
104
|
const comp = this.component as any
|
|
97
105
|
|
|
98
106
|
// 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
|
|
@@ -188,6 +196,8 @@ class MCSRealObject3D extends RealObjectGroup {
|
|
|
188
196
|
|
|
189
197
|
// ── Build ──
|
|
190
198
|
|
|
199
|
+
protected _ownMesh?: THREE.Mesh
|
|
200
|
+
|
|
191
201
|
build() {
|
|
192
202
|
super.build()
|
|
193
203
|
|
|
@@ -225,16 +235,36 @@ class MCSRealObject3D extends RealObjectGroup {
|
|
|
225
235
|
material.polygonOffsetFactor = -1
|
|
226
236
|
material.polygonOffsetUnit = -1
|
|
227
237
|
|
|
228
|
-
|
|
238
|
+
// BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5)
|
|
239
|
+
let meshMaterial: THREE.Material | THREE.Material[] = material
|
|
240
|
+
if (shape === 'box') {
|
|
241
|
+
if (this._config.openTop) {
|
|
242
|
+
// 상단면 제거 → 5면 개방 박스
|
|
243
|
+
const invisible = new THREE.MeshBasicMaterial({ visible: false })
|
|
244
|
+
meshMaterial = [material, material, invisible, material, material, material]
|
|
245
|
+
} else if (this._config.texturedTop) {
|
|
246
|
+
// 상판에 2D 렌더링 텍스처
|
|
247
|
+
const topMat = this.buildTopTexture(w, h)
|
|
248
|
+
meshMaterial = [material, material, topMat, material, material, material]
|
|
249
|
+
} else if (this._config.matteTop) {
|
|
250
|
+
// 상판만 무반사
|
|
251
|
+
const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 })
|
|
252
|
+
meshMaterial = [material, material, matte, material, material, material]
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
|
|
229
257
|
// sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), 아니면 자체 중심 (depth/2)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.object3d.add(
|
|
258
|
+
this._ownMesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
|
|
259
|
+
this._ownMesh.castShadow = true
|
|
260
|
+
this._ownMesh.receiveShadow = true
|
|
261
|
+
this.object3d.add(this._ownMesh)
|
|
234
262
|
}
|
|
235
263
|
|
|
236
264
|
// ── Change handling ──
|
|
237
265
|
|
|
266
|
+
private _updatePending = false
|
|
267
|
+
|
|
238
268
|
onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
|
|
239
269
|
if (
|
|
240
270
|
'width' in after ||
|
|
@@ -242,18 +272,117 @@ class MCSRealObject3D extends RealObjectGroup {
|
|
|
242
272
|
'depth' in after ||
|
|
243
273
|
'fillStyle' in after ||
|
|
244
274
|
'strokeStyle' in after ||
|
|
245
|
-
'id' in after
|
|
275
|
+
'id' in after ||
|
|
276
|
+
'EMPTYTYPE' in after ||
|
|
277
|
+
'CARRIERSTATUS' in after
|
|
246
278
|
) {
|
|
247
|
-
|
|
279
|
+
// 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
|
|
280
|
+
if (!this._updatePending) {
|
|
281
|
+
this._updatePending = true
|
|
282
|
+
queueMicrotask(() => {
|
|
283
|
+
this._updatePending = false
|
|
284
|
+
this.update()
|
|
285
|
+
})
|
|
286
|
+
}
|
|
248
287
|
return
|
|
249
288
|
}
|
|
250
289
|
|
|
251
290
|
super.onchange(after, before)
|
|
252
291
|
}
|
|
253
292
|
|
|
254
|
-
|
|
293
|
+
/**
|
|
294
|
+
* 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용
|
|
295
|
+
*/
|
|
296
|
+
protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial {
|
|
297
|
+
const scale = 4
|
|
298
|
+
const cw = this.nextPow2(w * scale)
|
|
299
|
+
const ch = this.nextPow2(h * scale)
|
|
300
|
+
const canvas = document.createElement('canvas')
|
|
301
|
+
canvas.width = cw
|
|
302
|
+
canvas.height = ch
|
|
303
|
+
const ctx = canvas.getContext('2d')!
|
|
304
|
+
|
|
305
|
+
const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0')
|
|
306
|
+
const emptyType = (this.component.state as any)?.EMPTYTYPE || ''
|
|
307
|
+
const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : ''
|
|
308
|
+
|
|
309
|
+
// 실제 사용 영역 (nextPow2 이전 크기)
|
|
310
|
+
const usedW = w * scale
|
|
311
|
+
const usedH = h * scale
|
|
312
|
+
|
|
313
|
+
// 바탕: 캐리어 상태 색상 (사용 영역만)
|
|
314
|
+
ctx.fillStyle = statusColor
|
|
315
|
+
ctx.fillRect(0, 0, usedW, usedH)
|
|
316
|
+
|
|
317
|
+
// 텍스트 (사용 영역 중앙)
|
|
318
|
+
if (text) {
|
|
319
|
+
const fontSize = Math.round(Math.min(usedW, usedH) * 0.7)
|
|
320
|
+
ctx.font = `bold ${fontSize}px Arial`
|
|
321
|
+
ctx.textAlign = 'center'
|
|
322
|
+
ctx.textBaseline = 'middle'
|
|
323
|
+
ctx.fillStyle = '#000000'
|
|
324
|
+
ctx.fillText(text, usedW / 2, usedH / 2)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
328
|
+
texture.offset.set(0, 1 - usedH / ch)
|
|
329
|
+
texture.repeat.set(usedW / cw, usedH / ch)
|
|
330
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
331
|
+
|
|
332
|
+
return new THREE.MeshBasicMaterial({
|
|
333
|
+
map: texture,
|
|
334
|
+
transparent: true,
|
|
335
|
+
side: THREE.DoubleSide,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private nextPow2(n: number): number {
|
|
340
|
+
if (!(n > 0)) return 1
|
|
341
|
+
let v = Math.ceil(n)
|
|
342
|
+
v--
|
|
343
|
+
v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16
|
|
344
|
+
return v + 1
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 자신의 mesh만 제거 후 rebuild.
|
|
349
|
+
* 기본 update()는 clear()로 object3d의 모든 children을 제거하는데,
|
|
350
|
+
* 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨.
|
|
351
|
+
* 이를 방지하기 위해 자신이 생성한 mesh만 제거한다.
|
|
352
|
+
*/
|
|
353
|
+
update() {
|
|
354
|
+
// 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
|
|
355
|
+
if (this._ownMesh) {
|
|
356
|
+
this.object3d.remove(this._ownMesh)
|
|
357
|
+
disposeObject3D(this._ownMesh)
|
|
358
|
+
this._ownMesh = undefined
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.build()
|
|
362
|
+
this.updateTransform()
|
|
363
|
+
this.updateAlpha()
|
|
364
|
+
this.updateHidden()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// RealObjectGroup 기본 동작 비활성화
|
|
255
368
|
updateDimension() {}
|
|
256
|
-
|
|
369
|
+
|
|
370
|
+
updateAlpha() {
|
|
371
|
+
const { alpha = 1 } = this.component.state
|
|
372
|
+
const configOpacity = this._config.opacity
|
|
373
|
+
const effective = configOpacity * alpha
|
|
374
|
+
|
|
375
|
+
this.object3d.traverse((o: any) => {
|
|
376
|
+
if (!o.material) return
|
|
377
|
+
const materials = Array.isArray(o.material) ? o.material : [o.material]
|
|
378
|
+
for (const m of materials) {
|
|
379
|
+
if (!m.visible) continue // openTop invisible face 무시
|
|
380
|
+
if (m.map) continue // 텍스처 material은 자체 transparent 유지
|
|
381
|
+
m.opacity = effective
|
|
382
|
+
m.transparent = effective < 1
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
}
|
|
257
386
|
}
|
|
258
387
|
|
|
259
388
|
// ── 팩토리 등록 헬퍼 ─────────────────────────────────────
|
|
@@ -269,7 +398,7 @@ function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
|
|
|
269
398
|
|
|
270
399
|
register('Equipment', {
|
|
271
400
|
defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT,
|
|
272
|
-
opacity: 0.
|
|
401
|
+
opacity: 0.75,
|
|
273
402
|
metalness: 0.4,
|
|
274
403
|
roughness: 0.6,
|
|
275
404
|
defaultColor: 0x8899aa,
|
|
@@ -277,7 +406,7 @@ register('Equipment', {
|
|
|
277
406
|
|
|
278
407
|
register('Buffer', {
|
|
279
408
|
defaultDepth: TRANSPORT_SURFACE_HEIGHT,
|
|
280
|
-
opacity: 0
|
|
409
|
+
opacity: 1.0,
|
|
281
410
|
metalness: 0.3,
|
|
282
411
|
roughness: 0.7,
|
|
283
412
|
defaultColor: 0x99aa88,
|
|
@@ -298,26 +427,201 @@ register('OHTLine', {
|
|
|
298
427
|
defaultColor: 0x888899,
|
|
299
428
|
})
|
|
300
429
|
|
|
301
|
-
|
|
430
|
+
// STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
|
|
431
|
+
const STOCKER_CONFIG: MCS3DConfig = {
|
|
432
|
+
...DEFAULTS,
|
|
302
433
|
defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT,
|
|
303
|
-
opacity: 0.
|
|
434
|
+
opacity: 0.75,
|
|
304
435
|
metalness: 0.3,
|
|
305
436
|
roughness: 0.6,
|
|
306
437
|
defaultColor: 0x8899bb,
|
|
307
|
-
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
class StockerRealObject3D extends MCSRealObject3D {
|
|
441
|
+
private gaugeMesh?: THREE.Mesh
|
|
442
|
+
|
|
443
|
+
constructor(component: Component) {
|
|
444
|
+
super(component, STOCKER_CONFIG)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
build() {
|
|
448
|
+
super.build()
|
|
449
|
+
this.buildGauge()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private buildGauge() {
|
|
453
|
+
const comp = this.component as any
|
|
454
|
+
const data = comp.data || {}
|
|
455
|
+
const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
|
|
456
|
+
|
|
457
|
+
const gauge = this.resolveGauge(data, gaugeMode)
|
|
458
|
+
|
|
459
|
+
// 테두리는 스토커의 상태 색상 (machine status)
|
|
460
|
+
const statusColor = comp.statusColor
|
|
461
|
+
if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') {
|
|
462
|
+
gauge.borderColor = statusColor
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const { w, h } = this.absSize
|
|
466
|
+
const depth = this.effectiveDepth
|
|
467
|
+
const isWidthLonger = w >= h
|
|
468
|
+
|
|
469
|
+
// 게이지 캔버스 (고해상도)
|
|
470
|
+
const barW = 130
|
|
471
|
+
const barH = 26
|
|
472
|
+
const scale = 4
|
|
473
|
+
const canvasW = barW * scale
|
|
474
|
+
const canvasH = barH * scale
|
|
475
|
+
const canvas = document.createElement('canvas')
|
|
476
|
+
canvas.width = canvasW
|
|
477
|
+
canvas.height = canvasH
|
|
478
|
+
const ctx = canvas.getContext('2d')!
|
|
479
|
+
|
|
480
|
+
const pad = 12
|
|
481
|
+
|
|
482
|
+
// 배경 + 테두리
|
|
483
|
+
ctx.fillStyle = '#dddddd'
|
|
484
|
+
ctx.fillRect(0, 0, canvasW, canvasH)
|
|
485
|
+
ctx.strokeStyle = gauge.borderColor
|
|
486
|
+
ctx.lineWidth = 12
|
|
487
|
+
ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
|
|
488
|
+
|
|
489
|
+
// 세그먼트 채움 (왼→오)
|
|
490
|
+
const innerW = canvasW - pad * 2
|
|
491
|
+
const innerH = canvasH - pad * 2
|
|
492
|
+
let x = pad
|
|
493
|
+
for (const seg of gauge.segments) {
|
|
494
|
+
const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
|
|
495
|
+
if (segW <= 0) continue
|
|
496
|
+
ctx.fillStyle = seg.color
|
|
497
|
+
ctx.fillRect(x, pad, segW, innerH)
|
|
498
|
+
x += segW
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 텍스트
|
|
502
|
+
ctx.font = `bold ${10 * scale}px sans-serif`
|
|
503
|
+
ctx.textAlign = 'center'
|
|
504
|
+
ctx.textBaseline = 'middle'
|
|
505
|
+
ctx.fillStyle = '#333333'
|
|
506
|
+
ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
|
|
507
|
+
|
|
508
|
+
// 텍스처 → Plane
|
|
509
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
510
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
511
|
+
|
|
512
|
+
const geo = new THREE.PlaneGeometry(barW, barH)
|
|
513
|
+
geo.rotateX(-Math.PI / 2) // 수평 (지붕 위)
|
|
514
|
+
if (!isWidthLonger) geo.rotateY(Math.PI / 2)
|
|
515
|
+
|
|
516
|
+
const mat = new THREE.MeshBasicMaterial({
|
|
517
|
+
map: texture,
|
|
518
|
+
transparent: true,
|
|
519
|
+
side: THREE.DoubleSide,
|
|
520
|
+
})
|
|
521
|
+
mat.polygonOffset = true
|
|
522
|
+
mat.polygonOffsetFactor = -2
|
|
523
|
+
mat.polygonOffsetUnit = -2
|
|
524
|
+
|
|
525
|
+
this.gaugeMesh = new THREE.Mesh(geo, mat)
|
|
526
|
+
this.gaugeMesh.position.set(0, depth + 0.5, 0)
|
|
527
|
+
this.object3d.add(this.gaugeMesh)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private resolveGauge(data: any, mode: string) {
|
|
531
|
+
switch (mode) {
|
|
532
|
+
case 'carrier': {
|
|
533
|
+
const full = Number(data.FULL_CNT) || 0
|
|
534
|
+
const empty = Number(data.EMPTY_CNT) || 0
|
|
535
|
+
const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0
|
|
536
|
+
const total = full + empty + emptyEmpty
|
|
537
|
+
const segments = total > 0
|
|
538
|
+
? [
|
|
539
|
+
{ ratio: full / total, color: '#4AA7FE' },
|
|
540
|
+
{ ratio: empty / total, color: '#8BDA5B' },
|
|
541
|
+
{ ratio: emptyEmpty / total, color: '#019D59' },
|
|
542
|
+
]
|
|
543
|
+
: [{ ratio: 1, color: '#dddddd' }]
|
|
544
|
+
return {
|
|
545
|
+
segments,
|
|
546
|
+
borderColor: '#4AA7FE',
|
|
547
|
+
label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '',
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
case 'abnormal': {
|
|
551
|
+
const normal = Number(data.CARRIER_NORMAL) || 0
|
|
552
|
+
const abnormal = Number(data.CARRIER_ABNORMAL) || 0
|
|
553
|
+
const total = normal + abnormal
|
|
554
|
+
const segments = total > 0
|
|
555
|
+
? [
|
|
556
|
+
{ ratio: normal / total, color: '#8BDA5B' },
|
|
557
|
+
{ ratio: abnormal / total, color: '#F4B4B4' },
|
|
558
|
+
]
|
|
559
|
+
: [{ ratio: 1, color: '#dddddd' }]
|
|
560
|
+
return {
|
|
561
|
+
segments,
|
|
562
|
+
borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B',
|
|
563
|
+
label: total > 0 ? `N${normal} A${abnormal}` : '',
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
default: { // capacity
|
|
567
|
+
const cur = Number(data.CURRENTCAPACITY) || 0
|
|
568
|
+
const max = Number(data.MAXCAPACITY) || 100
|
|
569
|
+
const fullup = data.FULLUP === 'T'
|
|
570
|
+
const usage = Math.round((cur / max) * 100)
|
|
571
|
+
const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
|
|
572
|
+
const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
|
|
573
|
+
return {
|
|
574
|
+
segments: [{ ratio: usage / 100, color }],
|
|
575
|
+
borderColor: color,
|
|
576
|
+
label: `${usage}%`,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
|
|
583
|
+
if ('data' in after) {
|
|
584
|
+
this.clearGauge()
|
|
585
|
+
this.buildGauge()
|
|
586
|
+
}
|
|
587
|
+
super.onchange(after, before)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
clear() {
|
|
591
|
+
this.clearGauge()
|
|
592
|
+
return super.clear()
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private clearGauge() {
|
|
596
|
+
if (this.gaugeMesh) {
|
|
597
|
+
this.object3d.remove(this.gaugeMesh)
|
|
598
|
+
this.gaugeMesh.geometry?.dispose()
|
|
599
|
+
const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
|
|
600
|
+
mat?.map?.dispose()
|
|
601
|
+
mat?.dispose()
|
|
602
|
+
this.gaugeMesh = undefined
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component))
|
|
308
608
|
|
|
309
609
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
310
610
|
// Unit (이동체 · 홀더) — 낮은 depth, 불투명
|
|
311
611
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
312
612
|
|
|
313
|
-
|
|
613
|
+
// Port: 상부 개방 5면 박스
|
|
614
|
+
const PORT_CONFIG: MCS3DConfig = {
|
|
615
|
+
...DEFAULTS,
|
|
314
616
|
depthRatio: 0.5,
|
|
315
617
|
defaultZPosFromParent: true,
|
|
316
618
|
opacity: 0.5,
|
|
317
619
|
metalness: 0.4,
|
|
318
620
|
roughness: 0.6,
|
|
319
621
|
defaultColor: 0xb0a090,
|
|
320
|
-
|
|
622
|
+
openTop: true,
|
|
623
|
+
}
|
|
624
|
+
registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
|
|
321
625
|
|
|
322
626
|
register('Shelf', {
|
|
323
627
|
depthRatio: 0.25,
|
|
@@ -326,36 +630,138 @@ register('Shelf', {
|
|
|
326
630
|
defaultColor: 0xaabbcc,
|
|
327
631
|
})
|
|
328
632
|
|
|
329
|
-
|
|
633
|
+
// Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
|
|
634
|
+
const CARRIER_CONFIG: MCS3DConfig = {
|
|
635
|
+
...DEFAULTS,
|
|
330
636
|
depthRatio: 0.5,
|
|
331
637
|
metalness: 0.2,
|
|
332
638
|
roughness: 0.8,
|
|
333
639
|
defaultColor: 0x66bb66,
|
|
334
640
|
isVisible: (c: Component) => !!(c.state as any).id,
|
|
335
641
|
sizeRatio: 0.8,
|
|
336
|
-
|
|
642
|
+
texturedTop: true,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
class CarrierRealObject3D extends MCSRealObject3D {
|
|
646
|
+
private _animRaf: number | null = null
|
|
647
|
+
private _animStartTime = 0
|
|
648
|
+
|
|
649
|
+
constructor(component: Component) {
|
|
650
|
+
super(component, CARRIER_CONFIG)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
build() {
|
|
654
|
+
super.build()
|
|
655
|
+
this.startCarrierAnimation()
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
|
|
660
|
+
* things-scene의 delta 시스템과 독립적으로 동작.
|
|
661
|
+
*/
|
|
662
|
+
private startCarrierAnimation() {
|
|
663
|
+
this.stopCarrierAnimation()
|
|
664
|
+
|
|
665
|
+
const comp = this.component as any
|
|
666
|
+
if (!comp.app?.isViewMode) return
|
|
667
|
+
|
|
668
|
+
const carrierStatus = comp.state?.CARRIERSTATUS
|
|
669
|
+
if (!carrierStatus) return
|
|
670
|
+
|
|
671
|
+
// 애니메이션 설정 가져오기
|
|
672
|
+
const config = comp.getAnimationConfig?.(carrierStatus)
|
|
673
|
+
if (!config?.animation) return
|
|
674
|
+
|
|
675
|
+
const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
|
|
676
|
+
if (!type) return
|
|
677
|
+
|
|
678
|
+
this._animStartTime = performance.now()
|
|
679
|
+
|
|
680
|
+
// 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
|
|
681
|
+
// 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
|
|
682
|
+
const obj = this.object3d
|
|
683
|
+
|
|
684
|
+
const animate = () => {
|
|
685
|
+
if (!this._ownMesh) return
|
|
686
|
+
|
|
687
|
+
const elapsed = performance.now() - this._animStartTime
|
|
688
|
+
const progress = (elapsed % duration) / duration // 0~1 반복
|
|
689
|
+
|
|
690
|
+
switch (type) {
|
|
691
|
+
case 'heartbeat': {
|
|
692
|
+
const t = progress < 0.5 ? progress * 2 : (1 - progress) * 2
|
|
693
|
+
const s = 1 + (animScale - 1) * t
|
|
694
|
+
obj.scale.set(s, 1, s)
|
|
695
|
+
break
|
|
696
|
+
}
|
|
697
|
+
case 'rotation': {
|
|
698
|
+
obj.rotation.y = progress * Math.PI * 2
|
|
699
|
+
break
|
|
700
|
+
}
|
|
701
|
+
case 'vibration': {
|
|
702
|
+
const theta = config.animation.theta ?? 0.26
|
|
703
|
+
const current = Math.sin(progress * Math.PI * 2) * theta
|
|
704
|
+
obj.rotation.y = current
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
case 'fade': {
|
|
708
|
+
const alpha = 0.3 + 0.7 * (progress < 0.5 ? progress * 2 : (1 - progress) * 2)
|
|
709
|
+
obj.traverse((o: any) => {
|
|
710
|
+
if (o.material && !o.material.map) {
|
|
711
|
+
o.material.opacity = alpha
|
|
712
|
+
o.material.transparent = true
|
|
713
|
+
}
|
|
714
|
+
})
|
|
715
|
+
break
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
this._animRaf = requestAnimationFrame(animate)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
this._animRaf = requestAnimationFrame(animate)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
|
|
726
|
+
syncAnimationDelta() {
|
|
727
|
+
if (!this._animRaf) {
|
|
728
|
+
super.syncAnimationDelta()
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private stopCarrierAnimation() {
|
|
733
|
+
if (this._animRaf) {
|
|
734
|
+
cancelAnimationFrame(this._animRaf)
|
|
735
|
+
this._animRaf = null
|
|
736
|
+
}
|
|
737
|
+
// group 스케일/회전 초기화
|
|
738
|
+
this.object3d.scale.set(1, 1, 1)
|
|
739
|
+
this.object3d.rotation.y = 0
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
update() {
|
|
743
|
+
this.stopCarrierAnimation()
|
|
744
|
+
super.update()
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
clear() {
|
|
748
|
+
this.stopCarrierAnimation()
|
|
749
|
+
return super.clear()
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
|
|
337
754
|
|
|
338
755
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
339
756
|
// Gauge (용량 게이지) — 얇은 바
|
|
340
757
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
341
758
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
roughness: 0.8,
|
|
346
|
-
defaultColor: 0x44aa44,
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
register('StockerCapacityBar', {
|
|
350
|
-
depthRatio: 0.15,
|
|
351
|
-
metalness: 0.2,
|
|
352
|
-
roughness: 0.8,
|
|
353
|
-
defaultColor: 0x44aa44,
|
|
354
|
-
})
|
|
759
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
760
|
+
// Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
|
|
761
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
355
762
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
})
|
|
763
|
+
// Gauge 컴포넌트 (StockerCapacityBar, ZoneCapacityBar, MCSGaugeCapacityBar)는
|
|
764
|
+
// 3D에서 표시하지 않음. 대신 부모(STOCKER 등)의 3D 모델에서 직접 게이지를 렌더링.
|
|
765
|
+
registerRealObjectFactory('StockerCapacityBar', () => null as any)
|
|
766
|
+
registerRealObjectFactory('MCSGaugeCapacityBar', () => null as any)
|
|
767
|
+
registerRealObjectFactory('ZoneCapacityBar', () => null as any)
|
package/src/factories/oht-3d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as THREE from 'three'
|
|
13
13
|
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
14
14
|
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
|
|
15
|
-
import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
|
|
15
|
+
import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
|
|
16
16
|
import type { Component } from '@hatiolab/things-scene'
|
|
17
17
|
import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
|
|
18
18
|
|
|
@@ -24,6 +24,7 @@ const DEFAULT_BODY_COLOR = 0x6688cc
|
|
|
24
24
|
const OHT_RAIL_HEIGHT = 6 * TRANSPORT_SURFACE_HEIGHT
|
|
25
25
|
|
|
26
26
|
export class OHT3D extends RealObjectGroup {
|
|
27
|
+
private _ownMeshes: THREE.Mesh[] = []
|
|
27
28
|
get effectiveDepth(): number {
|
|
28
29
|
const { depth } = this.component.state
|
|
29
30
|
return depth || TRANSPORT_SURFACE_HEIGHT
|
|
@@ -89,7 +90,6 @@ export class OHT3D extends RealObjectGroup {
|
|
|
89
90
|
const gripperMesh = new THREE.Mesh(gripperGeo, frameMat)
|
|
90
91
|
gripperMesh.position.y = gripperBarH / 2
|
|
91
92
|
gripperMesh.castShadow = true
|
|
92
|
-
this.object3d.add(gripperMesh)
|
|
93
93
|
|
|
94
94
|
// ── 2. 호이스트 암 (좌우 수직 기둥 + 하단 가로연결) ──
|
|
95
95
|
const armBaseY = gripperBarH
|
|
@@ -111,7 +111,6 @@ export class OHT3D extends RealObjectGroup {
|
|
|
111
111
|
|
|
112
112
|
const armMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(armGeometries), frameMat)
|
|
113
113
|
armMesh.castShadow = true
|
|
114
|
-
this.object3d.add(armMesh)
|
|
115
114
|
|
|
116
115
|
// ── 3. 본체 하우징 (statusColor 적용) ──
|
|
117
116
|
const bodyBaseY = armBaseY + armH
|
|
@@ -126,7 +125,6 @@ export class OHT3D extends RealObjectGroup {
|
|
|
126
125
|
bodyMesh.position.y = bodyBaseY + bodyH / 2
|
|
127
126
|
bodyMesh.castShadow = true
|
|
128
127
|
bodyMesh.receiveShadow = true
|
|
129
|
-
this.object3d.add(bodyMesh)
|
|
130
128
|
|
|
131
129
|
// ── 4. 상단 트롤리 플레이트 (납작하고 넓은 레일 클램프) ──
|
|
132
130
|
const trolleyBaseY = bodyBaseY + bodyH + gap
|
|
@@ -141,27 +139,53 @@ export class OHT3D extends RealObjectGroup {
|
|
|
141
139
|
const trolleyMesh = new THREE.Mesh(trolleyGeo, trolleyMat)
|
|
142
140
|
trolleyMesh.position.y = trolleyBaseY + trolleyH / 2
|
|
143
141
|
trolleyMesh.castShadow = true
|
|
144
|
-
this.object3d.add(trolleyMesh)
|
|
145
142
|
|
|
146
143
|
// ── 5. 좌우 바퀴 (트롤리 위, 레일을 타는 원통형 바퀴) ──
|
|
147
144
|
const wheelBaseY = trolleyBaseY + trolleyH
|
|
148
145
|
const wheelRadius = wheelH / 2
|
|
149
146
|
const wheelDepth = h * 0.2
|
|
147
|
+
const wheelMeshes: THREE.Mesh[] = []
|
|
150
148
|
for (const xSign of [-1, 1]) {
|
|
151
149
|
const wheelGeo = new THREE.CylinderGeometry(wheelRadius, wheelRadius, wheelDepth, 12)
|
|
152
150
|
wheelGeo.rotateX(Math.PI / 2)
|
|
153
151
|
const wheelMesh = new THREE.Mesh(wheelGeo, trolleyMat)
|
|
154
152
|
wheelMesh.position.set(xSign * (trolleyW * 0.35), wheelBaseY + wheelRadius, 0)
|
|
155
153
|
wheelMesh.castShadow = true
|
|
156
|
-
|
|
154
|
+
wheelMeshes.push(wheelMesh)
|
|
157
155
|
}
|
|
156
|
+
|
|
157
|
+
this._ownMeshes = [gripperMesh, armMesh, bodyMesh, trolleyMesh, ...wheelMeshes]
|
|
158
|
+
for (const m of this._ownMeshes) this.object3d.add(m)
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
updateDimension() {}
|
|
161
|
-
|
|
162
|
+
|
|
163
|
+
update() {
|
|
164
|
+
for (const m of this._ownMeshes) {
|
|
165
|
+
this.object3d.remove(m)
|
|
166
|
+
disposeObject3D(m)
|
|
167
|
+
}
|
|
168
|
+
this._ownMeshes = []
|
|
169
|
+
this.build()
|
|
170
|
+
this.updateTransform()
|
|
171
|
+
this.updateAlpha()
|
|
172
|
+
this.updateHidden()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
updateAlpha() {
|
|
176
|
+
const { alpha = 1 } = this.component.state
|
|
177
|
+
for (const m of this._ownMeshes) {
|
|
178
|
+
const materials = Array.isArray(m.material) ? m.material : [m.material]
|
|
179
|
+
for (const mat of materials) {
|
|
180
|
+
;(mat as THREE.MeshStandardMaterial).opacity = alpha
|
|
181
|
+
;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
162
185
|
|
|
163
186
|
onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
|
|
164
|
-
if ('width' in after || 'height' in after || 'depth' in after ||
|
|
187
|
+
if ('width' in after || 'height' in after || 'depth' in after ||
|
|
188
|
+
'fillStyle' in after || 'strokeStyle' in after || 'id' in after) {
|
|
165
189
|
this.update()
|
|
166
190
|
return
|
|
167
191
|
}
|