/** * OHT (Overhead Hoist Transport) 3D Factory * * 천장 레일에 매달려 이동하는 반송 차량. 캐리어를 호이스트로 운반. * 구조 (바닥 원점, y=0~depth): * - 하단 그리퍼 바 (캐리어 집는 가로 바) * - 호이스트 암 (좌우 수직 기둥) * - 본체 하우징 (statusColor 적용) * - 상단 트롤리 (납작+넓은 레일 클램프 + 좌우 바퀴) */ import * as THREE from 'three' import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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 TROLLEY_COLOR = 0x222222 const FRAME_COLOR = 0x444444 const DEFAULT_BODY_COLOR = 0x6688cc /** OHT 레일 높이: 스토커(5x) 위 */ const OHT_RAIL_HEIGHT = 6 * TRANSPORT_SURFACE_HEIGHT export class OHT3D extends RealObjectGroup { private _ownMeshes: THREE.Mesh[] = [] get effectiveDepth(): number { const { depth } = this.component.state return depth || TRANSPORT_SURFACE_HEIGHT } /** 천장 레일 높이에 배치 (center origin 기준: 레일 높이 + 볼륨 중심 반깊이) */ protected get syncZPosOffset(): number { return OHT_RAIL_HEIGHT + this.effectiveDepth / 2 } 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 // 높이 배분: 그리퍼 8%, 호이스트암 22%, 본체 45%, 간격 5%, 트롤리 12%, 바퀴 8% const gripperBarH = d * 0.08 const armH = d * 0.22 const bodyH = d * 0.45 const gap = d * 0.05 const trolleyH = d * 0.12 const wheelH = d * 0.08 const armThickness = Math.min(w, h) * 0.12 const bodyCornerR = Math.min(w, h, bodyH) * 0.1 const frameMat = new THREE.MeshStandardMaterial({ color: FRAME_COLOR, roughness: 0.5, metalness: 0.4, }) // ── 1. 하단 그리퍼 바 (캐리어를 집는 가로 바 — 이동 방향과 직교) ── const gripperBarW = w * 0.7 const gripperBarD = h * 0.15 const gripperGeo = new THREE.BoxGeometry(gripperBarW, gripperBarH, gripperBarD) const gripperMesh = new THREE.Mesh(gripperGeo, frameMat) gripperMesh.position.y = gripperBarH / 2 gripperMesh.castShadow = true // ── 2. 호이스트 암 (전후 수직 기둥 + 하단 연결 바) ── const armBaseY = gripperBarH const armGeometries: THREE.BufferGeometry[] = [] // 전후 수직 기둥 (레일 방향 = Z축) for (const zSign of [-1, 1]) { const pillar = new THREE.BoxGeometry(armThickness, armH, armThickness) pillar.translate(0, armBaseY + armH / 2, zSign * (h * 0.3)) armGeometries.push(pillar) } // 하단 연결 바 (레일 방향으로 연결) const crossBarD = h * 0.6 + armThickness const crossBarH = armThickness * 0.6 const crossBar = new THREE.BoxGeometry(armThickness, crossBarH, crossBarD) crossBar.translate(0, armBaseY + crossBarH / 2, 0) armGeometries.push(crossBar) const armMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(armGeometries), frameMat) armMesh.castShadow = true // ── 3. 본체 하우징 (statusColor 적용) ── const bodyBaseY = armBaseY + armH const bodyColor = this.resolveColor() const bodyGeo = new RoundedBoxGeometry(w, bodyH, h, 4, bodyCornerR) const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 0.3, metalness: 0.5, }) const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat) bodyMesh.position.y = bodyBaseY + bodyH / 2 bodyMesh.castShadow = true bodyMesh.receiveShadow = true // ── 4. 상단 트롤리 플레이트 (레일 방향으로 길게 — Z축) ── const trolleyBaseY = bodyBaseY + bodyH + gap const trolleyW = w * 0.6 const trolleyD = h * 1.15 const trolleyGeo = new THREE.BoxGeometry(trolleyW, trolleyH, trolleyD) const trolleyMat = new THREE.MeshStandardMaterial({ color: TROLLEY_COLOR, roughness: 0.6, metalness: 0.3, }) const trolleyMesh = new THREE.Mesh(trolleyGeo, trolleyMat) trolleyMesh.position.y = trolleyBaseY + trolleyH / 2 trolleyMesh.castShadow = true // ── 5. 전후 바퀴 (트롤리 위, 레일 방향 = Z축) ── const wheelBaseY = trolleyBaseY + trolleyH const wheelRadius = wheelH / 2 const wheelDepth = w * 0.2 const wheelMeshes: THREE.Mesh[] = [] for (const zSign of [-1, 1]) { const wheelGeo = new THREE.CylinderGeometry(wheelRadius, wheelRadius, wheelDepth, 12) wheelGeo.rotateZ(Math.PI / 2) const wheelMesh = new THREE.Mesh(wheelGeo, trolleyMat) wheelMesh.position.set(0, wheelBaseY + wheelRadius, zSign * (trolleyD * 0.35)) wheelMesh.castShadow = true wheelMeshes.push(wheelMesh) } this._ownMeshes = [gripperMesh, armMesh, bodyMesh, trolleyMesh, ...wheelMeshes] 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('OHT', (component: Component) => new OHT3D(component))