import { Component, RootContainer } from '@hatiolab/things-scene' import { themesColorMap, themesColorRange, themesAnimationMap } from '@fmsim/api' import { Legend } from '../features/mcs-status-default' import CarrierManager from '../carrier-manager' // RootContainer 클래스 타입 확장 declare module '@hatiolab/things-scene' { interface RootContainer { portLegendTheme?: any carrierLegendTheme?: any carrierAnimationTheme?: any stockerCapacityLegendTheme?: any zoneCapacityLegendTheme?: any batteryRateLegendTheme?: any bufferLegendTheme?: any agvlineLegendTheme?: any ohtlineLegendTheme?: any conveyorLegendTheme?: any stokerLegendTheme?: any agvLegendTheme?: any ohtLegendTheme?: any shuttleLegendTheme?: any craneLegendTheme?: any shelfLegendTheme?: any equipmentLegendTheme?: any style: any vehicleAnimation?: boolean // 스토커 게이지 표시 모드 — 런타임 전용 transient (모델 비저장, 항상 'capacity' 시작) stockerGaugeMode: string _stockerGaugeMode?: string _refreshStockerGauges(): void // 캐시 관련 속성 추가 _animationConfigCache?: Map _carrierManager?: CarrierManager carrierManager: CarrierManager } } /** * 스토커 적재율 게이지 표시 모드 — 런타임 선택지. * value 는 엔진 canonical(2D StockerCapacityBar / 3D buildGauge 공통), label 은 표시명. * 런타임 UI(뷰어 컨트롤)가 이 목록으로 selector 를 구성한다. */ export const STOCKER_GAUGE_MODES: { value: string; label: string }[] = [ { value: 'capacity', label: 'Capacity' }, { value: 'carrier', label: 'Carrier Status' }, { value: 'abnormal', label: 'Abnormal Carrier' } ] const ModelLayer = (Component as any).register('model-layer') as any Object.defineProperty(ModelLayer.prototype, 'nature', { get: function () { return { mutable: false, rotatable: false, resizable: true, properties: [ { type: 'boolean', label: 'vehicle-animation', name: 'vehicleAnimation' }, { type: 'legend', label: '', name: 'LEGENDS', property: { label: 'LEGENDS' } }, { type: 'select', label: 'equipment-legend-name', name: 'equipmentLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'buffer-legend-name', name: 'bufferLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'agvline-legend-name', name: 'agvlineLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'ohtline-legend-name', name: 'ohtlineLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'conveyor-legend-name', name: 'conveyorLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'stoker-legend-name', name: 'stokerLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'port-legend-name', name: 'portLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'agv-legend-name', name: 'agvLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'oht-legend-name', name: 'ohtLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'shuttle-legend-name', name: 'shuttleLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'crane-legend-name', name: 'craneLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'port-legend-name', name: 'portLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'shelf-legend-name', name: 'shelfLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'carrier-legend-name', name: 'carrierLegendName', property: { options: themesColorMap } }, { type: 'select', label: 'carrier-animation-name', name: 'carrierAnimationName', property: { options: themesAnimationMap } }, { type: 'select', label: 'stocker-capacity-legend-name', name: 'stockerCapacityLegendName', property: { options: themesColorRange } }, { type: 'select', label: 'zone-capacity-legend-name', name: 'zoneCapacityLegendName', property: { options: themesColorRange } }, { type: 'select', label: 'battery-rate-legend-name', name: 'batteryRateLegendName', property: { options: themesColorRange } }, { type: 'legend', label: '', name: 'MOOD', requires3d: true, property: { label: 'MOOD' } }, { type: 'select', label: 'light-preset', name: 'lightPreset', requires3d: true, property: { options: ['dark', 'medium', 'bright'] } } ], 'value-property': 'text' } } }) /** * 조명 프리셋 — 3 단계 밝기 토글. * * fmsim 은 디테일 조명 옵션 UI 를 노출하지 않으므로 단일 select 로 단순화. * medium = things-scene 의 현재 default 값과 동일 (외관 변화 없음). * 사용자가 select 변경 시 onchange 가 exposure + hemiIntensity + dirLightIntensity * 를 일괄 set → three-capability 의 변경 감지 핸들러가 즉시 갱신. */ const LIGHT_PRESETS: Record = { dark: { exposure: 1.2, hemiIntensity: 4.0, dirLightIntensity: 0.6 }, medium: { exposure: 1.2, hemiIntensity: 4.0, dirLightIntensity: 2.0 }, bright: { exposure: 1.2, hemiIntensity: 4.0, dirLightIntensity: 5.0 } } const _origModelLayerOnchange = ModelLayer.prototype.onchange ModelLayer.prototype.onchange = function (after: any) { if (after && Object.prototype.hasOwnProperty.call(after, 'lightPreset')) { const preset = LIGHT_PRESETS[after.lightPreset as string] if (preset) { this.set({ exposure: preset.exposure, hemiIntensity: preset.hemiIntensity, dirLightIntensity: preset.dirLightIntensity }) } } return _origModelLayerOnchange.call(this, after) } // === LegendTheme / AnimationTheme value resolve === // // rootModel.state.style[name] 에 저장된 legend/animation 값은 보드 직렬화 // 형태에 따라 (a) 정상 object 또는 (b) JSON 문자열 둘 다로 나타난다 (2026-06-09 // 진단 — consumer 인 carrier.ts:animationPreset 이 같은 이유로 typeof string // 분기 + JSON5.parse 처리하는 선례 존재). LegendTheme getter 들이 이 분기를 // 두지 않아 string 통과 → getValueOnRanges 매칭 실패 → 3D gauge 의 색상이 // fallback 으로만 결정되던 증상. // // fix 는 getter 한 곳에서 lazy parse. WeakMap> // 으로 *raw string 자체를 key* — 보드 편집으로 raw 가 바뀌면 자연 무효화, // 같은 raw 의 반복 lookup 은 O(1). root 가 GC 되면 cache 도 같이 GC. const _legendParseCache = new WeakMap>() function parseLegendIfString(state: any, raw: any): any { if (typeof raw !== 'string') return raw let cache = _legendParseCache.get(state) if (!cache) { cache = new Map() _legendParseCache.set(state, cache) } if (cache.has(raw)) return cache.get(raw) let parsed: any try { parsed = JSON.parse(raw) } catch { parsed = raw } // parse 실패 시 raw 보존 cache.set(raw, parsed) return parsed } function legendThemeGetter(stateKey: string) { return function (this: any) { const name = this.rootModel.state[stateKey] const raw = name && this.state.style[name] return parseLegendIfString(this.state, raw) } } Object.defineProperty(RootContainer.prototype, 'equipmentLegendTheme', { get: legendThemeGetter('equipmentLegendName') }) Object.defineProperty(RootContainer.prototype, 'bufferLegendTheme', { get: legendThemeGetter('bufferLegendName') }) Object.defineProperty(RootContainer.prototype, 'agvlineLegendTheme', { get: legendThemeGetter('agvlineLegendName') }) Object.defineProperty(RootContainer.prototype, 'ohtlineLegendTheme', { get: legendThemeGetter('ohtlineLegendName') }) Object.defineProperty(RootContainer.prototype, 'conveyorLegendTheme', { get: legendThemeGetter('conveyorLegendName') }) Object.defineProperty(RootContainer.prototype, 'stokerLegendTheme', { get: legendThemeGetter('stokerLegendName') }) Object.defineProperty(RootContainer.prototype, 'agvLegendTheme', { get: legendThemeGetter('agvLegendName') }) Object.defineProperty(RootContainer.prototype, 'ohtLegendTheme', { get: legendThemeGetter('ohtLegendName') }) Object.defineProperty(RootContainer.prototype, 'shuttleLegendTheme', { get: legendThemeGetter('shuttleLegendName') }) Object.defineProperty(RootContainer.prototype, 'craneLegendTheme', { get: legendThemeGetter('craneLegendName') }) Object.defineProperty(RootContainer.prototype, 'shelfLegendTheme', { get: legendThemeGetter('shelfLegendName') }) Object.defineProperty(RootContainer.prototype, 'portLegendTheme', { get: legendThemeGetter('portLegendName') }) Object.defineProperty(RootContainer.prototype, 'carrierLegendTheme', { get: legendThemeGetter('carrierLegendName') }) Object.defineProperty(RootContainer.prototype, 'carrierAnimationTheme', { get: legendThemeGetter('carrierAnimationName') }) Object.defineProperty(RootContainer.prototype, 'stockerCapacityLegendTheme', { get: legendThemeGetter('stockerCapacityLegendName') }) Object.defineProperty(RootContainer.prototype, 'zoneCapacityLegendTheme', { get: legendThemeGetter('zoneCapacityLegendName') }) Object.defineProperty(RootContainer.prototype, 'batteryRateLegendTheme', { get: legendThemeGetter('batteryRateLegendName') }) Object.defineProperty(RootContainer.prototype, 'vehicleAnimation', { get: function () { const { vehicleAnimation } = this.rootModel.state return !!vehicleAnimation } }) Object.defineProperty(RootContainer.prototype, 'carrierManager', { get: function () { if (!this._carrierManager) { this._carrierManager = new CarrierManager(this) } return this._carrierManager } }) // === 스토커 게이지 표시 모드 (런타임 전용 transient) === // // 모델(state)에 저장하지 않는다 — nature 속성이 아니므로 보드 직렬화에 포함되지 않고, // 보드를 다시 열면 항상 기본값 'capacity' 로 시작한다. 실행 중 뷰어 컨트롤 등이 // `scene.rootContainer.stockerGaugeMode = 'carrier'` 로 동적 변경한다. // (주의: scene.root 은 model_layer 라 override 가 없다. 반드시 rootContainer 에 설정. // 컴포넌트의 this.root 는 RootContainer(isRoot)로 해석되어 이 값을 읽는다.) // // 2D(StockerCapacityBar.gaugeMode) 와 3D(machine-3d buildGauge) 가 모두 이 값을 읽어 // 게이지 표현이 항상 일치한다. 값이 바뀌면 setter 가 2D 리드로우 + 3D 게이지 재빌드를 // 즉시 트리거한다. Object.defineProperty(RootContainer.prototype, 'stockerGaugeMode', { get: function () { return this._stockerGaugeMode || 'capacity' }, set: function (mode: string) { const next = mode || 'capacity' if (this._stockerGaugeMode === next) return this._stockerGaugeMode = next this._refreshStockerGauges() } }) /** scene 하위 STOCKER(3D 게이지) + StockerCapacityBar(2D 게이지) 를 새 모드로 갱신. */ RootContainer.prototype._refreshStockerGauges = function () { const walk = (container: any) => { const kids = container?.components if (!Array.isArray(kids)) return for (const c of kids) { const type = c?.state?.type if (type === 'STOCKER') { // 3D 게이지는 스토커 자신의 real-object 가 그린다 → 재빌드 (2D 모드면 _realObject 없음) c._realObject?.update?.() } else if (type === 'StockerCapacityBar' || type === 'MCSGaugeCapacityBar') { // 2D 게이지는 자식 컴포넌트가 그린다 → 리드로우 c.invalidate?.() } walk(c) } } walk(this) this.invalidate?.() }