From f42f699f601878a191f3dc3d6b5b2642d350bc51 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Mon, 27 Jul 2026 16:45:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BC=8F=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Map/Cesium_Model_Settings.tsx | 122 +++++++++++++++++++++++++++ src/Map/Map_Models.tsx | 134 ++++++++++++++++++++++++++++++ src/Map/WebGL_Support.ts | 43 ++++++++++ 3 files changed, 299 insertions(+) create mode 100644 src/Map/Cesium_Model_Settings.tsx create mode 100644 src/Map/Map_Models.tsx create mode 100644 src/Map/WebGL_Support.ts diff --git a/src/Map/Cesium_Model_Settings.tsx b/src/Map/Cesium_Model_Settings.tsx new file mode 100644 index 0000000..3d890c7 --- /dev/null +++ b/src/Map/Cesium_Model_Settings.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import {Button, Input, InputNumber, message, Space, Upload} from "antd"; +import {UploadOutlined} from "@ant-design/icons"; +import enhance from "../core/enhance.tsx"; +import {col_style, row_style, setting_style} from "../Global.tsx"; +import { + aircraft_model_type_options, + empty_map_model_config, + load_map_model_config, + save_map_model_config, + upload_map_model, + type Map_Model_Item_Config, + type Map_Model_Config +} from "./Map_Models.tsx"; + +type Model_Number_Key = keyof Pick +export class Cesium_Model_Settings extends enhance.Base { + config: Map_Model_Config = {...empty_map_model_config}; + number_input_drafts: Record = {}; + async on_mount() { + this.config = await load_map_model_config(); + this.number_input_drafts = {}; + this.flush(); + } + async upload(model_type: "aircraft" | "base_station" | "aircraft_type", file: File, aircraft_model_key?: string) { + if (!file.name.toLowerCase().endsWith(".glb")) { + message.error("只支持上传 .glb 模型"); + return false; + } + this.config = await upload_map_model(model_type, file, aircraft_model_key); + message.success(this.model_upload_message(model_type)); + this.flush(); + return false; + } + model_upload_message(model_type: "aircraft" | "base_station" | "aircraft_type"): string { + if (model_type === "aircraft") return "默认飞机模型已更新"; + if (model_type === "aircraft_type") return "分类飞机模型已更新"; + return "基站模型已更新"; + } + number_input_key(model: Map_Model_Item_Config, key: Model_Number_Key): string { + return `${model.url}:${key}`; + } + number_input_value(model: Map_Model_Item_Config, key: Model_Number_Key): number | null { + const input_key = this.number_input_key(model, key); + return Object.prototype.hasOwnProperty.call(this.number_input_drafts, input_key) ? this.number_input_drafts[input_key] : model[key]; + } + set_model_number(model: Map_Model_Item_Config, key: Model_Number_Key, value: number | null) { + const input_key = this.number_input_key(model, key); + this.number_input_drafts[input_key] = value; + if (value !== null) { + model[key] = value; + } + this.flush(); + } + blur_model_number(model: Map_Model_Item_Config, key: Model_Number_Key) { + const input_key = this.number_input_key(model, key); + if (Object.prototype.hasOwnProperty.call(this.number_input_drafts, input_key)) { + delete this.number_input_drafts[input_key]; + } + this.flush(); + } + async save() { + this.config = await save_map_model_config(this.config); + this.number_input_drafts = {}; + message.success("模型配置已保存"); + this.flush(); + } + render_model_detail(model: Map_Model_Item_Config) { + return ( + + this.set_model_number(model, "builtInSize", value)} onBlur={() => this.blur_model_number(model, "builtInSize")} style={{width: 180}} /> + this.set_model_number(model, "headingOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "headingOffsetDegrees")} style={{width: 180}} /> + this.set_model_number(model, "pitchOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "pitchOffsetDegrees")} style={{width: 180}} /> + this.set_model_number(model, "rollOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "rollOffsetDegrees")} style={{width: 180}} /> + + ); + } + render_aircraft_type_model(option: {key: string, label: string}) { + const model = this.config.aircraftModels[option.key] || this.config.aircraftModel; + return ( + + + {option.label} + + this.upload("aircraft_type", file, option.key)}> + + + + {this.render_model_detail(model)} + + ); + } + render_model_upload(label: string, model: Map_Model_Item_Config, model_type: "aircraft" | "base_station") { + return ( + + + + this.upload(model_type, file)}> + + + + {this.render_model_detail(model)} + + ); + } + render(props: any) { + return ( +
+

Cesium模型设置

+ +

默认飞机模型

+ {this.render_model_upload("飞机模型", this.config.aircraftModel, "aircraft")} +

基站模型

+ {this.render_model_upload("基站模型", this.config.baseStationModel, "base_station")} +

按涡流/目标类型选择飞机模型

+ {aircraft_model_type_options.map(option => this.render_aircraft_type_model(option))} + +
+
+ ); + } +} diff --git a/src/Map/Map_Models.tsx b/src/Map/Map_Models.tsx new file mode 100644 index 0000000..eb38ca6 --- /dev/null +++ b/src/Map/Map_Models.tsx @@ -0,0 +1,134 @@ +import axios from "axios"; + +export type Aircraft_Model_Type_Option = { + key: string + label: string + modelFile: string +} +export const aircraft_model_type_options: Aircraft_Model_Type_Option[] = [ + {key: "no_category_information", label: "无类别信息", modelFile: "aircraft-no-category-information.glb"}, + {key: "surface_emergency_vehicle", label: "地面应急车", modelFile: "aircraft-surface-emergency-vehicle.glb"}, + {key: "surface_service_vehicle", label: "地面服务车", modelFile: "aircraft-surface-service-vehicle.glb"}, + {key: "ground_obstruction_4", label: "地面障碍物4", modelFile: "aircraft-ground-obstruction-4.glb"}, + {key: "ground_obstruction_5", label: "地面障碍物5", modelFile: "aircraft-ground-obstruction-5.glb"}, + {key: "ground_obstruction_6", label: "地面障碍物6", modelFile: "aircraft-ground-obstruction-6.glb"}, + {key: "ground_obstruction_7", label: "地面障碍物7", modelFile: "aircraft-ground-obstruction-7.glb"}, + {key: "glider", label: "滑翔机,滑翔飞机", modelFile: "aircraft-glider.glb"}, + {key: "lighter_than_air", label: "比空气轻", modelFile: "aircraft-lighter-than-air.glb"}, + {key: "parachutist", label: "跳伞运动员", modelFile: "aircraft-parachutist.glb"}, + {key: "ultralight_hangglider_paraglider", label: "超轻型,悬挂式滑翔机,滑翔伞", modelFile: "aircraft-ultralight-hangglider-paraglider.glb"}, + {key: "reserved_3_5", label: "保留", modelFile: "aircraft-reserved-3-5.glb"}, + {key: "unmanned_aerial_vehicle", label: "无人机", modelFile: "aircraft-unmanned-aerial-vehicle.glb"}, + {key: "space_transatmospheric_vehicle", label: "太空或跨大气层飞行器", modelFile: "aircraft-space-transatmospheric-vehicle.glb"}, + {key: "light_aircraft", label: "轻型飞机(小于 7000 公斤)", modelFile: "aircraft-light-aircraft.glb"}, + {key: "medium_1_aircraft", label: "中型 1(7000 公斤到 34000 公斤之间)", modelFile: "aircraft-medium-1-aircraft.glb"}, + {key: "medium_2_aircraft", label: "中型 2(34000 公斤到 136000 公斤之间)", modelFile: "aircraft-medium-2-aircraft.glb"}, + {key: "high_vortex_aircraft", label: "高涡旋飞机", modelFile: "aircraft-high-vortex-aircraft.glb"}, + {key: "heavy_aircraft", label: "重型飞机(大于 136000 公斤)", modelFile: "aircraft-heavy-aircraft.glb"}, + {key: "high_performance_aircraft", label: "高性能(>5 g 加速度)和高速(>400 节)", modelFile: "aircraft-high-performance-aircraft.glb"}, + {key: "rotorcraft", label: "旋翼机", modelFile: "aircraft-rotorcraft.glb"} +]; +export type Map_Model_Item_Config = { + url: string + builtInSize: number + headingOffsetDegrees: number + pitchOffsetDegrees: number + rollOffsetDegrees: number +} +export type Map_Model_Config = { + aircraftModel: Map_Model_Item_Config + aircraftModels: Record + baseStationModel: Map_Model_Item_Config +} +type Map_Model_Item_Config_Json = { + url: string + built_in_size: number + heading_offset_degrees: number + pitch_offset_degrees: number + roll_offset_degrees: number +} +type Map_Model_Config_Json = { + aircraft_model: Map_Model_Item_Config_Json + aircraft_models?: Record + base_station_model: Map_Model_Item_Config_Json +} +function default_model_item(url: string, builtInSize: number, heading = 0, pitch = 0, roll = 0): Map_Model_Item_Config { + return {url, builtInSize, headingOffsetDegrees: heading, pitchOffsetDegrees: pitch, rollOffsetDegrees: roll}; +} +function default_aircraft_models(): Record { + return Object.fromEntries(aircraft_model_type_options.map(item => [item.key, default_model_item(`/ui/model/${item.modelFile}`, 50, -90)])); +} +export const empty_map_model_config: Map_Model_Config = { + aircraftModel: default_model_item("/ui/model/aircraft.glb", 50, -90), + aircraftModels: default_aircraft_models(), + baseStationModel: default_model_item("/ui/model/base-station.glb", 100) +}; +export const map_model_config_event = "ecap_map_model_config_changed"; +function number_or(value: unknown, fallback: number): number { + const next = Number(value); + return Number.isFinite(next) ? next : fallback; +} +function from_server_model_item(data: Partial | undefined, fallback: Map_Model_Item_Config): Map_Model_Item_Config { + return { + url: typeof data?.url === "string" ? data.url : fallback.url, + builtInSize: number_or(data?.built_in_size, fallback.builtInSize), + headingOffsetDegrees: number_or(data?.heading_offset_degrees, fallback.headingOffsetDegrees), + pitchOffsetDegrees: number_or(data?.pitch_offset_degrees, fallback.pitchOffsetDegrees), + rollOffsetDegrees: number_or(data?.roll_offset_degrees, fallback.rollOffsetDegrees) + }; +} +function to_server_model_item(config: Map_Model_Item_Config): Map_Model_Item_Config_Json { + return { + url: config.url, + built_in_size: config.builtInSize, + heading_offset_degrees: config.headingOffsetDegrees, + pitch_offset_degrees: config.pitchOffsetDegrees, + roll_offset_degrees: config.rollOffsetDegrees + }; +} +function aircraft_models_from_server(values?: Record): Record { + const models = default_aircraft_models(); + if (values) { + for (const [key, value] of Object.entries(values)) { + models[key] = from_server_model_item(value, models[key] || empty_map_model_config.aircraftModel); + } + } + return models; +} +function from_server_config(data: Partial): Map_Model_Config { + return { + aircraftModel: from_server_model_item(data.aircraft_model, empty_map_model_config.aircraftModel), + aircraftModels: aircraft_models_from_server(data.aircraft_models), + baseStationModel: from_server_model_item(data.base_station_model, empty_map_model_config.baseStationModel) + }; +} +function to_server_config(config: Map_Model_Config): Map_Model_Config_Json { + return { + aircraft_model: to_server_model_item(config.aircraftModel), + aircraft_models: Object.fromEntries(Object.entries(config.aircraftModels).map(([key, value]) => [key, to_server_model_item(value)])), + base_station_model: to_server_model_item(config.baseStationModel) + }; +} +export async function load_map_model_config(): Promise { + const response = await axios.get("/map/models"); + return from_server_config(response.data); +} +export async function save_map_model_config(config: Map_Model_Config): Promise { + const response = await axios.post("/map/models", to_server_config(config)); + const saved_config = from_server_config(response.data); + emit_map_model_config(saved_config); + return saved_config; +} +export async function upload_map_model(model_type: "aircraft" | "base_station" | "aircraft_type", file: File, aircraft_model_key?: string): Promise { + const form = new FormData(); + form.append("model_type", model_type); + if (aircraft_model_key) form.append("aircraft_model_key", aircraft_model_key); + form.append("file", file); + const response = await axios.post("/map/models", form); + const config = from_server_config(response.data); + emit_map_model_config(config); + return config; +} +export function emit_map_model_config(config: Map_Model_Config) { + window.dispatchEvent(new CustomEvent(map_model_config_event, {detail: config})); +} diff --git a/src/Map/WebGL_Support.ts b/src/Map/WebGL_Support.ts new file mode 100644 index 0000000..bc53468 --- /dev/null +++ b/src/Map/WebGL_Support.ts @@ -0,0 +1,43 @@ +type WebGL_Context_Name = "webgl2" | "webgl" | "experimental-webgl"; +type WebGL_Context_Options = { + alpha: boolean + antialias: boolean + depth: boolean + stencil: boolean + failIfMajorPerformanceCaveat: boolean + powerPreference: "default" | "high-performance" | "low-power" +}; +const webgl_context_names: WebGL_Context_Name[] = ["webgl2", "webgl", "experimental-webgl"]; +const cesium_webgl_unavailable_key = "ecap_cesium_webgl_unavailable"; +export const webgl_unavailable_message = "当前电脑无法初始化 WebGL,已切换到 2D 地图"; +function create_webgl_context(canvas: HTMLCanvasElement, name: WebGL_Context_Name, options?: WebGL_Context_Options): WebGLRenderingContext | WebGL2RenderingContext | null { + try { + return canvas.getContext(name, options as object) as WebGLRenderingContext | WebGL2RenderingContext | null; + } + catch { + return null; + } +} +export function can_create_webgl_context(): boolean { + if (typeof document === "undefined") return false; + const canvas = document.createElement("canvas"); + const options: WebGL_Context_Options = {alpha: false, antialias: false, depth: true, stencil: false, failIfMajorPerformanceCaveat: true, powerPreference: "high-performance"}; + for (const name of webgl_context_names) { + const context = create_webgl_context(canvas, name, options) || create_webgl_context(canvas, name); + if (context) { + context.getExtension("WEBGL_lose_context")?.loseContext(); + return true; + } + } + return false; +} +export function is_cesium_webgl_available(): boolean { + if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(cesium_webgl_unavailable_key) === "1") return false; + return can_create_webgl_context(); +} +export function mark_cesium_webgl_unavailable() { + if (typeof sessionStorage !== "undefined") { + sessionStorage.setItem(cesium_webgl_unavailable_key, "1"); + } + window.dispatchEvent(new CustomEvent("ecap_cesium_webgl_unavailable")); +}