/** * Crane (Stacker Crane) 3D Factory * * 스토커 내부의 스태커 크레인 — CraneRail 위를 이동하는 차체. * 레일 인프라는 CraneRail이 담당하므로 크레인은 이동체만 표현. * * 구조 (바닥 원점, y=0~depth): * - 하단 바퀴/클램프 (4%, 바닥 레일에 물리는 부분) * - 수직 마스트 (좌우 기둥 88%, statusColor 적용) * - 캐리지 포크 (마스트 40% 높이, 양쪽 포크 암) * - 상단 클램프 (4%, 천장 레일에 물리는 부분) */ import * as THREE from 'three' import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene' import type { Component } from '@hatiolab/things-scene' import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js' const CLAMP_COLOR = 0x444444 const FORK_COLOR = 0x999999 const DEFAULT_BODY_COLOR = 0xcc8844 export class Crane3D extends RealObjectGroup { get effectiveDepth(): number { const { depth } = this.component.state return depth || 5 * TRANSPORT_SURFACE_HEIGHT - 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 const inner = this.components3D // 높이 배분: 하단 클램프 4%, 마스트 92%, 상단 클램프 4% const clampH = d * 0.04 const mastH = d * 0.92 const pillarThick = Math.min(w, h) * 0.12 const clampMat = new THREE.MeshStandardMaterial({ color: CLAMP_COLOR, roughness: 0.5, metalness: 0.5, }) // ── 1. 하단 클램프 (바닥 레일에 물리는 바퀴부) ── const clampW = w * 0.7 const clampD = h * 0.5 const bottomClampGeo = new THREE.BoxGeometry(clampW, clampH, clampD) const bottomClamp = new THREE.Mesh(bottomClampGeo, clampMat) bottomClamp.position.y = clampH / 2 bottomClamp.castShadow = true inner.add(bottomClamp) // ── 2. 수직 마스트 (좌우 기둥 + 상·하 가로 연결 바) ── const mastBaseY = clampH const bodyColor = this.resolveColor() const mastMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 0.3, metalness: 0.6, }) const mastGeometries: THREE.BufferGeometry[] = [] // 좌우 수직 기둥 for (const xSign of [-1, 1]) { const pillar = new THREE.BoxGeometry(pillarThick, mastH, pillarThick) pillar.translate(xSign * (w / 2 - pillarThick / 2), mastBaseY + mastH / 2, 0) mastGeometries.push(pillar) } // 하단 가로 연결 바 const crossW = w - pillarThick const crossH = pillarThick * 0.5 const lowerCross = new THREE.BoxGeometry(crossW, crossH, pillarThick) lowerCross.translate(0, mastBaseY + crossH / 2, 0) mastGeometries.push(lowerCross) // 상단 가로 연결 바 const upperCross = new THREE.BoxGeometry(crossW, crossH, pillarThick) upperCross.translate(0, mastBaseY + mastH - crossH / 2, 0) mastGeometries.push(upperCross) const mastMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(mastGeometries), mastMat) mastMesh.castShadow = true inner.add(mastMesh) // ── 3. 캐리지 포크 (마스트 40% 높이, 양쪽으로 뻗는 포크 암) ── const forkY = mastBaseY + mastH * 0.4 const forkMat = new THREE.MeshStandardMaterial({ color: FORK_COLOR, roughness: 0.4, metalness: 0.5, }) const forkGeometries: THREE.BufferGeometry[] = [] // 캐리지 중심 플레이트 (마스트에 부착, 양쪽 포크의 베이스) const plateW = w * 0.9 const plateH = d * 0.08 const plateD = pillarThick * 0.8 const centerPlate = new THREE.BoxGeometry(plateW, plateH, plateD) centerPlate.translate(0, forkY + plateH / 2, 0) forkGeometries.push(centerPlate) // 양쪽 포크 암 (좌/우 선반 방향으로 뻗음) const armW = pillarThick * 0.6 const armH = plateH * 0.4 const armD = h * 0.6 const armSpacing = w * 0.5 for (const zSign of [-1, 1]) { for (const xSign of [-1, 1]) { const arm = new THREE.BoxGeometry(armW, armH, armD) arm.translate(xSign * (armSpacing / 2), forkY + armH / 2, zSign * (plateD / 2 + armD / 2)) forkGeometries.push(arm) } } const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat) forkMesh.castShadow = true inner.add(forkMesh) // ── 4. 상단 클램프 (천장 레일에 물리는 가이드부) ── const topClampGeo = new THREE.BoxGeometry(clampW, clampH, clampD) const topClamp = new THREE.Mesh(topClampGeo, clampMat) topClamp.position.y = mastBaseY + mastH + clampH / 2 topClamp.castShadow = true inner.add(topClamp) } updateDimension() {} updateAlpha() {} onchange(after: Record, before: Record) { if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) { this.update() return } super.onchange(after, before) } } registerRealObjectFactory('Crane', (component: Component) => new Crane3D(component))