/** * AGV 3D Factory * * 범퍼 + 본체 + 플랫폼 3단 구조: * - 하단 범퍼 (10%, 본체보다 약간 넓음, #222222) * - 본체 (58%, RoundedBoxGeometry, statusColor 적용) * - 간격 (4%) * - 상단 플랫폼 (28%, 본체보다 안쪽, #333333) * * Based on operato-scene Vehicle3D, adapted for fmsim: * - extends RealObjectGroup (Conveyor3D와 동일 패턴) * - statusColor from MCSStatusMixin * - 볼륨 중심 원점 (things-scene 기본 컨벤션) - mesh 로컬 [-d/2, +d/2] */ import * as THREE from 'three' import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene' import type { Component } from '@hatiolab/things-scene' import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js' const BUMPER_COLOR = 0x222222 const PLATFORM_COLOR = 0x333333 const DEFAULT_BODY_COLOR = 0x6688cc export class AGV3D extends RealObjectGroup { private _ownMeshes: THREE.Mesh[] = [] get effectiveDepth(): number { const { depth } = this.component.state return depth || TRANSPORT_SURFACE_HEIGHT } private resolveColor(): number { const comp = this.component as any // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상) // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용 try { const sc = comp.statusColor if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex() } catch { /* statusColor 미구현 시 무시 */ } // 2) state.fillStyle fallback const { fillStyle } = this.component.state if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') { try { return new THREE.Color(fillStyle).getHex() } catch { /* 파싱 실패 시 무시 */ } } return DEFAULT_BODY_COLOR } build() { super.build() const { width = 10, height = 10 } = this.component.state const w = Math.abs(width) const h = Math.abs(height) const d = this.effectiveDepth if (!w || !h) return // 비율: 하단 범퍼 10%, 본체 58%, 간격 4%, 플랫폼 28% const bumperH = d * 0.1 const bodyH = d * 0.58 const gapH = d * 0.04 const platH = d * 0.28 const cornerR = Math.min(w, h, bodyH) * 0.12 // ── 하단 범퍼 밴드 (본체보다 약간 넓게 돌출) ── const bumperPad = Math.min(w, h) * 0.03 const bumperCornerR = Math.min(w + bumperPad * 2, h + bumperPad * 2, bumperH) * 0.2 const bumperGeo = new RoundedBoxGeometry(w + bumperPad * 2, bumperH, h + bumperPad * 2, 4, bumperCornerR) const bumperMat = new THREE.MeshStandardMaterial({ color: BUMPER_COLOR, roughness: 0.8, metalness: 0.1, }) const bumper = new THREE.Mesh(bumperGeo, bumperMat) bumper.position.y = bumperH / 2 bumper.castShadow = true bumper.receiveShadow = true // ── 본체 (둥근 모서리, statusColor 적용) ── const bodyColor = this.resolveColor() const bodyGeo = new RoundedBoxGeometry(w, bodyH, h, 4, cornerR) const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 0.3, metalness: 0.5, }) const body = new THREE.Mesh(bodyGeo, bodyMat) body.position.y = bumperH + bodyH / 2 body.castShadow = true body.receiveShadow = true // ── 상단 플랫폼 (본체보다 안쪽, 어두운색) ── const platInset = Math.min(w, h) * 0.04 const platCornerR = Math.min(w - platInset * 2, h - platInset * 2, platH) * 0.15 const platGeo = new RoundedBoxGeometry(w - platInset * 2, platH, h - platInset * 2, 4, platCornerR) const platMat = new THREE.MeshStandardMaterial({ color: PLATFORM_COLOR, roughness: 0.5, metalness: 0.15, }) const plat = new THREE.Mesh(platGeo, platMat) plat.position.y = bumperH + bodyH + gapH + platH / 2 plat.castShadow = true plat.receiveShadow = true this._ownMeshes = [bumper, body, plat] // ownGroup이 center-origin ↔ bottom-up 좌표계 변환을 자동 처리 for (const m of this._ownMeshes) this.components3D.add(m) } updateDimension() {} update() { // 자신의 mesh만 제거 (Carrier 등 자식 컴포넌트 3D 보존) for (const m of this._ownMeshes) { this.components3D.remove(m) disposeObject3D(m) } this._ownMeshes = [] this.build() this.updateTransform() this.updateAlpha() this.updateHidden() } updateAlpha() { const { alpha = 1 } = this.component.state for (const m of this._ownMeshes) { const materials = Array.isArray(m.material) ? m.material : [m.material] for (const mat of materials) { ;(mat as THREE.MeshStandardMaterial).opacity = alpha ;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1 } } } onchange(after: Record, before: Record) { if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after || 'id' in after || 'EMPTYTYPE' in after || 'CARRIERSTATUS' in after) { this.update() return } super.onchange(after, before) } } registerRealObjectFactory('AGV', (component: Component) => new AGV3D(component))