Skip to content

Commit ff5a58c

Browse files
committed
Fix Fresnel unit handling
1 parent 63fd4c1 commit ff5a58c

7 files changed

Lines changed: 232 additions & 24 deletions

File tree

electron/raster/gdal-loader.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ type Stats = {
2222
std_dev?: number;
2323
};
2424

25+
type GdalUnitInfo = {
26+
units?: string;
27+
value?: number;
28+
};
29+
30+
type SpatialReferenceWithUnits = SpatialReference & {
31+
getAngularUnits?: () => GdalUnitInfo;
32+
getLinearUnits?: () => GdalUnitInfo;
33+
};
34+
2535
export async function loadDsmProject(
2636
paths: string[],
2737
): Promise<DsmProjectSummary> {
@@ -76,6 +86,9 @@ export async function loadDsmProject(
7686
projection,
7787
);
7888
const firstNoData = openedFiles[0]!.nodata;
89+
const distanceUnit = distanceUnitForSrs(firstSrs);
90+
const elevationUnit = firstFile.band.unitType || "unknown";
91+
const elevationMetersPerUnit = metersPerElevationUnit(elevationUnit);
7992

8093
const summary: DsmProjectSummary = {
8194
id: projectId,
@@ -91,10 +104,12 @@ export async function loadDsmProject(
91104
),
92105
crsWkt: firstSrs?.toWKT() ?? "",
93106
epsg,
107+
distance: distanceUnit,
94108
extent: projectExtent,
95109
pixelSize: firstFile.pixelSize,
96110
elevation: {
97-
unit: firstFile.band.unitType || "unknown",
111+
unit: elevationUnit,
112+
metersPerUnit: elevationMetersPerUnit,
98113
min: projectMin,
99114
max: projectMax,
100115
...(firstNoData === undefined ? {} : { nodata: firstNoData }),
@@ -208,6 +223,8 @@ function warningsForProject(
208223
sourceSrs: SpatialReference | null,
209224
): string[] {
210225
const warnings: string[] = [];
226+
const horizontalUnit = distanceUnitForSrs(sourceSrs);
227+
const elevationUnit = files[0]?.band.unitType || "unknown";
211228

212229
if (!sourceSrs) {
213230
warnings.push(
@@ -219,6 +236,18 @@ function warningsForProject(
219236
);
220237
}
221238

239+
if (horizontalUnit.metersPerUnit === null) {
240+
warnings.push(
241+
"Fresnel zones are hidden because the DSM horizontal unit cannot be converted to metres.",
242+
);
243+
}
244+
245+
if (metersPerElevationUnit(elevationUnit) === null) {
246+
warnings.push(
247+
"Fresnel zones are hidden because the DSM elevation unit cannot be converted to metres.",
248+
);
249+
}
250+
222251
for (let i = 0; i < files.length; i++) {
223252
for (let j = i + 1; j < files.length; j++) {
224253
if (extentsOverlap(files[i]!.extent, files[j]!.extent)) {
@@ -239,6 +268,57 @@ function warningsForProject(
239268
return warnings;
240269
}
241270

271+
function distanceUnitForSrs(sourceSrs: SpatialReference | null): {
272+
unit: string;
273+
metersPerUnit: number | null;
274+
} {
275+
if (!sourceSrs) return { unit: "unknown", metersPerUnit: null };
276+
277+
const srsWithUnits = sourceSrs as SpatialReferenceWithUnits;
278+
const unitInfo = sourceSrs.isGeographic()
279+
? srsWithUnits.getAngularUnits?.()
280+
: srsWithUnits.getLinearUnits?.();
281+
const unit = unitInfo?.units || "unknown";
282+
const metersPerUnit = sourceSrs.isGeographic()
283+
? null
284+
: finitePositiveOrNull(unitInfo?.value);
285+
286+
return { unit, metersPerUnit };
287+
}
288+
289+
function metersPerElevationUnit(unit: string): number | null {
290+
const normalized = unit.trim().toLowerCase();
291+
if (normalized === "" || normalized === "unknown") return null;
292+
293+
if (["m", "meter", "meters", "metre", "metres"].includes(normalized)) {
294+
return 1;
295+
}
296+
297+
if (
298+
[
299+
"ft",
300+
"foot",
301+
"feet",
302+
"international foot",
303+
"international feet",
304+
"us survey foot",
305+
"us survey feet",
306+
"survey foot",
307+
"survey feet",
308+
].includes(normalized)
309+
) {
310+
return 0.3048;
311+
}
312+
313+
return null;
314+
}
315+
316+
function finitePositiveOrNull(value: number | undefined): number | null {
317+
return typeof value === "number" && Number.isFinite(value) && value > 0
318+
? value
319+
: null;
320+
}
321+
242322
function rasterStats(band: RasterBand): Stats {
243323
try {
244324
const stats = band.computeStatistics(true) as Stats;

electron/raster/project-registry.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ function testProject(id: string, close: () => void): DsmProject {
3636
sourceSrs: null,
3737
summary: {
3838
crsWkt: "",
39-
elevation: { min: 0, max: 1, unit: "unknown" },
39+
distance: { metersPerUnit: null, unit: "unknown" },
40+
elevation: { metersPerUnit: null, min: 0, max: 1, unit: "unknown" },
4041
extent: {
4142
minX: 0,
4243
minY: 0,

src/components/path-profile-app.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getPathProfileApi, hasDesktopBridge } from "~/lib/electron-api";
2828
import { exportProfileStatus } from "~/lib/export-profile-status";
2929
import {
3030
createDefaultLineOfSightEndpoints,
31+
type FresnelZoneUnitScales,
3132
type LineOfSightEndpointId,
3233
type LineOfSightEndpoints,
3334
} from "~/lib/line-of-sight";
@@ -194,6 +195,19 @@ export function PathProfileApp() {
194195
project?.elevation.unit && project.elevation.unit !== "unknown"
195196
? project.elevation.unit
196197
: null;
198+
const fresnelUnitScales = useMemo<FresnelZoneUnitScales | null>(() => {
199+
const horizontalMetersPerUnit = project?.distance.metersPerUnit;
200+
const verticalMetersPerUnit = project?.elevation.metersPerUnit;
201+
202+
if (
203+
!isPositiveFinite(horizontalMetersPerUnit) ||
204+
!isPositiveFinite(verticalMetersPerUnit)
205+
) {
206+
return null;
207+
}
208+
209+
return { horizontalMetersPerUnit, verticalMetersPerUnit };
210+
}, [project]);
197211

198212
const noticeMessages = useMemo(
199213
() => (project ? warnings : ["Open a DEM from File > Open DEM..."]),
@@ -1022,6 +1036,7 @@ export function PathProfileApp() {
10221036
<div className="min-h-0 flex-1 px-4">
10231037
<ProfileChart
10241038
elevationUnit={elevationUnit}
1039+
fresnelUnitScales={fresnelUnitScales}
10251040
lineOfSightEndpoints={lineOfSightEndpoints}
10261041
lineOfSightDrafts={lineOfSightDrafts}
10271042
points={profilePoints}
@@ -1211,6 +1226,10 @@ function errorMessage(error: unknown): string {
12111226
return error instanceof Error ? error.message : String(error);
12121227
}
12131228

1229+
function isPositiveFinite(value: number | null | undefined): value is number {
1230+
return typeof value === "number" && Number.isFinite(value) && value > 0;
1231+
}
1232+
12141233
function themeLabel(theme: ThemeMode): string {
12151234
switch (theme) {
12161235
case "system":

src/components/profile-chart.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
buildFresnelZoneShell,
2929
buildVisibleFresnelZoneShellSegments,
3030
buildVisibleLineOfSightSegments,
31+
type FresnelZoneUnitScales,
3132
type LineOfSightChartPoint,
3233
type LineOfSightEndpointId,
3334
type LineOfSightEndpoints,
@@ -45,6 +46,7 @@ ChartJS.register(
4546

4647
type ProfileChartProps = {
4748
elevationUnit: string | null;
49+
fresnelUnitScales: FresnelZoneUnitScales | null;
4850
lineOfSightEndpoints: LineOfSightEndpoints | null;
4951
lineOfSightDrafts: LineOfSightDrafts;
5052
points: ProfilePoint[];
@@ -108,6 +110,7 @@ const fresnelShellDatasetOrder = 30;
108110
*/
109111
export function ProfileChart({
110112
elevationUnit,
113+
fresnelUnitScales,
111114
lineOfSightEndpoints,
112115
lineOfSightDrafts,
113116
points,
@@ -139,23 +142,29 @@ export function ProfileChart({
139142
const lastDistance = points.at(-1)?.distance ?? 0;
140143
const fresnelShell = useMemo(
141144
() =>
142-
buildFresnelZoneShell(
143-
points,
144-
lineOfSightEndpoints,
145-
fresnelFrequencyMhz,
146-
FRESNEL_SHELL_NUMBER,
147-
),
148-
[fresnelFrequencyMhz, lineOfSightEndpoints, points],
145+
fresnelUnitScales
146+
? buildFresnelZoneShell(
147+
points,
148+
lineOfSightEndpoints,
149+
fresnelFrequencyMhz,
150+
FRESNEL_SHELL_NUMBER,
151+
fresnelUnitScales,
152+
)
153+
: null,
154+
[fresnelFrequencyMhz, fresnelUnitScales, lineOfSightEndpoints, points],
149155
);
150156
const visibleFresnelShellSegments = useMemo(
151157
() =>
152-
buildVisibleFresnelZoneShellSegments(
153-
points,
154-
lineOfSightEndpoints,
155-
fresnelFrequencyMhz,
156-
FRESNEL_SHELL_NUMBER,
157-
),
158-
[fresnelFrequencyMhz, lineOfSightEndpoints, points],
158+
fresnelUnitScales
159+
? buildVisibleFresnelZoneShellSegments(
160+
points,
161+
lineOfSightEndpoints,
162+
fresnelFrequencyMhz,
163+
FRESNEL_SHELL_NUMBER,
164+
fresnelUnitScales,
165+
)
166+
: { lower: [], upper: [] },
167+
[fresnelFrequencyMhz, fresnelUnitScales, lineOfSightEndpoints, points],
159168
);
160169
const fresnelYBounds = useMemo(
161170
() =>
@@ -1027,7 +1036,12 @@ function distanceToSegment(
10271036
function parseFresnelFrequencyDraft(value: string): number | null {
10281037
if (value.trim() === "") return null;
10291038
const frequencyMhz = Number(value);
1030-
return Number.isFinite(frequencyMhz) && frequencyMhz > 0
1039+
const frequencyHz = frequencyMhz * 1_000_000;
1040+
1041+
return Number.isFinite(frequencyMhz) &&
1042+
frequencyMhz > 0 &&
1043+
Number.isFinite(frequencyHz) &&
1044+
frequencyHz > 0
10311045
? frequencyMhz
10321046
: null;
10331047
}

src/lib/line-of-sight.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe("line of sight helpers", () => {
9696
expect(fresnelRadiusAt(500, 1000, 0, 1)).toBeNull();
9797
expect(fresnelRadiusAt(500, 1000, 5800, 0)).toBeNull();
9898
expect(fresnelRadiusAt(1001, 1000, 5800, 1)).toBeNull();
99+
expect(fresnelRadiusAt(500, 1000, Number.MIN_VALUE, 1)).toBeNull();
99100
});
100101

101102
it("builds symmetric Fresnel shell lines around the line of sight", () => {
@@ -117,6 +118,19 @@ describe("line of sight helpers", () => {
117118
expect(shell?.lower[1]?.y).toBeCloseTo(11.41, 2);
118119
});
119120

121+
it("converts Fresnel distance and radius units for chart coordinates", () => {
122+
const shell = buildFresnelZoneShell(
123+
[point(0, null), point(500, null), point(1000, null)],
124+
{ startElevation: 10, endElevation: 20 },
125+
5800,
126+
1,
127+
{ horizontalMetersPerUnit: 0.3048, verticalMetersPerUnit: 0.3048 },
128+
);
129+
130+
expect(shell?.upper[1]?.y).toBeCloseTo(21.51, 2);
131+
expect(shell?.lower[1]?.y).toBeCloseTo(8.49, 2);
132+
});
133+
120134
it("returns null when a Fresnel shell cannot be built", () => {
121135
expect(
122136
buildFresnelZoneShell([], { startElevation: 0, endElevation: 0 }, 5800),
@@ -129,6 +143,15 @@ describe("line of sight helpers", () => {
129143
-1,
130144
),
131145
).toBeNull();
146+
expect(
147+
buildFresnelZoneShell(
148+
[point(0, null), point(1000, null)],
149+
{ startElevation: 0, endElevation: 0 },
150+
5800,
151+
1,
152+
{ horizontalMetersPerUnit: 0, verticalMetersPerUnit: 1 },
153+
),
154+
).toBeNull();
132155
});
133156

134157
it("builds Fresnel shell segments from visible boundary stretches", () => {
@@ -190,6 +213,15 @@ describe("line of sight helpers", () => {
190213
1,
191214
),
192215
).toEqual({ lower: [], upper: [] });
216+
expect(
217+
buildVisibleFresnelZoneShellSegments(
218+
[point(0, 5), point(10, 5), point(20, 5)],
219+
{ startElevation: 10, endElevation: 10 },
220+
5800,
221+
1,
222+
{ horizontalMetersPerUnit: 1, verticalMetersPerUnit: Infinity },
223+
),
224+
).toEqual({ lower: [], upper: [] });
193225
});
194226
});
195227

0 commit comments

Comments
 (0)