/** * 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, MaterialBank } 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 { /** 캐리지 포크 group — Crane.state.forkZ (0~100) 가 변하면 position.y 만 lerp. * build() 시 inner 에 add 되고, *_mastBaseY 와 mastH 를 함께 보관* 해서 * setForkZ 가 비율 계산에 사용. */ private forkGroup?: THREE.Group private forkMastBaseY = 0 private forkMastH = 0 get effectiveDepth(): number { const { depth } = this.component.state return depth || 5 * TRANSPORT_SURFACE_HEIGHT - 2 } /** * 0~100 정규화 z (= state.forkZ) 를 mast 높이 비율로 변환해서 포크 group 의 * three-up y 위치 갱신. build() 가 안 끝났으면 noop — build 가 forkZ 를 다시 * 반영하면서 끝난다. */ applyForkZ(z: number): void { if (!this.forkGroup) return const clamped = Math.max(0, Math.min(100, Number(z) || 0)) this.forkGroup.position.y = this.forkMastBaseY + (clamped / 100) * this.forkMastH } 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 // MaterialBank 공유(dedup). 자체 update() 없이 base 생명주기(guarded disposeObject3D) 상속 → 안전. const clampMat = MaterialBank.material('crane|clamp', { roughness: 0.5, metalness: 0.5 }, CLAMP_COLOR) // ── 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 = MaterialBank.material('crane|mast', { roughness: 0.3, metalness: 0.6 }, bodyColor) 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. 캐리지 포크 (별도 group — state.forkZ 변경 시 group.position.y lerp) ── // 포크 geometry 는 *_mastBaseY 기준 local 0 에 둠* — 즉 group.position.y = 0 // 일 때 mast 바닥 자리에. forkZ 0~100 에 따라 group.position.y 만 변경하면 // mast 따라 위/아래 이동. mesh 재구축 불필요. const forkMat = MaterialBank.material('crane|fork', { roughness: 0.4, metalness: 0.5 }, FORK_COLOR) 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, plateH / 2, 0) // local 0 = mast 바닥 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), armH / 2, zSign * (plateD / 2 + armD / 2)) forkGeometries.push(arm) } } const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat) forkMesh.castShadow = true const forkGroup = new THREE.Group() forkGroup.add(forkMesh) inner.add(forkGroup) this.forkGroup = forkGroup this.forkMastBaseY = mastBaseY // forkZ=100 시점에 mast 의 *_위쪽 한계*. 포크 자체 두께 (plateH) 만큼은 마스트 // 안에 머무르도록 limit 보정 — 그렇지 않으면 forkZ=100 일 때 포크가 상단 // 클램프와 충돌. mastH * 0.92 로 약간 보수적 (= 마스트 본체 92% 까지만 이동). this.forkMastH = mastH * 0.92 // 초기 forkZ 반영 — state.forkZ 가 이미 set 되어 있으면 그 위치에 배치 const initialForkZ = Number((this.component.state as any).forkZ) this.applyForkZ(Number.isFinite(initialForkZ) ? initialForkZ : 0) // ── 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) // mesh 는 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게). this.updateAlpha() } updateDimension() {} // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint). /** build() 직전 호출. forkGroup 참조를 깨끗이 비워둔다 — 새 build 가 다시 set. */ clear() { this.forkGroup = undefined super.clear() } onchange(after: Record, before: Record) { if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) { this.update() return } // forkZ 는 mesh 재구축 없이 group.position.y 만 갱신. 같은 onchange 에서 // left/top 도 함께 변경될 수 있으므로 super 도 반드시 호출해 본체 transform // 갱신 path (state.left/top → obj3d.position.x/z) 가 동작하게 함. if ('forkZ' in after) { this.applyForkZ(Number(after.forkZ) || 0) } super.onchange(after, before) } } registerRealObjectFactory('Crane', (component: Component) => new Crane3D(component))