feat: add border corridor analysis, gap-filler detection, PTZ camera geolocation, and 3D terrain/satellite visualization controls
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user