/** * 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, MaterialBank } from '@hatiolab/things-scene' import type { Component } from '@hatiolab/things-scene' import { LEGEND_CAPACITY, resolveCarrierLegend } from '../features/mcs-status-default' import { getValueOnRanges } from '../utils/get-value-on-ranges' import { fitGaugeScale } from '../utils/fit-gauge-scale' import { carrierSegmentsFromData, abnormalSegmentsFromData, carrierColorsFromLegend, ABNORMAL_COLORS } from '../utils/stocker-gauge' 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' // Standard body 는 MaterialBank 공유(색 변형) — 인스턴스 dedup + faint 안전(코어가 공유 base 의 // 변형만 스왑, 원본 base 불변). plane 은 unlit MeshBasicMaterial 이라 bank(Standard 전용) 비대상 → raw. const material: THREE.Material = isPlane ? new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1, side: THREE.DoubleSide, }) : MaterialBank.material( 'mcs|body', { metalness, roughness, opacity, side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide, ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}), }, color, ) // z-fighting 방지: 바닥면(floor grid)보다 앞. bank body 는 공유 base 라 polygonOffset 은 모든 mcs // body 공통 상수 — 반환 material 에 세팅(같은 값이라 멱등, 인스턴스별 발산 없음). 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) // mesh 는 super.build() *이후* 생성되므로, 코어의 build-시점 faint 적용이 이 mesh 에 못 닿는다. // mesh 생성 직후 명시 적용 — 코어 applyMaterialOpacity 가 content-alpha × faint 를 일괄 처리. // view 모드에선 factor=1(=state.alpha 없음) → canonical(config.opacity) 그대로, 고객 화면 불변. this.updateAlpha() } // ── 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 override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴. // 코어가 canonical(=build 시 config.opacity 로 만든 material) 위에 factor = state.alpha × faint 를 // 공유 변형 스왑으로 일괄 적용한다(원본 base 불변). 기존 in-place 변이는 (1) faint 미적용, // (2) 코어 applyFocusDim 과 이중 적용 소지가 있어 방언이었다. config.opacity 는 canonical 로 보존. } // ── 헬퍼 ──────────────────────────────────────────────── /** * 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) // 웹 두께 // 공유 base(MaterialBank) — OHTLine rail 은 정적 색이라 전 인스턴스 dedup. faint 는 코어가 // 공유 base 의 변형을 스왑(원본 불변). dispose 는 bank-safe(update 에서 material.dispose 안 함). const mat = MaterialBank.material('oht-line|rail', { metalness: 0.5, roughness: 0.4 }, OHT_LINE_RAIL_COLOR) 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) // mesh 가 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게). this.updateAlpha() } update() { if (this._mesh) { this.object3d.remove(this._mesh) this._mesh.geometry.dispose() // material 은 MaterialBank 공유(앱 수명) — 여기서 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) } // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint). updateDimension() {} } 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?.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 MAX_BAR_W = 130 const MAX_BAR_H = 26 // 스토커 지붕 footprint 에 맞춰 비례 축소 (확대는 안 함 — 현재 크기가 상한). const fit = fitGaugeScale(w, h, MAX_BAR_W, MAX_BAR_H) // PlaneGeometry 의 월드 크기는 clamp, 텍스처 캔버스 해상도는 최대 크기 기준 고정 // (축소되어도 선명도 유지). const barW = MAX_BAR_W * fit const barH = MAX_BAR_H * fit const scale = 4 const canvasW = MAX_BAR_W * scale const canvasH = MAX_BAR_H * 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) // 세그먼트 채움 (왼→오) + carrier/abnormal 은 세그먼트별 숫자(%) 표기 (2D 와 동일) const innerW = canvasW - pad * 2 const innerH = canvasH - pad * 2 // 컴포넌트 폰트 설정(fontFamily/bold/italic) 을 그대로, 크기만 텍스처 scale 배 (2D 와 상대 크기 일치). const fontSize = (Number((this.component as any).state?.fontSize) || 15) * scale const font = this.gaugeFont(fontSize) 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) if (gauge.perSegment) { this.drawGaugeSegmentNumber(ctx, seg.ratio, x, pad, segW, innerH, font, fontSize) } x += segW } // capacity: 전체 중앙에 usage% 한 개 (2D 와 동일). carrier/abnormal 은 위 세그먼트별 숫자 사용. if (gauge.label) { ctx.font = font 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 ): { segments: { ratio: number; color: string }[]; borderColor: string; label: string | null; perSegment: boolean } { switch (mode) { case 'carrier': { // 세그먼트 비율/색상은 2D 와 공유되는 단일 소스에서 계산 (필드/색상 drift 방지). // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득. const colors = carrierColorsFromLegend(resolveCarrierLegend(this.component)) const [f, e, ee] = carrierSegmentsFromData(data, colors) const total = f.count + e.count + ee.count // 접두사 없는 세그먼트별 숫자(%)로 표기 (capacity·2D 와 동일). label(중앙 한 개) 미사용. return total > 0 ? { segments: [f, e, ee].map(s => ({ ratio: s.ratio, color: s.color })), borderColor: colors.full, label: null, perSegment: true, } : { segments: [{ ratio: 1, color: '#dddddd' }], borderColor: colors.full, label: null, perSegment: false } } case 'abnormal': { const [n, a] = abnormalSegmentsFromData(data) const total = n.count + a.count return total > 0 ? { segments: [n, a].map(s => ({ ratio: s.ratio, color: s.color })), borderColor: a.count > 0 ? ABNORMAL_COLORS.abnormal : ABNORMAL_COLORS.normal, label: null, perSegment: true, } : { segments: [{ ratio: 1, color: '#dddddd' }], borderColor: ABNORMAL_COLORS.normal, label: null, perSegment: false, } } default: { // capacity — 전체 중앙에 usage% 한 개 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?.stockerCapacityLegendTheme || LEGEND_CAPACITY const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44') return { segments: [{ ratio: usage / 100, color }], borderColor: color, label: `${usage}%`, perSegment: false, } } } } /** 컴포넌트 폰트 설정(fontFamily/bold/italic)으로 CSS font 문자열 조합. size 는 텍스처 스케일 반영값. */ private gaugeFont(sizePx: number): string { const st = (this.component as any).state || {} const parts: string[] = [] if (st.bold) parts.push('bold') if (st.italic) parts.push('italic') parts.push(`${sizePx}px`) parts.push(st.fontFamily || 'sans-serif') return parts.join(' ') } /** 세그먼트 영역 중심에 숫자(%)만 표기. 좁으면 값% → 값 → 생략 (2D _drawSegmentLabel 과 동일 정책). */ private drawGaugeSegmentNumber( ctx: CanvasRenderingContext2D, ratio: number, x: number, y: number, w: number, h: number, font: string, fontSize: number ) { ctx.font = font ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = '#333333' const value = `${Math.round(ratio * 100)}` const cx = x + w / 2 const cy = y + h / 2 if (fontSize <= h && ctx.measureText(`${value}%`).width <= w) { ctx.fillText(`${value}%`, cx, cy) } else if (fontSize <= h && ctx.measureText(value).width <= w) { ctx.fillText(value, cx, cy) } } 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 미설정. } /** * 적층 시 다음 carrier 의 z-position 이 *_이전 carrier 의 effectiveDepth × N* * 만큼 위에 놓인다. N=1 이면 carrier 들이 맞붙어 적층 (기존), N<1 이면 부분 * overlap (3D box 들이 겹쳐서 적층). 사용자 의도 — carrier 두께는 두꺼워도 * 적층 시 *_compact 하게 보이도록* — N 을 작게. */ const CARRIER_STACK_STEP_RATIO = 0.9 class CarrierRealObject3D extends MCSRealObject3D { constructor(component: Component) { super(component, CARRIER_CONFIG) } /** * cz override — *carrier 자기 결정* 의 적층 위치. * parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth * 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고, * carrier 가 자기 stack 위치를 알아서 결정. */ override get cz(): number { const base = super.cz const parent = this.component.parent as any if (!parent?.components) return base const siblings = parent.components as any[] const idx = siblings.indexOf(this.component) if (idx <= 0) return base let offset = 0 for (let i = 0; i < idx; i++) { const ro = siblings[i]?._realObject const eff = Number(ro?.effectiveDepth) const d = Number.isFinite(eff) && eff > 0 ? eff : 1 offset += d * CARRIER_STACK_STEP_RATIO } // 적층 방향 — placement (= 부모 holder 타입) 에 따라 결정. // OHT 처럼 그리퍼 아래에 매달리는 holder 는 carrier 가 *_아래로_* 누적, // 그 외 (Port / Shelf / Shuttle / Vehicle) 는 위로 누적. // MCSRealObject3D.syncZPosOffset 의 OHT 분기 (-effectiveDepth + halfD) 와 // 같은 방향성 일관 적용. const parentType = parent?.state?.type const dir = parentType === 'OHT' ? -1 : 1 return base + dir * offset } } 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))