Files
houseplan-card/src/radar-geometry.ts
T
Matysh e9fb255a29 fix: close radar review gaps
User-Visible: yes
Issue: #485
2026-09-09 03:54:02 +03:00

108 lines
4.3 KiB
TypeScript

/** Pure physical projection and rigid two-reference calibration for #485. */
export const RADAR_UNIT_TO_CM = Object.freeze({
mm: .1, cm: 1, m: 100, in: 2.54, ft: 30.48,
});
export interface RadarFitResult {
headingDeg: number;
mirror: boolean;
rmsCm: number;
errorsCm: readonly [number, number];
}
type Point = readonly [number, number];
export function radarLengthCm(value: number, unit: keyof typeof RADAR_UNIT_TO_CM): number {
const result = value * RADAR_UNIT_TO_CM[unit];
if (!Number.isFinite(result) || Math.abs(result) > 10_000) throw new Error('invalid_radar');
return result;
}
export function projectRadarLocal(
mount: Point,
headingDeg: number,
mirror: boolean,
cellCm: number,
localCm: Point,
): Point {
if (![...mount, headingDeg, cellCm, ...localCm].every(Number.isFinite)
|| cellCm <= 0 || Math.hypot(...localCm) > 10_000) throw new Error('invalid_radar');
const theta = headingDeg * Math.PI / 180;
const sign = mirror ? -1 : 1;
const scale = 240 * cellCm;
return [
mount[0] + (sign * localCm[0] * Math.cos(theta) + localCm[1] * Math.sin(theta)) / scale,
mount[1] + (sign * localCm[0] * Math.sin(theta) - localCm[1] * Math.cos(theta)) / scale,
];
}
export function solveRadarTwoPoint(
mount: Point,
localPoints: readonly [Point, Point],
planPoints: readonly [Point, Point],
cellCm: number,
): RadarFitResult {
if (![...mount, cellCm, ...localPoints.flat(), ...planPoints.flat()].every(Number.isFinite)
|| cellCm <= 0) {
throw new Error('invalid_selection');
}
if (localPoints.some((point) => Math.hypot(...point) < 50)) throw new Error('bad_references');
const denominator = Math.hypot(...localPoints[0]) * Math.hypot(...localPoints[1]);
const cosine = (localPoints[0][0] * localPoints[1][0]
+ localPoints[0][1] * localPoints[1][1]) / denominator;
const angle = Math.acos(Math.max(-1, Math.min(1, cosine))) * 180 / Math.PI;
if (angle < 20 || angle > 160) throw new Error('bad_references');
const scale = 240 * cellCm;
const targets = planPoints.map((point) => [
(point[0] - mount[0]) * scale,
-(point[1] - mount[1]) * scale,
] as const);
const candidates: RadarFitResult[] = [];
for (const mirror of [false, true]) {
const inputs = localPoints.map(([x, y]) => [mirror ? -x : x, y] as const);
const dot = inputs.reduce((sum, point, index) => sum
+ point[0] * targets[index][0] + point[1] * targets[index][1], 0);
const cross = inputs.reduce((sum, point, index) => sum
+ point[0] * targets[index][1] - point[1] * targets[index][0], 0);
const rotation = Math.atan2(cross, dot);
const errors = inputs.map(([x, y], index) => {
const rx = x * Math.cos(rotation) - y * Math.sin(rotation);
const ry = x * Math.sin(rotation) + y * Math.cos(rotation);
return Math.hypot(rx - targets[index][0], ry - targets[index][1]);
}) as [number, number];
const radialOk = localPoints.every((point, index) => {
const actual = Math.hypot(...point);
const expected = Math.hypot(...targets[index]);
return Math.abs(actual - expected) <= Math.max(20, .15 * expected);
});
const rmsCm = Math.sqrt((errors[0] ** 2 + errors[1] ** 2) / 2);
if (radialOk && rmsCm <= 20 && Math.max(...errors) <= 30) {
candidates.push({
headingDeg: ((-rotation * 180 / Math.PI) % 360 + 360) % 360,
mirror, rmsCm, errorsCm: errors,
});
}
}
candidates.sort((a, b) => a.rmsCm - b.rmsCm);
if (!candidates.length) throw new Error('invalid_selection');
if (candidates.length > 1 && Math.abs(candidates[0].rmsCm - candidates[1].rmsCm) < 10) {
throw new Error('ambiguous_sources');
}
return candidates[0];
}
export function radarMedianSample(samples: readonly Point[]): Point {
if (samples.length < 3) throw new Error('insufficient_samples');
const median = (values: number[]) => {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
};
const point: Point = [median(samples.map((sample) => sample[0])),
median(samples.map((sample) => sample[1]))];
if (samples.some((sample) => Math.hypot(sample[0] - point[0], sample[1] - point[1]) > 15)) {
throw new Error('bad_fit');
}
return point;
}