/** * Conveyor 3D Factory * * 'conveyor' type (default): side rails + cylindrical rollers + 4 legs + cross-bracing * 'belt' type: side rails + two end drums + belt wrapping + 4 legs + cross-bracing * 'rail' type: side rails + two parallel rails + 4 legs + cross-bracing * * Based on operato-scene conveyor-3d.ts, adapted for fmsim: * - conveyorType is string ('', 'conveyor', 'belt', 'rail') instead of numeric * - statusColor from MCSStatusMixin instead of value-based lookup * - rollWidth auto-calculated: Math.max(10, Math.min(width, height) / 2) */ 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' import { Z_FMSIM } from './z-priority' const FRAME_COLOR = 0x888899 const ROLLER_COLOR = 0xaaaabc const DEFAULT_COLOR = 0x999999 export class Conveyor3D extends RealObjectGroup { get zPriority(): number { return Z_FMSIM.CONVEYOR } 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_COLOR } build() { super.build() const { width: rawWidth, height: rawHeight, conveyorType, orientation } = this.component.state const depth = this.effectiveDepth if (!rawWidth || !rawHeight) return // orientation: 'horizontal' = 롤러가 Z축(세로), 'vertical' = 롤러가 X축(가로) // default = 긴 방향에 수직 (긴 축을 따라 이송) const isVertical = orientation === 'vertical' ? true : orientation === 'horizontal' ? false : rawWidth < rawHeight // default: 세로가 길면 롤러는 가로(X축) // isVertical=true → 롤러 축이 X(가로), 이송 방향이 Z(세로) // isVertical=false → 롤러 축이 Z(세로), 이송 방향이 X(가로) const width = rawWidth const height = rawHeight // 2D와 동일 로직으로 rollWidth 산출 const rollWidth = Math.max(10, Math.min(width, height) / 2) const rollerDiameter = Math.max(rollWidth, 2) const frameH = Math.min(rollerDiameter, depth) const legH = Math.max(depth - frameH, 0) // isVertical일 때 이송 방향이 Z축: 레일은 Z축을 따라, 벨트폭은 width // !isVertical일 때 이송 방향이 X축: 레일은 X축을 따라, 벨트폭은 height const beltWidth = isVertical ? width : height const railLen = isVertical ? height : width const railW = Math.max(beltWidth * 0.06, 2) const legThickness = Math.max(railW * 0.8, 2) const frameMaterial = new THREE.MeshStandardMaterial({ color: FRAME_COLOR, metalness: 0.85, roughness: 0.35, }) // --- Frame: side rails + legs + cross-bracing --- // Y 좌표: y=0(바닥) ~ y=depth(상단). 레일 상단이 depth에 위치. const frameGeometries: THREE.BufferGeometry[] = [] const railY = depth - frameH / 2 // Side rails — 이송 방향을 따라, 벨트 양측에 배치 if (isVertical) { // 레일이 Z축(세로)을 따라, ±X에 배치 for (const xSign of [-1, 1]) { const rail = new THREE.BoxGeometry(railW, frameH, height) rail.translate(xSign * (width / 2 - railW / 2), railY, 0) frameGeometries.push(rail) } } else { // 레일이 X축(가로)을 따라, ±Z에 배치 for (const zSign of [-1, 1]) { const rail = new THREE.BoxGeometry(width, frameH, railW) rail.translate(0, railY, zSign * (height / 2 - railW / 2)) frameGeometries.push(rail) } } // 4 legs at corners if (legH > 0) { const legTopY = depth - frameH const legCenterY = legTopY - legH / 2 // 다리 위치: 레일 안쪽에 정렬 const legXPos = isVertical ? (width / 2 - railW / 2) : (width / 2 - legThickness / 2) const legZPos = isVertical ? (height / 2 - legThickness / 2) : (height / 2 - railW / 2) for (const xSign of [-1, 1]) { for (const zSign of [-1, 1]) { const leg = new THREE.BoxGeometry(legThickness, legH, legThickness) leg.translate(xSign * legXPos, legCenterY, zSign * legZPos) frameGeometries.push(leg) } } // Cross-bracing const braceH = legThickness * 0.6 const braceW = legThickness * 0.6 const braceY = legTopY - legH * 0.35 // 이송 방향 브레이스 (레일과 평행) if (isVertical) { for (const xSign of [-1, 1]) { const brace = new THREE.BoxGeometry(braceW, braceH, height - legThickness) brace.translate(xSign * legXPos, braceY, 0) frameGeometries.push(brace) } } else { for (const zSign of [-1, 1]) { const brace = new THREE.BoxGeometry(width - legThickness, braceH, braceW) brace.translate(0, braceY, zSign * legZPos) frameGeometries.push(brace) } } // 벨트폭 방향 브레이스 (레일과 수직) if (isVertical) { for (const zSign of [-1, 1]) { const brace = new THREE.BoxGeometry(width - railW, braceH, braceW) brace.translate(0, braceY, zSign * legZPos) frameGeometries.push(brace) } } else { for (const xSign of [-1, 1]) { const brace = new THREE.BoxGeometry(braceW, braceH, height - railW) brace.translate(xSign * legXPos, braceY, 0) frameGeometries.push(brace) } } } const frameMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(frameGeometries), frameMaterial) frameMesh.castShadow = true frameMesh.receiveShadow = true this.components3D.add(frameMesh) // --- Rollers, Belt, or Rails --- if (conveyorType === 'rail') { this.buildRails(width, height, depth, frameH, railW, isVertical) } else if (conveyorType === 'belt') { this.buildBelt(width, height, depth, frameH, railW, isVertical) } else { this.buildRollers(width, height, depth, frameH, railW, rollWidth, isVertical) } } private buildRollers( width: number, height: number, depth: number, frameH: number, railW: number, rollWidth: number, isVertical: boolean = false ) { const rollerRadius = Math.max(rollWidth / 8, 1) const diameter = rollerRadius * 2 const step = diameter * 1.05 const rollerY = depth - rollerRadius const color = this.resolveColor() const rollerGeometries: THREE.BufferGeometry[] = [] if (isVertical) { // 롤러 축 = X축(가로), 이송 방향 = Z축(세로) const rollerLength = width - railW * 2 - 0.5 const count = Math.max(1, Math.floor(height / step)) const totalSpan = (count - 1) * step const startZ = -totalSpan / 2 for (let i = 0; i < count; i++) { const z = startZ + i * step const roller = new THREE.CylinderGeometry(rollerRadius, rollerRadius, rollerLength, 16) roller.rotateZ(Math.PI / 2) roller.translate(0, rollerY, z) rollerGeometries.push(roller) } } else { // 롤러 축 = Z축(세로), 이송 방향 = X축(가로) — 기존 동작 const rollerLength = height - railW * 2 - 0.5 const count = Math.max(1, Math.floor(width / step)) const totalSpan = (count - 1) * step const startX = -totalSpan / 2 for (let i = 0; i < count; i++) { const x = startX + i * step const roller = new THREE.CylinderGeometry(rollerRadius, rollerRadius, rollerLength, 16) roller.rotateX(Math.PI / 2) roller.translate(x, rollerY, 0) rollerGeometries.push(roller) } } if (rollerGeometries.length > 0) { const rollerMesh = new THREE.Mesh( BufferGeometryUtils.mergeGeometries(rollerGeometries), new THREE.MeshStandardMaterial({ color, metalness: 0.9, roughness: 0.2 }) ) rollerMesh.castShadow = true this.components3D.add(rollerMesh) } } private buildBelt(width: number, height: number, depth: number, frameH: number, railW: number, isVertical: boolean = false) { const drumRadius = frameH * 0.38 const beltY = depth - drumRadius const color = this.resolveColor() // 이송 방향 길이와 벨트폭 const transportLen = isVertical ? height : width const beltWidth = isVertical ? width : height const drumLength = beltWidth - railW * 2 // Two end drums const drumGeometries: THREE.BufferGeometry[] = [] const drumInset = drumRadius * 1.5 const drum1 = -transportLen / 2 + drumInset const drum2 = transportLen / 2 - drumInset for (const dp of [drum1, drum2]) { const drum = new THREE.CylinderGeometry(drumRadius, drumRadius, drumLength, 16) if (isVertical) { drum.rotateZ(Math.PI / 2) drum.translate(0, beltY, dp) } else { drum.rotateX(Math.PI / 2) drum.translate(dp, beltY, 0) } drumGeometries.push(drum) } const drumMesh = new THREE.Mesh( BufferGeometryUtils.mergeGeometries(drumGeometries), new THREE.MeshStandardMaterial({ color: ROLLER_COLOR, metalness: 0.9, roughness: 0.2 }) ) drumMesh.castShadow = true this.components3D.add(drumMesh) // Belt surface const beltThickness = drumRadius * 0.12 const beltMaterial = new THREE.MeshStandardMaterial({ color: color, metalness: 0.0, roughness: 0.9, }) // Flat belt top surface spanning between drums const flatLen = drum2 - drum1 const flatGeo = isVertical ? new THREE.BoxGeometry(drumLength, beltThickness, flatLen) : new THREE.BoxGeometry(flatLen, beltThickness, drumLength) const flatMesh = new THREE.Mesh(flatGeo, beltMaterial) flatMesh.position.set(0, beltY + drumRadius, 0) flatMesh.castShadow = true this.components3D.add(flatMesh) // Belt wrap around drums (segmented half-cylinder) const wrapSegments = 12 for (const [dp, angleStart] of [ [drum1, Math.PI / 2], [drum2, -Math.PI / 2], ] as [number, number][]) { const wrapGeometries: THREE.BufferGeometry[] = [] for (let i = 0; i < wrapSegments; i++) { const a0 = angleStart + (Math.PI * i) / wrapSegments const a1 = angleStart + (Math.PI * (i + 1)) / wrapSegments const r = drumRadius + beltThickness / 2 const p0 = dp + Math.cos(a0) * r const y0 = beltY + Math.sin(a0) * r const p1 = dp + Math.cos(a1) * r const y1 = beltY + Math.sin(a1) * r const segLen = Math.sqrt((p1 - p0) ** 2 + (y1 - y0) ** 2) * 1.05 const segAngle = Math.atan2(y1 - y0, p1 - p0) if (isVertical) { const seg = new THREE.BoxGeometry(drumLength, beltThickness, segLen) seg.rotateX(-segAngle) seg.translate(0, (y0 + y1) / 2, (p0 + p1) / 2) wrapGeometries.push(seg) } else { const seg = new THREE.BoxGeometry(segLen, beltThickness, drumLength) seg.rotateZ(segAngle) seg.translate((p0 + p1) / 2, (y0 + y1) / 2, 0) wrapGeometries.push(seg) } } const wrapMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(wrapGeometries), beltMaterial) wrapMesh.castShadow = true this.components3D.add(wrapMesh) } // Flat belt bottom (return path) const bottomGeo = isVertical ? new THREE.BoxGeometry(drumLength * 0.85, beltThickness, flatLen) : new THREE.BoxGeometry(flatLen, beltThickness, drumLength * 0.85) const bottomMesh = new THREE.Mesh(bottomGeo, beltMaterial) bottomMesh.position.set(0, beltY - drumRadius, 0) this.components3D.add(bottomMesh) } private buildRails(width: number, height: number, depth: number, frameH: number, railW: number, isVertical: boolean = false) { const color = this.resolveColor() const railHeight = frameH * 0.3 const railY = depth - railHeight / 2 const beltWidth = isVertical ? width : height const transportLen = isVertical ? height : width const innerWidth = beltWidth - railW * 2 // 두 레일 간격: 내부 폭의 60% const railSpacing = innerWidth * 0.6 const railMaterial = new THREE.MeshStandardMaterial({ color, metalness: 0.7, roughness: 0.3, }) const railGeometries: THREE.BufferGeometry[] = [] // Two parallel rails running along transport direction if (isVertical) { for (const xSign of [-1, 1]) { const rail = new THREE.BoxGeometry(railW * 0.8, railHeight, transportLen - railW * 2) rail.translate(xSign * (railSpacing / 2), railY, 0) railGeometries.push(rail) } } else { for (const zSign of [-1, 1]) { const rail = new THREE.BoxGeometry(transportLen - railW * 2, railHeight, railW * 0.8) rail.translate(0, railY, zSign * (railSpacing / 2)) railGeometries.push(rail) } } const railMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(railGeometries), railMaterial) railMesh.castShadow = true this.components3D.add(railMesh) } updateDimension() {} updateAlpha() {} onchange(after: Record, before: Record) { if ( 'conveyorType' in after || 'orientation' in after || 'width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after ) { this.update() return } super.onchange(after, before) } } registerRealObjectFactory('Conveyor', (component: Component) => new Conveyor3D(component))