diff --git a/VERSION b/VERSION index a8ab6c96..73f8a5ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.44.0 +0.44.1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 11cb3d53..2217e48e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,16 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.44.1] — 2026-04-29 + +### 🐛 Fixes +- 修复 Earth 船只图层拖动不跟手的问题,将普通船只从独立 Sprite 切换为按航向分桶的批量 Points 渲染。 +- 修正船只 hover/click 拾取错位,改为屏幕空间命中检测并在拖拽/惯性期间跳过 hover 拾取。 +- 统一 AIS 船只普通态与交互态方向,并让 hover/locked glow 与普通图标保持同尺寸覆盖。 +- 恢复船只深度测试并收敛默认图标尺寸,避免北部岛屿/冰面附近出现明显压盖陆地的视觉问题。 + +--- + ## [0.44.0] — 2026-04-29 ### ✨ Highlights diff --git a/docs/version-history.md b/docs/version-history.md index 27dd5318..af89d5ba 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.44.0` +- `dev` 当前开发分支历史推导到:`0.44.1` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 | | `0.44.0` | feature | `dev` | `pending` | 重构数据源目录与采集器设置,新增 BarentsWatch AIS 连接教程、Earth 船只/缩放体验优化和仪表盘前端重启 | | `0.43.1` | bugfix | `dev` | `pending` | 修正全量 restart 后 AI Provider 启动提示语义,避免把预期未就绪描述成异常 | | `0.43.0` | feature | `dev` | `pending` | 新增 Earth 船舶追踪、自定义数据源映射、外部集成配置中心、Markdown 渲染器增强,并整理规则/技能文档加载约束 | diff --git a/frontend/package.json b/frontend/package.json index 51f43aa9..1bff4b32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.44.0", + "version": "0.44.1", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index f73cba29..c48c2009 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -258,6 +258,7 @@ let lastBGPClickTime = 0; let lastBGPClickCollector = null; let lastBGPClickType = null; let lastBGPClickPos = { x: 0, y: 0 }; +let lastVesselHoverPickTime = 0; let earthTexture = null; let animationFrameId = null; let initialized = false; @@ -295,7 +296,9 @@ const scratchBGPWorldPosition = new THREE.Vector3(); const scratchComputeCenterDirection = new THREE.Vector3(); const scratchComputeCenterWorldPosition = new THREE.Vector3(); const scratchVesselDirection = new THREE.Vector3(); +const scratchVesselCameraLocal = new THREE.Vector3(); const scratchVesselWorldPosition = new THREE.Vector3(); +const scratchVesselScreenPosition = new THREE.Vector3(); const scratchSatelliteWorldPosition = new THREE.Vector3(); const scratchSatelliteScreenPosition = new THREE.Vector3(); const scratchViewCenterWorld = new THREE.Vector3(); @@ -310,6 +313,8 @@ const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc"; const DRAG_POINTER_THRESHOLD_PX = 8; +const VESSEL_HOVER_PICK_INTERVAL_MS = 100; +const VESSEL_POINTER_RADIUS_PX = 22; const GLOBE_DRAGGING_CLASS = "is-globe-dragging"; const HUD_INTERACTIVE_SELECTORS = [ ".earth-left-column", @@ -364,6 +369,13 @@ function setGlobeDraggingUiState(active) { document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active); } +function hasActiveGlobeInertia() { + return ( + Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || + Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY + ); +} + function getDragRotationFactor() { const zoom = Math.max(getZoomLevel(), 0.01); const scale = THREE.MathUtils.clamp( @@ -540,21 +552,92 @@ function getFrontFacingVesselMarkers(markers) { const earth = getEarth(); if (!earth) return markers; - scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); + scratchVesselCameraLocal.copy(camera.position); + earth.worldToLocal(scratchVesselCameraLocal); + scratchVesselCameraLocal.normalize(); return markers.filter((marker) => { - scratchVesselWorldPosition.copy(marker.position); - marker.parent?.localToWorld(scratchVesselWorldPosition); - scratchVesselDirection - .subVectors(scratchVesselWorldPosition, earth.position) - .normalize(); + scratchVesselDirection.copy(marker.position).normalize(); return ( - scratchCameraToEarth.dot(scratchVesselDirection) > + scratchVesselCameraLocal.dot(scratchVesselDirection) > SATELLITE_CONFIG.frontFacingDotThreshold ); }); } +function getVesselPointerIntersections() { + const earth = getEarth(); + if (!earth) return []; + scratchVesselCameraLocal.copy(camera.position); + earth.worldToLocal(scratchVesselCameraLocal); + scratchVesselCameraLocal.normalize(); + + const pointerX = ((interactionMouse.x + 1) / 2) * window.innerWidth; + const pointerY = ((1 - interactionMouse.y) / 2) * window.innerHeight; + const radiusSq = VESSEL_POINTER_RADIUS_PX * VESSEL_POINTER_RADIUS_PX; + const intersections = []; + + getVesselMarkers().forEach((marker) => { + scratchVesselDirection.copy(marker.position).normalize(); + if ( + scratchVesselCameraLocal.dot(scratchVesselDirection) <= + SATELLITE_CONFIG.frontFacingDotThreshold + ) { + return; + } + + scratchVesselWorldPosition.copy(marker.position); + earth.localToWorld(scratchVesselWorldPosition); + scratchVesselScreenPosition.copy(scratchVesselWorldPosition).project(camera); + if ( + scratchVesselScreenPosition.z < -1 || + scratchVesselScreenPosition.z > 1 + ) { + return; + } + + const screenX = (scratchVesselScreenPosition.x * 0.5 + 0.5) * window.innerWidth; + const screenY = (-scratchVesselScreenPosition.y * 0.5 + 0.5) * window.innerHeight; + const deltaX = screenX - pointerX; + const deltaY = screenY - pointerY; + const distancePxSq = deltaX * deltaX + deltaY * deltaY; + if (distancePxSq > radiusSq) return; + + intersections.push({ + object: marker, + point: scratchVesselWorldPosition.clone(), + distance: camera.position.distanceTo(scratchVesselWorldPosition), + distancePxSq, + }); + }); + + return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq); +} + +function shouldSkipVesselHoverPicking() { + return isDragging || hasActiveGlobeInertia(); +} + +function getVesselHoverIntersections() { + if (!getShowVessels()) { + return { checked: true, intersects: [] }; + } + if (shouldSkipVesselHoverPicking()) { + return { checked: false, intersects: [] }; + } + + const now = performance.now(); + if (now - lastVesselHoverPickTime < VESSEL_HOVER_PICK_INTERVAL_MS) { + return { checked: false, intersects: [] }; + } + lastVesselHoverPickTime = now; + + return { + checked: true, + intersects: getVesselPointerIntersections(), + }; +} + function applyBGPHoverState(marker) { resetTransientBGPStates(); if (!marker) { @@ -590,14 +673,14 @@ function applyComputeCenterHoverState(marker) { } function resetTransientVesselStates() { - getVesselMarkers().forEach((marker) => { - if (marker !== lockedObject) { - setVesselMarkerState(marker, "normal"); - } - }); + if (hoveredVessel && hoveredVessel !== lockedObject) { + setVesselMarkerState(hoveredVessel, "normal"); + } + hoveredVessel = null; } function applyVesselHoverState(marker) { + if (isSameVessel(hoveredVessel, marker)) return; resetTransientVesselStates(); if (!marker) { hoveredVessel = null; @@ -3134,11 +3217,8 @@ function onMouseMove(event) { const computeCenterIntersects = getShowComputeCenters() ? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers) : []; - const vesselIntersects = getShowVessels() - ? interactionRaycaster.intersectObjects( - getFrontFacingVesselMarkers(getVesselMarkers()), - ) - : []; + const vesselPick = getVesselHoverIntersections(); + const vesselIntersects = vesselPick.intersects; let hoveredSat = null; let hoveredSatIndexFromIntersect = null; @@ -3161,7 +3241,7 @@ function onMouseMove(event) { const hoveredComputeCenterMarker = computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null; const hoveredVesselMarker = - vesselIntersects.length > 0 ? vesselIntersects[0].object : null; + vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null; if ( hoveredComputeCenter && @@ -3169,7 +3249,11 @@ function onMouseMove(event) { ) { clearTransientHoverState(); } - if (hoveredVessel && !isSameVessel(hoveredVessel, hoveredVesselMarker)) { + if ( + vesselPick.checked && + hoveredVessel && + !isSameVessel(hoveredVessel, hoveredVesselMarker) + ) { clearTransientHoverState(); } @@ -3216,6 +3300,7 @@ function onMouseMove(event) { ); objectTooltipShown = true; } else if ( + vesselPick.checked && hoveredVesselMarker && getShowVessels() && lockedObjectType !== "vessel" @@ -3262,7 +3347,9 @@ function onMouseMove(event) { } else if (!lockedObjectType && !isCruisePresentationPinned()) { resetTransientBGPStates(); resetTransientComputeCenterStates(); - resetTransientVesselStates(); + if (vesselPick.checked) { + resetTransientVesselStates(); + } hideInfoCard(); } @@ -3483,9 +3570,7 @@ function onClick(event) { ) : []; const vesselIntersects = getShowVessels() - ? interactionRaycaster.intersectObjects( - getFrontFacingVesselMarkers(getVesselMarkers()), - ) + ? getVesselPointerIntersections() : []; const satIntersects = getSatellitePointerIntersections(event); @@ -3681,9 +3766,7 @@ function animate() { const earth = getEarth(); const deltaTime = clock.getDelta() * 1000; - const hasInertia = - Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || - Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY; + const hasInertia = hasActiveGlobeInertia(); if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth) { earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16); diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index e72bb306..ee5717bc 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -1,15 +1,29 @@ import * as THREE from "three"; import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js"; -import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; +import { latLonToVector3 } from "./utils.js"; const vesselGroup = new THREE.Group(); const vesselMarkers = []; const textureCache = new Map(); +let vesselPoints = null; +let vesselPointObjects = []; +let hoverOverlaySprite = null; +let lockedOverlaySprite = null; let showVessels = false; let activeTrackLine = null; +let lastVisualStateKey = ""; +let visualStateVersion = 0; const VESSEL_RENDER_ORDER = 4.4; +const VESSEL_POINT_SIZE = 34; +const VESSEL_ATLAS_CELL_SIZE = 128; +const VESSEL_COURSE_BINS = 32; + +function invalidateVesselVisualState() { + visualStateVersion += 1; + lastVisualStateKey = ""; +} function normalizeVesselType(value, code) { const type = String(value || "").trim().toLowerCase(); @@ -22,36 +36,87 @@ function normalizeVesselType(value, code) { return "other"; } -function createVesselTexture(type, anchored) { - const textureKey = `${type}:${anchored ? "anchored" : "moving"}`; - if (textureCache.has(textureKey)) return textureCache.get(textureKey); - - const color = VESSEL_CONFIG.colors[type] || VESSEL_CONFIG.colors.other; - const canvas = document.createElement("canvas"); - canvas.width = 96; - canvas.height = 96; - const context = canvas.getContext("2d"); - context.clearRect(0, 0, 96, 96); - context.save(); - context.translate(48, 48); +function drawVesselShape(context, anchored, glow, color = "#ffffff") { context.fillStyle = color; context.globalAlpha = anchored ? 0.55 : 0.96; - context.shadowColor = color; - context.shadowBlur = anchored ? 8 : 14; + if (glow) { + context.shadowColor = color; + context.shadowBlur = anchored ? 8 : 14; + } context.beginPath(); if (anchored) { - context.arc(0, 0, 18, 0, Math.PI * 2); + context.arc(0, 0, 24, 0, Math.PI * 2); } else { - context.moveTo(0, -28); - context.lineTo(21, 24); - context.lineTo(0, 13); - context.lineTo(-21, 24); + context.moveTo(0, -37); + context.lineTo(28, 32); + context.lineTo(0, 17); + context.lineTo(-28, 32); context.closePath(); } context.fill(); +} + +function createVesselPointTexture(anchored, courseBin = 0) { + const textureKey = `vessel-point:${anchored ? "anchored" : "moving"}:${courseBin}`; + if (textureCache.has(textureKey)) return textureCache.get(textureKey); + + const canvas = document.createElement("canvas"); + canvas.width = VESSEL_ATLAS_CELL_SIZE; + canvas.height = VESSEL_ATLAS_CELL_SIZE; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.save(); + context.translate(canvas.width / 2, canvas.height / 2); + if (!anchored) { + context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2); + } + drawVesselShape(context, anchored, false); context.restore(); const texture = new THREE.CanvasTexture(canvas); + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + textureCache.set(textureKey, texture); + return texture; +} + +function createVesselOverlayTexture(marker, glow = true) { + const kind = marker?.userData?.vessel_kind || "other"; + const anchored = Boolean(marker?.userData?.anchored); + const courseBin = marker ? getCourseBin(marker) : 0; + const textureKey = [ + "vessel-overlay", + kind, + anchored ? "anchored" : "moving", + courseBin, + glow ? "glow" : "plain", + ].join(":"); + if (textureCache.has(textureKey)) return textureCache.get(textureKey); + + const canvas = document.createElement("canvas"); + canvas.width = VESSEL_ATLAS_CELL_SIZE; + canvas.height = VESSEL_ATLAS_CELL_SIZE; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.save(); + context.translate(canvas.width / 2, canvas.height / 2); + if (!anchored) { + context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2); + } + drawVesselShape( + context, + anchored, + glow, + VESSEL_CONFIG.colors[kind] || VESSEL_CONFIG.colors.other, + ); + context.restore(); + + const texture = new THREE.CanvasTexture(canvas); + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; textureCache.set(textureKey, texture); return texture; @@ -80,16 +145,7 @@ function buildVesselMarkerData(feature) { } function createVesselMarker(markerData) { - const material = new THREE.SpriteMaterial({ - map: createVesselTexture(markerData.type, markerData.anchored), - transparent: true, - depthWrite: false, - opacity: VESSEL_CONFIG.marker.baseOpacity, - rotation: markerData.anchored - ? 0 - : THREE.MathUtils.degToRad(-markerData.course), - }); - const marker = new THREE.Sprite(material); + const marker = new THREE.Object3D(); marker.position.copy( latLonToVector3( markerData.latitude, @@ -97,9 +153,6 @@ function createVesselMarker(markerData) { CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset, ), ); - marker.scale.setScalar(VESSEL_CONFIG.marker.baseScale); - marker.renderOrder = VESSEL_RENDER_ORDER; - marker.visible = showVessels; marker.userData = { ...markerData, type: "vessel", @@ -107,10 +160,88 @@ function createVesselMarker(markerData) { baseScale: VESSEL_CONFIG.marker.baseScale, state: "normal", }; - vesselGroup.add(marker); vesselMarkers.push(marker); } +function colorToRgbArray(colorValue) { + const color = new THREE.Color(colorValue || VESSEL_CONFIG.colors.other); + return [color.r, color.g, color.b]; +} + +function getCourseBin(marker) { + if (marker.userData.anchored) return 0; + const course = Number(marker.userData.course || 0); + const normalized = ((course % 360) + 360) % 360; + return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS; +} + +function buildVesselPoints() { + vesselPoints = new THREE.Group(); + vesselPoints.visible = showVessels; + vesselPoints.renderOrder = VESSEL_RENDER_ORDER; + vesselPoints.userData = { type: "vessel_points" }; + vesselPointObjects = []; + + const groups = new Map(); + vesselMarkers.forEach((marker, index) => { + const anchored = Boolean(marker.userData.anchored); + const courseBin = getCourseBin(marker); + const key = `${anchored ? "anchored" : "moving"}:${courseBin}`; + if (!groups.has(key)) { + groups.set(key, { + anchored, + courseBin, + markers: [], + markerIndexes: [], + }); + } + const group = groups.get(key); + group.markers.push(marker); + group.markerIndexes.push(index); + }); + + groups.forEach((group) => { + const count = group.markers.length; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + group.markers.forEach((marker, index) => { + positions[index * 3] = marker.position.x; + positions[index * 3 + 1] = marker.position.y; + positions[index * 3 + 2] = marker.position.z; + const [r, g, b] = colorToRgbArray(VESSEL_CONFIG.colors[marker.userData.vessel_kind]); + colors[index * 3] = r; + colors[index * 3 + 1] = g; + colors[index * 3 + 2] = b; + }); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.computeBoundingSphere(); + const material = new THREE.PointsMaterial({ + map: createVesselPointTexture(group.anchored, group.courseBin), + size: VESSEL_POINT_SIZE, + sizeAttenuation: false, + vertexColors: true, + transparent: true, + opacity: VESSEL_CONFIG.marker.baseOpacity, + depthWrite: false, + depthTest: true, + alphaTest: 0.01, + }); + const points = new THREE.Points(geometry, material); + points.renderOrder = VESSEL_RENDER_ORDER; + points.frustumCulled = false; + points.userData = { + type: "vessel_points", + }; + vesselPointObjects.push(points); + vesselPoints.add(points); + }); + + vesselGroup.add(vesselPoints); +} + function clearGroup(group) { for (let index = group.children.length - 1; index >= 0; index -= 1) { const child = group.children[index]; @@ -120,13 +251,24 @@ function clearGroup(group) { } } -function getDistanceScale(camera) { - return getSurfaceMarkerCameraScale(camera, { - altitudeOffset: VESSEL_CONFIG.altitudeOffset, - referenceFov: 75, - min: VESSEL_CONFIG.sizeStabilization.min, - max: VESSEL_CONFIG.sizeStabilization.max, +function disposeVesselRenderObjects() { + if (vesselPoints?.parent) { + vesselPoints.parent.remove(vesselPoints); + } + vesselPointObjects.forEach((points) => { + points.geometry?.dispose?.(); + points.material?.dispose?.(); }); + hoverOverlaySprite?.geometry?.dispose?.(); + hoverOverlaySprite?.material?.dispose?.(); + hoverOverlaySprite?.parent?.remove?.(hoverOverlaySprite); + lockedOverlaySprite?.geometry?.dispose?.(); + lockedOverlaySprite?.material?.dispose?.(); + lockedOverlaySprite?.parent?.remove?.(lockedOverlaySprite); + vesselPoints = null; + vesselPointObjects = []; + hoverOverlaySprite = null; + lockedOverlaySprite = null; } export function getVesselMarkers() { @@ -143,10 +285,11 @@ export function getShowVessels() { export function toggleVessels(show) { showVessels = Boolean(show); + invalidateVesselVisualState(); vesselGroup.visible = showVessels; - vesselMarkers.forEach((marker) => { - marker.visible = showVessels; - }); + if (vesselPoints) { + vesselPoints.visible = showVessels; + } if (activeTrackLine) { activeTrackLine.visible = showVessels; } @@ -168,12 +311,16 @@ function clearVesselTrack() { export function setVesselMarkerState(marker, state = "normal") { if (!marker || marker.userData?.type !== "vessel") return; + if (marker.userData.state === state) return; marker.userData.state = state; + invalidateVesselVisualState(); } export function clearVesselData(earth) { - vesselMarkers.length = 0; + invalidateVesselVisualState(); clearVesselSelection(); + vesselMarkers.length = 0; + disposeVesselRenderObjects(); clearGroup(vesselGroup); if (earth && vesselGroup.parent === earth) { earth.remove(vesselGroup); @@ -196,6 +343,7 @@ export async function loadVessels(_scene, earth, options = {}) { .filter(Boolean) .slice(0, VESSEL_CONFIG.maxRenderedMarkers) .forEach((markerData) => createVesselMarker(markerData)); + buildVesselPoints(); if (earth && !vesselGroup.parent) { earth.add(vesselGroup); @@ -256,26 +404,102 @@ export function getVesselLegendItems() { ]; } -export function updateVesselVisualState(lockedObjectType, lockedObject, camera) { - const hasFocus = lockedObjectType === "vessel" && lockedObject; - const distanceScale = getDistanceScale(camera); - vesselMarkers.forEach((marker) => { - const isLocked = lockedObjectType === "vessel" && lockedObject === marker; - const state = marker.userData?.state || "normal"; - let opacity = VESSEL_CONFIG.marker.baseOpacity; - let scaleMultiplier = 1; - if (isLocked) { - opacity = 1; - scaleMultiplier = VESSEL_CONFIG.marker.lockedScale; - } else if (state === "hover") { - opacity = 0.98; - scaleMultiplier = VESSEL_CONFIG.marker.hoverScale; - } else if (hasFocus) { - opacity = VESSEL_CONFIG.marker.dimmedOpacity; - scaleMultiplier = VESSEL_CONFIG.marker.dimmedScale; - } - marker.material.opacity = showVessels ? opacity : 0; - marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale); - marker.visible = showVessels; +function ensureOverlaySprite(kind) { + const existing = kind === "locked" ? lockedOverlaySprite : hoverOverlaySprite; + if (existing) return existing; + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.BufferAttribute(new Float32Array(3), 3), + ); + const material = new THREE.PointsMaterial({ + size: VESSEL_POINT_SIZE, + sizeAttenuation: false, + transparent: true, + depthWrite: false, + depthTest: true, + opacity: 1, + alphaTest: 0.01, }); + const overlay = new THREE.Points(geometry, material); + overlay.renderOrder = VESSEL_RENDER_ORDER + (kind === "locked" ? 0.2 : 0.1); + overlay.frustumCulled = false; + overlay.visible = false; + vesselGroup.add(overlay); + if (kind === "locked") { + lockedOverlaySprite = overlay; + } else { + hoverOverlaySprite = overlay; + } + return overlay; +} + +function updateOverlaySprite(overlay, marker, opacity) { + if (!overlay) return; + if (!marker) { + overlay.visible = false; + return; + } + + const texture = createVesselOverlayTexture(marker, true); + if (overlay.material.map !== texture) { + overlay.material.map = texture; + overlay.material.needsUpdate = true; + } + overlay.material.opacity = opacity; + overlay.material.size = VESSEL_POINT_SIZE; + const positionAttribute = overlay.geometry.getAttribute("position"); + positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z); + positionAttribute.needsUpdate = true; + overlay.visible = showVessels; +} + +export function updateVesselVisualState(lockedObjectType, lockedObject, camera) { + if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) { + if (lastVisualStateKey !== "hidden") { + if (vesselPoints) vesselPoints.visible = false; + if (hoverOverlaySprite) hoverOverlaySprite.visible = false; + if (lockedOverlaySprite) lockedOverlaySprite.visible = false; + lastVisualStateKey = "hidden"; + } + return; + } + + vesselPoints.visible = true; + const hasFocus = lockedObjectType === "vessel" && lockedObject; + const lockedKey = hasFocus + ? lockedObject?.userData?.mmsi || lockedObject?.uuid || "locked" + : "none"; + const stateKey = [ + "visible", + lockedObjectType || "none", + lockedKey, + visualStateVersion, + ].join(":"); + + if (stateKey === lastVisualStateKey) return; + lastVisualStateKey = stateKey; + + vesselPointObjects.forEach((points) => { + points.visible = showVessels; + points.material.opacity = hasFocus + ? VESSEL_CONFIG.marker.dimmedOpacity + : VESSEL_CONFIG.marker.baseOpacity; + points.material.size = VESSEL_POINT_SIZE; + }); + + const hoverMarker = vesselMarkers.find( + (marker) => marker.userData?.state === "hover" && marker !== lockedObject, + ); + updateOverlaySprite( + ensureOverlaySprite("hover"), + hoverMarker, + 0.98, + ); + updateOverlaySprite( + ensureOverlaySprite("locked"), + hasFocus ? lockedObject : null, + 1, + ); } diff --git a/pyproject.toml b/pyproject.toml index fa983c92..c8969648 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.44.0" +version = "0.44.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index d89fbd38..64d2e4b6 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.44.0" +version = "0.44.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" },