/** * MCS 설비 컴포넌트 3D 팩토리 * * 10,000+ 인스턴스를 위한 최소 폴리곤 설계: * - 컴포넌트당 단일 Mesh (draw call 최소화) * - BoxGeometry / ExtrudeGeometry(hexagon) 만 사용 * - Machine: 높은 depth, 컨테이너는 반투명 * - Unit: 낮은 depth, 불투명 */ 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 { LEGEND_CAPACITY } from '../features/mcs-status-default' import { getValueOnRanges } from '../utils/get-value-on-ranges' import { Z_FMSIM } from './z-priority' // ── 설정 타입 ──────────────────────────────────────────── interface MCS3DConfig { /** state.depth 미설정 시 기본 depth. * defaultDepth > 0이면 절대값, 아니면 min(w,h) * depthRatio */ depthRatio: number defaultDepth?: number /** true이면 syncZPosOffset = TRANSPORT_SURFACE_HEIGHT */ defaultZPosFromParent?: boolean /** 1.0 = 불투명, 0.35 = 반투명 컨테이너 */ opacity: number metalness: number roughness: number /** statusColor 미사용 시 기본 색상 */ defaultColor: number /** 'box' | 'hexagon' | 'plane' | 'triangle' */ shape: 'box' | 'hexagon' | 'plane' | 'triangle' /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */ isVisible?: (component: Component) => boolean /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */ sizeRatio?: number /** 상부 면 제거 (5면 개방 박스) */ openTop?: boolean /** 상판 무반사 (MeshBasicMaterial) */ matteTop?: boolean /** 상판에 2D 렌더링 텍스처 적용 */ texturedTop?: boolean /** 자체 발광 강도 (0~1). 조명 약한 면에서도 색상 유지 */ emissiveIntensity?: number /** 바닥면 유지 (기본 false — 바닥면 제거) */ keepBottomFace?: boolean /** syncZPosOffset 고정값 (설정 시 다른 로직 무시) */ zPosOverride?: number /** z-priority — polygonOffset 으로 z-fight 완화. 기본 0 (OBJECTS). */ zPriority?: number } /** * 포트 기본 높이 (절대값). * 모든 이송장치의 상단 높이 = 이 값. * 캐리어가 이송장치 간 높이 차 없이 이동할 수 있는 기준 높이. */ export const TRANSPORT_SURFACE_HEIGHT = 30 const DEFAULTS: MCS3DConfig = { depthRatio: 0.5, opacity: 1.0, metalness: 0.3, roughness: 0.7, defaultColor: 0xaabbcc, shape: 'box', } // ── 3D 오브젝트 ────────────────────────────────────────── class MCSRealObject3D extends RealObjectGroup { private _config: MCS3DConfig constructor(component: Component, config: MCS3DConfig) { super(component) this._config = config } get zPriority(): number { return this._config.zPriority ?? 0 } // width, height 절대값 (음수 값은 양수로 취급). // `resizable:false` 컴포넌트(예: Scanner)는 state.width/height 가 없고 // bounds 만 있을 수 있으므로 bounds 를 fallback 으로 사용. // // `??` only catches null/undefined — a property-panel input that produced // NaN would slip through and corrupt every downstream Math.* call. Use a // finite check so NaN falls through to bounds → 10 like missing values do. protected get absSize(): { w: number; h: number } { const state = (this.component.state || {}) as any const bounds = (this.component.bounds || {}) as any const width = pickFinite(state.width, bounds.width, 10) const height = pickFinite(state.height, bounds.height, 10) return { w: Math.abs(width), h: Math.abs(height) } } get effectiveDepth(): number { const { depth } = this.component.state // Truthy `if (depth)` already rejected NaN/0/undefined, but be explicit // so the intent (and the resolution chain) reads cleanly: only an // explicit positive finite number wins; everything else → defaults. if (typeof depth === 'number' && Number.isFinite(depth) && depth > 0) { return depth } if (this._config.defaultDepth) return this._config.defaultDepth const { w, h } = this.absSize return Math.min(w, h) * this._config.depthRatio } protected get syncZPosOffset(): number { // syncZPosOffset = placement + geometric (center origin = effectiveDepth/2) // 중첩 보정(parent shift 상쇄)은 base RealObject.cz가 parent.geometricOffsetY를 // 빼는 방식으로 처리하므로 여기선 placement + 본인 geometric만 반환 const halfD = this.effectiveDepth / 2 // zPosOverride: 고정 높이 (OHTLine 등) if (this._config.zPosOverride != null) { return this._config.zPosOverride + halfD } // Port: 이송 표면 높이에 배치 if (this._config.defaultZPosFromParent) { return TRANSPORT_SURFACE_HEIGHT + halfD } // Carrier: 부모 유형에 따라 배치 위치 결정 if (this._config.sizeRatio && this._config.sizeRatio < 1) { const parentType = this.component.parent?.state?.type // OHT: 그리퍼 아래에 매달림 — placement = -effectiveDepth(carrier top이 OHT bottom) if (parentType === 'OHT') { return -this.effectiveDepth + halfD } if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'Shuttle') { return halfD } return TRANSPORT_SURFACE_HEIGHT + halfD } return halfD } // geometric 부분(center origin: depth/2)은 base 기본값 사용 — override 불필요 // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환 protected 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 this._config.defaultColor } // ── Geometry helpers ── /** * 바닥면(-Y 방향) 삼각형 제거: z-fighting 근본 해결. * 바닥면이 없으면 floor grid와 겹칠 면 자체가 없어진다. */ private removeBottomFace(geometry: THREE.BufferGeometry): THREE.BufferGeometry { const posAttr = geometry.getAttribute('position') const normAttr = geometry.getAttribute('normal') const index = geometry.getIndex() if (!posAttr || !normAttr || !index) return geometry const newIndices: number[] = [] const normalY = new THREE.Vector3() for (let i = 0; i < index.count; i += 3) { const a = index.getX(i) const b = index.getX(i + 1) const c = index.getX(i + 2) // 세 꼭짓점 노말의 Y 성분 평균으로 방향 판단 const avgY = (normAttr.getY(a) + normAttr.getY(b) + normAttr.getY(c)) / 3 // -Y 방향 삼각형 제거 (threshold -0.9: 거의 아래를 향하는 면만) if (avgY > -0.9) { newIndices.push(a, b, c) } } geometry.setIndex(newIndices) return geometry } // ── Geometry builders ── private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry { const geo = new THREE.BoxGeometry(w, d, h) return this._config.keepBottomFace ? geo : this.removeBottomFace(geo) } private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry { // Buffer의 2D 형태: 좌우 끝이 꺾인 육각형 const offset = Math.min(w, h) * 0.2 const hw = w / 2 const hh = h / 2 const shape = new THREE.Shape() shape.moveTo(-hw + offset, -hh) shape.lineTo(hw - offset, -hh) shape.lineTo(hw, 0) shape.lineTo(hw - offset, hh) shape.lineTo(-hw + offset, hh) shape.lineTo(-hw, 0) shape.closePath() const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: d, bevelEnabled: false, }) // 중심 정렬 후 Y-up 좌표계 회전 (extrude 축: +Z → +Y) geo.translate(0, 0, -d / 2) geo.rotateX(Math.PI / 2) return this.removeBottomFace(geo) } private buildPlaneGeometry(w: number, h: number): THREE.BufferGeometry { const geo = new THREE.PlaneGeometry(w, h) geo.rotateX(-Math.PI / 2) return geo } /** * 삼각형 prism — 2D Triangle 의 꼭짓점(state.x1/y1, x2/y2, x3/y3) 을 그대로 사용. * things-scene 의 TriangleExtrude 와 동일한 방식 — 각 Scanner 인스턴스의 * 실제 정점 배치(방향, 비대칭 등) 가 3D 에 반영된다. * * `geometry.center()` 로 로컬 원점을 bounding-box 중심으로 맞추므로 * 후속 placement 와 정합. */ private buildTriangleGeometry(d: number): THREE.BufferGeometry { const state = (this.component.state || {}) as any const { x1 = 0, y1 = 0, x2 = 0, y2 = 0, x3 = 0, y3 = 0 } = state const shape = new THREE.Shape() shape.moveTo(x1, y1) shape.lineTo(x2, y2) shape.lineTo(x3, y3) shape.closePath() const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: d, bevelEnabled: false, }) // bounding box 중심으로 원점 정렬 후 Y-up 좌표계로 회전 geo.center() geo.rotateX(Math.PI / 2) return this.removeBottomFace(geo) } // ── Build ── protected _ownMesh?: THREE.Mesh build() { super.build() // 가시성 판정 — e.g., Carrier는 데이터 없으면 mesh 미생성 if (this._config.isVisible && !this._config.isVisible(this.component)) return const { w: rawW, h: rawH } = this.absSize const rawDepth = this.effectiveDepth if (!rawW || !rawH) return const ratio = this._config.sizeRatio ?? 1.0 const w = rawW * ratio const h = rawH * ratio const depth = rawDepth * ratio const color = this.resolveColor() const { opacity, metalness, roughness, shape } = this._config const geometry = shape === 'plane' ? this.buildPlaneGeometry(w, h) : shape === 'hexagon' ? this.buildHexagonGeometry(w, depth, h) : shape === 'triangle' ? this.buildTriangleGeometry(depth) : this.buildBoxGeometry(w, depth, h) const isPlane = shape === 'plane' const material = isPlane ? new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1, side: THREE.DoubleSide, }) : new THREE.MeshStandardMaterial({ color, metalness, roughness, opacity, transparent: opacity < 1, side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide, ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}), }) // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링 material.polygonOffset = true material.polygonOffsetFactor = -1 material.polygonOffsetUnit = -1 // BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5) let meshMaterial: THREE.Material | THREE.Material[] = material if (shape === 'box') { if (this._config.openTop) { // 상단면 제거 → 5면 개방 박스 const invisible = new THREE.MeshBasicMaterial({ visible: false }) meshMaterial = [material, material, invisible, material, material, material] } else if (this._config.texturedTop) { // 상판에 2D 렌더링 텍스처 const topMat = this.buildTopTexture(w, h) meshMaterial = [material, material, topMat, material, material, material] } else if (this._config.matteTop) { // 상판만 무반사 const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 }) meshMaterial = [material, material, matte, material, material, material] } } this._ownMesh = new THREE.Mesh(geometry, meshMaterial) // ownGroup 기준(bottom-up): mesh 바닥을 y=0에 맞추고 ownGroup이 -d/2로 shift해 // center-origin object3d에 정렬됨. // box/hexagon (center-local geometry): y=rawDepth/2 (바닥을 0에 맞춤) // plane: y=1 (바닥 살짝 띄움) this._ownMesh.position.y = isPlane ? 1 : rawDepth / 2 this._ownMesh.castShadow = !isPlane this._ownMesh.receiveShadow = true this.components3D.add(this._ownMesh) } // ── Change handling ── private _updatePending = false onchange(after: Record, before: Record) { if ( 'width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after || 'id' in after || 'EMPTYTYPE' in after || 'CARRIERSTATUS' in after ) { // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행 if (!this._updatePending) { this._updatePending = true queueMicrotask(() => { this._updatePending = false this.update() }) } return } super.onchange(after, before) } /** * 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용 */ protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial { const scale = 4 const cw = this.nextPow2(w * scale) const ch = this.nextPow2(h * scale) const canvas = document.createElement('canvas') canvas.width = cw canvas.height = ch const ctx = canvas.getContext('2d')! const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0') const emptyType = (this.component.state as any)?.EMPTYTYPE || '' const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : '' // 실제 사용 영역 (nextPow2 이전 크기) const usedW = w * scale const usedH = h * scale // 바탕: 캐리어 상태 색상 (사용 영역만) ctx.fillStyle = statusColor ctx.fillRect(0, 0, usedW, usedH) // 텍스트 (사용 영역 중앙) if (text) { const fontSize = Math.round(Math.min(usedW, usedH) * 0.7) ctx.font = `bold ${fontSize}px Arial` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = '#000000' ctx.fillText(text, usedW / 2, usedH / 2) } const texture = new THREE.CanvasTexture(canvas) texture.offset.set(0, 1 - usedH / ch) texture.repeat.set(usedW / cw, usedH / ch) texture.colorSpace = THREE.SRGBColorSpace return new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, }) } private nextPow2(n: number): number { if (!(n > 0)) return 1 let v = Math.ceil(n) v-- v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16 return v + 1 } /** * 자신의 mesh만 제거 후 rebuild. * 기본 update()는 clear()로 object3d의 모든 children을 제거하는데, * 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨. * 이를 방지하기 위해 자신이 생성한 mesh만 제거한다. */ update() { // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존) if (this._ownMesh) { this.components3D.remove(this._ownMesh) disposeObject3D(this._ownMesh) this._ownMesh = undefined } this.build() this.updateTransform() this.updateAlpha() this.updateHidden() } // RealObjectGroup 기본 동작 비활성화 updateDimension() {} updateAlpha() { const state = this.component.state // alpha가 명시적으로 설정되어 있으면 그 값을 사용, 없으면 config 기본값 적용 const effective = ('alpha' in state) ? (state.alpha as number) : this._config.opacity this.object3d.traverse((o: any) => { if (!o.material) return const materials = Array.isArray(o.material) ? o.material : [o.material] for (const m of materials) { if (!m.visible) continue // openTop invisible face 무시 if (m.map) continue // 텍스처 material은 자체 transparent 유지 m.opacity = effective m.transparent = effective < 1 } }) } } // ── 헬퍼 ──────────────────────────────────────────────── /** * Returns the first argument that is a finite number. NaN slips past `??` * (which only catches null/undefined), so without this a property-panel * input producing NaN would corrupt absSize and cascade through Math.*. */ function pickFinite(...candidates: unknown[]): number { for (const c of candidates) { if (typeof c === 'number' && Number.isFinite(c)) return c } return 0 } // ── 팩토리 등록 헬퍼 ───────────────────────────────────── function register(type: string, overrides: Partial = {}) { const config: MCS3DConfig = { ...DEFAULTS, ...overrides } registerRealObjectFactory(type, (component: Component) => new MCSRealObject3D(component, config)) } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Machine (인프라 설비) — 높은 depth, 컨테이너는 반투명 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ register('Equipment', { defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT, opacity: 0.75, metalness: 0.4, roughness: 0.6, defaultColor: 0x8899aa, zPriority: Z_FMSIM.EQUIPMENT, }) register('Buffer', { defaultDepth: TRANSPORT_SURFACE_HEIGHT, opacity: 1.0, metalness: 0.3, roughness: 0.7, defaultColor: 0x99aa88, shape: 'hexagon', zPriority: Z_FMSIM.BUFFER, }) register('AGVLine', { depthRatio: 0, defaultDepth: 0.1, metalness: 0.6, roughness: 0.3, defaultColor: 0x888899, zPriority: Z_FMSIM.AGV_LINE, }) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // OHTLine — 천장 I-beam 레일 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const OHT_LINE_RAIL_COLOR = 0xcccccc const OHT_LINE_HEIGHT = 7 * TRANSPORT_SURFACE_HEIGHT // OHT 상단과 일치 class OHTLineRealObject3D extends RealObjectGroup { private _mesh?: THREE.Mesh get zPriority(): number { return Z_FMSIM.OHT_LINE } get effectiveDepth(): number { return 0 } protected get syncZPosOffset(): number { return OHT_LINE_HEIGHT } build() { super.build() const { width = 10, height = 10 } = this.component.state const w = Math.abs(width) const h = Math.abs(height) if (!w || !h) return // I-beam 치수: 이송 방향 = 긴 변, 폭은 2D의 1/2 const isVertical = w < h const shortSide = isVertical ? w : h // 2D 원본 짧은 쪽 폭 const beltW = shortSide * 0.25 // I-beam 폭: 원본의 1/4 const beamH = Math.max(beltW * 0.5, 3) // I-beam 전체 높이 const inset = shortSide * 0.5 // 양끝 여백: 원본 폭의 1/2 const railLen = (isVertical ? h : w) - inset * 2 const flangeH = beamH * 0.2 // 플랜지 두께 const flangeW = beltW * 0.8 // 플랜지 폭 const webH = beamH - flangeH * 2 // 웹 높이 const webW = Math.max(flangeW * 0.25, 1) // 웹 두께 const mat = new THREE.MeshStandardMaterial({ color: OHT_LINE_RAIL_COLOR, metalness: 0.5, roughness: 0.4, }) const geoms: THREE.BufferGeometry[] = [] // 상부 플랜지 const topFlange = isVertical ? new THREE.BoxGeometry(flangeW, flangeH, railLen) : new THREE.BoxGeometry(railLen, flangeH, flangeW) topFlange.translate(0, -flangeH / 2, 0) geoms.push(topFlange) // 웹 (수직판) const web = isVertical ? new THREE.BoxGeometry(webW, webH, railLen) : new THREE.BoxGeometry(railLen, webH, webW) web.translate(0, -flangeH - webH / 2, 0) geoms.push(web) // 하부 플랜지 const botFlange = isVertical ? new THREE.BoxGeometry(flangeW, flangeH, railLen) : new THREE.BoxGeometry(railLen, flangeH, flangeW) botFlange.translate(0, -flangeH - webH - flangeH / 2, 0) geoms.push(botFlange) this._mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(geoms), mat) this._mesh.castShadow = true this.object3d.add(this._mesh) } update() { if (this._mesh) { this.object3d.remove(this._mesh) this._mesh.geometry.dispose() ;(this._mesh.material as THREE.Material).dispose() this._mesh = undefined } this.build() this.updateTransform() this.updateHidden() } 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) } updateDimension() {} updateAlpha() {} } registerRealObjectFactory('OHTLine', (component: Component) => new OHTLineRealObject3D(component)) // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트 const STOCKER_CONFIG: MCS3DConfig = { ...DEFAULTS, defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT, opacity: 0.75, metalness: 0.3, roughness: 0.6, defaultColor: 0x8899bb, zPriority: Z_FMSIM.STOCKER, } class StockerRealObject3D extends MCSRealObject3D { private gaugeMesh?: THREE.Mesh constructor(component: Component) { super(component, STOCKER_CONFIG) } build() { super.build() this.buildGauge() } private buildGauge() { const comp = this.component as any const data = comp.data || {} const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity' const gauge = this.resolveGauge(data, gaugeMode) // 테두리는 스토커의 상태 색상 (machine status) const statusColor = comp.statusColor if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') { gauge.borderColor = statusColor } const { w, h } = this.absSize const depth = this.effectiveDepth const isWidthLonger = w >= h // 게이지 캔버스 (고해상도) const barW = 130 const barH = 26 const scale = 4 const canvasW = barW * scale const canvasH = barH * scale const canvas = document.createElement('canvas') canvas.width = canvasW canvas.height = canvasH const ctx = canvas.getContext('2d')! const pad = 12 // 배경 + 테두리 ctx.fillStyle = '#dddddd' ctx.fillRect(0, 0, canvasW, canvasH) ctx.strokeStyle = gauge.borderColor ctx.lineWidth = 12 ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12) // 세그먼트 채움 (왼→오) const innerW = canvasW - pad * 2 const innerH = canvasH - pad * 2 let x = pad for (const seg of gauge.segments) { const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0) if (segW <= 0) continue ctx.fillStyle = seg.color ctx.fillRect(x, pad, segW, innerH) x += segW } // 텍스트 ctx.font = `bold ${10 * scale}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = '#333333' ctx.fillText(gauge.label, canvasW / 2, canvasH / 2) // 텍스처 → Plane const texture = new THREE.CanvasTexture(canvas) texture.colorSpace = THREE.SRGBColorSpace const geo = new THREE.PlaneGeometry(barW, barH) geo.rotateX(-Math.PI / 2) // 수평 (지붕 위) if (!isWidthLonger) geo.rotateY(Math.PI / 2) const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, }) mat.polygonOffset = true mat.polygonOffsetFactor = -2 mat.polygonOffsetUnit = -2 this.gaugeMesh = new THREE.Mesh(geo, mat) // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움. this.gaugeMesh.position.set(0, depth + 0.5, 0) this.components3D.add(this.gaugeMesh) } private resolveGauge(data: any, mode: string) { switch (mode) { case 'carrier': { const full = Number(data.FULL_CNT) || 0 const empty = Number(data.EMPTY_CNT) || 0 const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0 const total = full + empty + emptyEmpty const segments = total > 0 ? [ { ratio: full / total, color: '#4AA7FE' }, { ratio: empty / total, color: '#8BDA5B' }, { ratio: emptyEmpty / total, color: '#019D59' }, ] : [{ ratio: 1, color: '#dddddd' }] return { segments, borderColor: '#4AA7FE', label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '', } } case 'abnormal': { const normal = Number(data.CARRIER_NORMAL) || 0 const abnormal = Number(data.CARRIER_ABNORMAL) || 0 const total = normal + abnormal const segments = total > 0 ? [ { ratio: normal / total, color: '#8BDA5B' }, { ratio: abnormal / total, color: '#F4B4B4' }, ] : [{ ratio: 1, color: '#dddddd' }] return { segments, borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B', label: total > 0 ? `N${normal} A${abnormal}` : '', } } default: { // capacity const cur = Number(data.CURRENTCAPACITY) || 0 const max = Number(data.MAXCAPACITY) || 100 const fullup = data.FULLUP === 'T' const usage = Math.round((cur / max) * 100) const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44') return { segments: [{ ratio: usage / 100, color }], borderColor: color, label: `${usage}%`, } } } } update() { this.clearGauge() super.update() } onchange(after: Record, before: Record) { if ('data' in after) { this.clearGauge() this.buildGauge() } super.onchange(after, before) } clear() { this.clearGauge() return super.clear() } private clearGauge() { if (this.gaugeMesh) { this.components3D.remove(this.gaugeMesh) this.gaugeMesh.geometry?.dispose() const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial mat?.map?.dispose() mat?.dispose() this.gaugeMesh = undefined } } } registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component)) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Unit (이동체 · 홀더) — 낮은 depth, 불투명 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Port: 바닥판 (PlaneGeometry) const PORT_CONFIG: MCS3DConfig = { ...DEFAULTS, depthRatio: 0.01, defaultZPosFromParent: true, opacity: 0.1, metalness: 0.3, roughness: 0.8, defaultColor: 0xb0a090, shape: 'plane', zPriority: Z_FMSIM.PORT, } registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG)) register('Shelf', { depthRatio: 0.25, metalness: 0.3, roughness: 0.7, defaultColor: 0xaabbcc, zPriority: Z_FMSIM.SHELF, }) // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원 const CARRIER_CONFIG: MCS3DConfig = { ...DEFAULTS, depthRatio: 0.5, metalness: 0, roughness: 1, defaultColor: 0xf0f0f0, isVisible: (c: Component) => !!(c.state as any).id, sizeRatio: 0.8, texturedTop: true, // Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정. } class CarrierRealObject3D extends MCSRealObject3D { constructor(component: Component) { super(component, CARRIER_CONFIG) } } registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component)) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Gauge (용량 게이지) — 얇은 바 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Gauge 컴포넌트는 3D에서 표시하지 않음 (부모 STOCKER의 3D 모델에서 직접 게이지 렌더링) // null 반환으로 3D 오브젝트 생성을 억제하고, default factory fallback도 방지 registerRealObjectFactory('StockerCapacityBar', () => undefined as any) registerRealObjectFactory('MCSGaugeCapacityBar', () => undefined as any) registerRealObjectFactory('ZoneCapacityBar', () => undefined as any) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // PortFlow — 2D render를 Plane 텍스처로 3D 표시 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class PortFlowRealObject3D extends RealObjectGroup { private _mesh?: THREE.Mesh private _texture?: THREE.CanvasTexture get zPriority(): number { return Z_FMSIM.PORT_FLOW } get effectiveDepth(): number { return 0 } protected get syncZPosOffset(): number { // 부모 Port/Conveyor의 zPos와 동일하게 const parent = (this.component as any).parentObject if (parent?.state?.zPos != null) return parent.state.zPos return TRANSPORT_SURFACE_HEIGHT } build() { super.build() const { width = 10, height = 10 } = this.component.state const w = Math.abs(width) const h = Math.abs(height) if (!w || !h) return // 2D render를 캔버스에 캡처 // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산 const { left, top } = this.component.bounds const { fontSize = 24, labelPosition = 'top' } = this.component.state const arrowPad = Math.min(w, h) * 0.4 // 텍스트 영역 확장 let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad if ((this.component as any).hasTextProperty) { switch (labelPosition) { case 'top': padTop = Math.max(padTop, fontSize + 4); break case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break } } const totalW = w + padLeft + padRight const totalH = h + padTop + padBottom const scale = 2 const canvas = document.createElement('canvas') canvas.width = totalW * scale canvas.height = totalH * scale const ctx = canvas.getContext('2d')! ctx.save() ctx.scale(scale, scale) ctx.translate(-left + padLeft, -top + padTop) try { // 화살표 렌더 this.component.render(ctx) ctx.fillStyle = this.component.state.strokeStyle || '#666666' ctx.fill() // 텍스트 렌더 if ((this.component as any).hasTextProperty && (this.component as any).drawText) { ;(this.component as any).drawText(ctx) } } catch { /* render 실패 시 빈 텍스처 */ } ctx.restore() this._texture = new THREE.CanvasTexture(canvas) this._texture.colorSpace = THREE.SRGBColorSpace const geo = new THREE.PlaneGeometry(totalW, totalH) geo.rotateX(-Math.PI / 2) const mat = new THREE.MeshBasicMaterial({ map: this._texture, alphaTest: 0.1, side: THREE.DoubleSide, }) this._mesh = new THREE.Mesh(geo, mat) // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤 this._mesh.position.x = (padRight - padLeft) / 2 this._mesh.position.y = 1 this._mesh.position.z = (padBottom - padTop) / 2 this.object3d.add(this._mesh) } update() { if (this._mesh) { this.object3d.remove(this._mesh) this._mesh.geometry.dispose() ;(this._mesh.material as THREE.Material).dispose() this._texture?.dispose() this._mesh = undefined this._texture = undefined } this.build() this.updateTransform() this.updateHidden() } onchange(after: Record, before: Record) { if ('width' in after || 'height' in after || 'orientation' in after || 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) { this.update() return } super.onchange(after, before) } updateDimension() {} updateAlpha() {} } registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component)) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class NodeRealObject3D extends RealObjectGroup { private _mesh?: THREE.Mesh private _texture?: THREE.CanvasTexture get zPriority(): number { return Z_FMSIM.NODE } get effectiveDepth(): number { return 0 } protected get syncZPosOffset(): number { return 0 } build() { super.build() const { cx, cy } = this.component.state const radius = 8 const size = radius * 2 const scale = 2 const pad = 2 const canvas = document.createElement('canvas') canvas.width = (size + pad * 2) * scale canvas.height = (size + pad * 2) * scale const ctx = canvas.getContext('2d')! ctx.save() ctx.scale(scale, scale) ctx.translate(-cx + radius + pad, -cy + radius + pad) try { this.component.render(ctx) const { fillStyle = '#888888' } = this.component.state ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888' ctx.fill() const { strokeStyle } = this.component.state if (strokeStyle && typeof strokeStyle === 'string') { ctx.strokeStyle = strokeStyle ctx.lineWidth = 1 ctx.stroke() } } catch { /* render 실패 시 빈 텍스처 */ } ctx.restore() this._texture = new THREE.CanvasTexture(canvas) this._texture.colorSpace = THREE.SRGBColorSpace const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2) geo.rotateX(-Math.PI / 2) const mat = new THREE.MeshBasicMaterial({ map: this._texture, alphaTest: 0.1, side: THREE.DoubleSide, }) this._mesh = new THREE.Mesh(geo, mat) this._mesh.position.y = 1 this.object3d.add(this._mesh) } update() { if (this._mesh) { this.object3d.remove(this._mesh) this._mesh.geometry.dispose() ;(this._mesh.material as THREE.Material).dispose() this._texture?.dispose() this._mesh = undefined this._texture = undefined } this.build() this.updateTransform() this.updateHidden() } onchange(after: Record, before: Record) { if ('fillStyle' in after || 'strokeStyle' in after) { this.update() return } super.onchange(after, before) } updateDimension() {} updateAlpha() {} } registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component)) // Scanner — 2D Triangle 실루엣과 정합되는 삼각형 prism. // 이송 표면 높이의 0.2배(=6) 얇은 기둥 — 원래 값 유지, shape 만 'box' → 'triangle'. const SCANNER_CONFIG: MCS3DConfig = { ...DEFAULTS, defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2, defaultZPosFromParent: true, opacity: 1, metalness: 0.3, roughness: 0.7, defaultColor: 0x888888, shape: 'triangle', zPriority: Z_FMSIM.SCANNER, } registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))