/** * Shuttle 3D Factory * * 스토커 내부에서 캐리어를 선반↔포트 간 이송하는 포크 캐리지. * 수평 레일 위를 이동하며, 포크를 밀어넣어 캐리어를 적재/하역. * * 구조 (바닥 원점, y=0~depth): * - 하단 베이스 플레이트 (납작, 넓음, 레일 위 주행부) * - 중앙 마스트 (좌우 수직 기둥, statusColor 적용) * - 상단 포크 (2개의 평행한 가로 암, 캐리어 적재면) */ import * as THREE from 'three' import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.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 BASE_COLOR = 0x333333 const FORK_COLOR = 0x777777 const DEFAULT_BODY_COLOR = 0x6688cc export class Shuttle3D 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 try { const sc = comp.statusColor if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0') return new THREE.Color(sc).getHex() } catch { /* statusColor 미구현 시 무시 */ } const { fillStyle } = this.component.state if (fillStyle && typeof fillStyle === 'string') { 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 // 높이 배분: 베이스 15%, 마스트 55%, 포크 30% const baseH = d * 0.15 const mastH = d * 0.55 const forkH = d * 0.30 const mastThickness = Math.min(w, h) * 0.15 // ── 1. 하단 베이스 플레이트 (납작하고 넓은 주행부) ── const basePad = Math.min(w, h) * 0.05 const baseGeo = new THREE.BoxGeometry(w + basePad * 2, baseH, h + basePad * 2) const baseMat = new THREE.MeshStandardMaterial({ color: BASE_COLOR, roughness: 0.7, metalness: 0.3, }) const baseMesh = new THREE.Mesh(baseGeo, baseMat) baseMesh.position.y = baseH / 2 baseMesh.castShadow = true baseMesh.receiveShadow = true // ── 2. 좌우 마스트 기둥 (statusColor 적용) ── const mastBaseY = baseH const bodyColor = this.resolveColor() const mastMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 0.4, metalness: 0.5, }) const mastGeometries: THREE.BufferGeometry[] = [] // 좌우 수직 기둥 for (const xSign of [-1, 1]) { const pillar = new THREE.BoxGeometry(mastThickness, mastH, mastThickness) pillar.translate(xSign * (w / 2 - mastThickness / 2), mastBaseY + mastH / 2, 0) mastGeometries.push(pillar) } // 중간 가로 연결 바 (구조 강성) const crossBarW = w - mastThickness const crossBarH = mastThickness * 0.5 const crossBar = new THREE.BoxGeometry(crossBarW, crossBarH, mastThickness) crossBar.translate(0, mastBaseY + mastH * 0.4, 0) mastGeometries.push(crossBar) const mastMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(mastGeometries), mastMat) mastMesh.castShadow = true // ── 3. 상단 포크 (2개의 평행 암 + 백 플레이트) ── const forkBaseY = mastBaseY + mastH const forkMat = new THREE.MeshStandardMaterial({ color: FORK_COLOR, roughness: 0.4, metalness: 0.6, }) const forkGeometries: THREE.BufferGeometry[] = [] // 백 플레이트 (마스트 상단을 잇는 가로판) const backPlateH = forkH * 0.4 const backPlate = new THREE.BoxGeometry(w, backPlateH, mastThickness * 0.8) backPlate.translate(0, forkBaseY + backPlateH / 2, -h * 0.35) forkGeometries.push(backPlate) // 2개의 포크 암 (앞으로 뻗은 평행 막대) const forkArmW = mastThickness * 0.7 const forkArmH = forkH * 0.25 const forkArmD = h * 0.85 const forkSpacing = w * 0.55 for (const xSign of [-1, 1]) { const arm = new THREE.BoxGeometry(forkArmW, forkArmH, forkArmD) arm.translate(xSign * (forkSpacing / 2), forkBaseY + forkArmH / 2, forkArmD / 2 - h * 0.35) forkGeometries.push(arm) } const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat) forkMesh.castShadow = true this._ownMeshes = [baseMesh, mastMesh, forkMesh] for (const m of this._ownMeshes) this.components3D.add(m) } updateDimension() {} update() { 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) { this.update() return } super.onchange(after, before) } } registerRealObjectFactory('Shuttle', (component: Component) => new Shuttle3D(component))