feat: add border corridor analysis, gap-filler detection, PTZ camera geolocation, and 3D terrain/satellite visualization controls

This commit is contained in:
Hamza-Ayed
2026-09-26 00:06:52 +03:00
parent cb91e5f0de
commit 5028d20881
5 changed files with 2713 additions and 252 deletions
@@ -50,6 +50,11 @@ export class ArtilleryMissionRequestDto {
@IsOptional()
@IsString()
caliber?: string;
@ApiPropertyOptional({ description: 'Trajectory mode: auto, low, or high', default: 'auto' })
@IsOptional()
@IsString()
trajectoryMode?: 'auto' | 'low' | 'high';
}
export class TacticalSymbolDto {
+146 -65
View File
@@ -300,7 +300,7 @@ export class TacticalService {
observerHeight: number = 2,
radiusMeters: number = 5000,
rayCount: number = 72,
samplesPerRay: number = 25,
samplesPerRay: number = 60,
) {
const R_earth = 6371000;
const k_refraction = 0.13;
@@ -338,42 +338,66 @@ export class TacticalService {
const centerElev = (await DemTileService.getElevation(centerLat, centerLng, 13)) ?? this.estimateElevation(centerLat, centerLng);
const observerTotal = centerElev + observerHeight;
const polygonCoordinates: [number, number][] = [];
let totalVisibleSum = 0;
const multiPolygonCoords: [number, number][][][] = [];
const invisibleMultiPolygonCoords: [number, number][][][] = [];
let visibleAreaSum = 0;
let totalAreaSum = 0;
const raysVis: boolean[][] = [];
const raysPoints: {lat: number, lng: number}[][] = [];
for (let r = 0; r < rayCount; r++) {
const raySteps = rays[r];
const vis: boolean[] = [true];
const pts = [{lat: centerLat, lng: centerLng}];
let maxTheta = -Infinity;
let visibleDist = radiusMeters;
let visibleLat = raySteps[raySteps.length - 1].lat;
let visibleLng = raySteps[raySteps.length - 1].lng;
for (const step of raySteps) {
pts.push({lat: step.lat, lng: step.lng});
const sElev = (await DemTileService.getElevation(step.lat, step.lng, 13)) ?? this.estimateElevation(step.lat, step.lng);
const sagitta = (step.dist * step.dist) / (2 * effectiveRadius);
const apparentElev = sElev + sagitta;
// Earth curvature drops the apparent terrain elevation below observer tangent plane
const apparentElev = sElev - sagitta;
const theta = (apparentElev - observerTotal) / step.dist;
if (theta >= maxTheta) {
maxTheta = theta;
visibleDist = step.dist;
visibleLat = step.lat;
visibleLng = step.lng;
vis.push(true);
} else {
vis.push(false);
}
}
totalVisibleSum += visibleDist;
polygonCoordinates.push([Number(visibleLng.toFixed(6)), Number(visibleLat.toFixed(6))]);
raysVis.push(vis);
raysPoints.push(pts);
}
if (polygonCoordinates.length > 0) {
polygonCoordinates.push(polygonCoordinates[0]);
for (let r = 0; r < rayCount; r++) {
const nextR = (r + 1) % rayCount;
const vis1 = raysVis[r];
const vis2 = raysVis[nextR];
const pts1 = raysPoints[r];
const pts2 = raysPoints[nextR];
for (let s = 1; s <= samplesPerRay; s++) {
totalAreaSum += s;
const p1 = [pts1[s-1].lng, pts1[s-1].lat] as [number, number];
const p2 = [pts1[s].lng, pts1[s].lat] as [number, number];
const p3 = [pts2[s].lng, pts2[s].lat] as [number, number];
const p4 = [pts2[s-1].lng, pts2[s-1].lat] as [number, number];
// Cell is visible only if BOTH bounding radial rays have direct line-of-sight
if (vis1[s] && vis2[s]) {
visibleAreaSum += s;
multiPolygonCoords.push([[p1, p2, p3, p4, p1]]);
} else {
invisibleMultiPolygonCoords.push([[p1, p2, p3, p4, p1]]);
}
}
}
const avgRadius = totalVisibleSum / rayCount;
const coveredAreaKm2 = Math.PI * Math.pow(avgRadius / 1000, 2);
const coveragePercent = Math.min(100, Math.round((visibleAreaSum / totalAreaSum) * 100));
const maxAreaKm2 = Math.PI * Math.pow(radiusMeters / 1000, 2);
const coveragePercent = Math.min(100, Math.round((coveredAreaKm2 / maxAreaKm2) * 100));
const coveredAreaKm2 = maxAreaKm2 * (coveragePercent / 100);
return {
center: {
@@ -390,10 +414,18 @@ export class TacticalService {
type: 'Feature',
properties: { centerLat, centerLng, radiusMeters, coveragePercent, coveredAreaKm2: Math.round(coveredAreaKm2 * 10) / 10 },
geometry: {
type: 'Polygon',
coordinates: [polygonCoordinates],
type: 'MultiPolygon',
coordinates: multiPolygonCoords,
},
},
invisiblePolygon: {
type: 'Feature',
properties: { centerLat, centerLng, radiusMeters },
geometry: {
type: 'MultiPolygon',
coordinates: invisibleMultiPolygonCoords,
},
}
};
}
@@ -428,15 +460,19 @@ export class TacticalService {
const distanceMeters = this.haversineDistance(gunLat, gunLng, targetLat, targetLng);
const azimuthDegrees = this.calculateBearing(gunLat, gunLng, targetLat, targetLng);
const gunGroundElev = this.estimateElevation(gunLat, gunLng);
const targetGroundElev = this.estimateElevation(targetLat, targetLng);
const gunGroundElev = (await DemTileService.getElevation(gunLat, gunLng, 13)) ?? this.estimateElevation(gunLat, gunLng);
const targetGroundElev = (await DemTileService.getElevation(targetLat, targetLng, 13)) ?? this.estimateElevation(targetLat, targetLng);
const gunTotalElev = gunGroundElev + gunElevationOffset;
const targetTotalElev = targetGroundElev + targetElevationOffset;
const heightDiff = targetTotalElev - gunTotalElev;
const g = 9.80665;
const v0 = muzzleVelocity;
const isMortar = caliber.toLowerCase().includes('mortar') || caliber.includes('هاون');
let v0 = muzzleVelocity;
if (isMortar && (v0 > 450 || !v0 || v0 === 827)) {
v0 = 240; // Standard 120mm mortar velocity (Charge 3 ~240 m/s)
}
const term = Math.pow(v0, 4) - g * (g * Math.pow(distanceMeters, 2) + 2 * heightDiff * Math.pow(v0, 2));
@@ -451,7 +487,7 @@ export class TacticalService {
highAngleRad = Math.atan((Math.pow(v0, 2) + sqrtTerm) / (g * distanceMeters));
} else {
lowAngleRad = (45 * Math.PI) / 180;
highAngleRad = (60 * Math.PI) / 180;
highAngleRad = (65 * Math.PI) / 180;
}
const lowAngleDeg = (lowAngleRad * 180) / Math.PI;
@@ -460,74 +496,119 @@ export class TacticalService {
const highAngleDeg = (highAngleRad * 180) / Math.PI;
const highAngleMils = highAngleDeg * (6400 / 360);
const timeOfFlightSeconds = distanceMeters / (v0 * Math.cos(lowAngleRad));
const apexHeightMeters = gunTotalElev + Math.pow(v0 * Math.sin(lowAngleRad), 2) / (2 * g);
const samples = 60;
const trajectoryPoints: any[] = [];
const samples = 50;
let hasCrestClearance = true;
let criticalObstacle: any = null;
// Helper to evaluate trajectory points, apex, and obstacle crest clearance
const evaluateTrajectory = async (angleRad: number) => {
const tof = distanceMeters / (v0 * Math.cos(angleRad));
const apex = gunTotalElev + Math.pow(v0 * Math.sin(angleRad), 2) / (2 * g);
const points: any[] = [];
let isClear = true;
let minClearance = Infinity;
let obstacle: any = null;
for (let i = 0; i <= samples; i++) {
const frac = i / samples;
const d = distanceMeters * frac;
const lat = gunLat + (targetLat - gunLat) * frac;
const lng = gunLng + (targetLng - gunLng) * frac;
for (let i = 0; i <= samples; i++) {
const frac = i / samples;
const d = distanceMeters * frac;
const lat = gunLat + (targetLat - gunLat) * frac;
const lng = gunLng + (targetLng - gunLng) * frac;
const t = frac * timeOfFlightSeconds;
const y = v0 * Math.sin(lowAngleRad) * t - 0.5 * g * Math.pow(t, 2);
const projectileAlt = gunTotalElev + y;
const t = frac * tof;
const y = v0 * Math.sin(angleRad) * t - 0.5 * g * Math.pow(t, 2);
const projectileAlt = gunTotalElev + y;
const terrainElev = this.estimateElevation(lat, lng);
const clearance = projectileAlt - terrainElev;
const terrainElev = (await DemTileService.getElevation(lat, lng, 13)) ?? this.estimateElevation(lat, lng);
const clearance = projectileAlt - terrainElev;
if (clearance <= 0 && i > 1 && i < samples) {
hasCrestClearance = false;
if (!criticalObstacle || clearance < criticalObstacle.clearance) {
criticalObstacle = {
distanceMeters: Math.round(d),
terrainElevMeters: Math.round(terrainElev),
projectileAltMeters: Math.round(projectileAlt),
deficitMeters: Math.round(Math.abs(clearance)),
lat,
lng,
};
if (clearance < minClearance && i > 1 && i < samples) {
minClearance = clearance;
}
if (clearance <= 0 && i > 1 && i < samples) {
isClear = false;
if (!obstacle || clearance < obstacle.clearance) {
obstacle = {
distanceMeters: Math.round(d),
terrainElevMeters: Math.round(terrainElev),
projectileAltMeters: Math.round(projectileAlt),
deficitMeters: Math.round(Math.abs(clearance)),
lat,
lng,
};
}
}
points.push({
distanceMeters: Math.round(d),
lat,
lng,
terrainElevation: Math.round(terrainElev),
projectileAltitude: Math.round(projectileAlt),
clearanceMeters: Math.round(clearance),
});
}
trajectoryPoints.push({
distanceMeters: Math.round(d),
lat,
lng,
terrainElevation: Math.round(terrainElev),
projectileAltitude: Math.round(projectileAlt),
clearanceMeters: Math.round(clearance),
});
return { tof, apex, points, isClear, minClearance, obstacle };
};
const lowEval = await evaluateTrajectory(lowAngleRad);
const highEval = await evaluateTrajectory(highAngleRad);
// Tactical trajectory selection: Mortars are always high-angle; howitzers auto-switch if low is blocked
let activeTrajectory: 'low' | 'high' = 'low';
let chosenEval = lowEval;
if (isMortar) {
activeTrajectory = 'high';
chosenEval = highEval;
} else if (dto.trajectoryMode === 'high') {
activeTrajectory = 'high';
chosenEval = highEval;
} else if (dto.trajectoryMode === 'low') {
activeTrajectory = 'low';
chosenEval = lowEval;
} else {
if (!lowEval.isClear && highEval.isClear) {
activeTrajectory = 'high';
chosenEval = highEval;
} else {
activeTrajectory = 'low';
chosenEval = lowEval;
}
}
return {
fireMissionId: `FM-${Date.now().toString().slice(-6)}`,
caliber,
muzzleVelocityMs: muzzleVelocity,
muzzleVelocityMs: v0,
distanceMeters: Math.round(distanceMeters),
distanceKm: Math.round((distanceMeters / 1000) * 100) / 100,
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
azimuthMils: Math.round((azimuthDegrees * (6400 / 360)) * 10) / 10,
gunElevationMeters: Math.round(gunTotalElev),
targetElevationMeters: Math.round(targetTotalElev),
apexAltitudeMeters: Math.round(apexHeightMeters),
timeOfFlightSeconds: Math.round(timeOfFlightSeconds * 10) / 10,
apexAltitudeMeters: Math.round(chosenEval.apex),
timeOfFlightSeconds: Math.round(chosenEval.tof * 10) / 10,
activeTrajectory,
isMortar,
hasCrestClearance: chosenEval.isClear,
clearanceMarginMeters: Math.round(chosenEval.minClearance),
criticalObstacle: chosenEval.obstacle,
trajectoryPoints: chosenEval.points,
lowAngle: {
degrees: Math.round(lowAngleDeg * 100) / 100,
mils: Math.round(lowAngleMils * 10) / 10,
hasClearance: lowEval.isClear,
apexAltitudeMeters: Math.round(lowEval.apex),
timeOfFlightSeconds: Math.round(lowEval.tof * 10) / 10,
},
highAngle: {
degrees: Math.round(highAngleDeg * 100) / 100,
mils: Math.round(highAngleMils * 10) / 10,
hasClearance: highEval.isClear,
apexAltitudeMeters: Math.round(highEval.apex),
timeOfFlightSeconds: Math.round(highEval.tof * 10) / 10,
},
hasCrestClearance,
criticalObstacle,
trajectoryPoints,
};
}