Skip to main content

ifc_lite_processing/appearance/
calibration.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//! Measured source-plane calibration, independent of raster DPI and crop.
5use super::{Mapping, MappingFrame};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct PlaneCalibrationRequest {
11    /// Affine [a,b,c,d,e,f]: raster pixel edges -> stable native source XY.
12    /// For PDF pages this is PdfRasterRecipe.pixelToPdf, including page rotation.
13    pub raster_to_source: [f64; 6],
14    pub raster_size: [u32; 2],
15    /// Two measured points in native source coordinates, retained across crops.
16    pub source_points: [[f64; 2]; 2],
17    pub distance_metres: f64,
18    /// IFC world Z-up metres at the first measured source point.
19    pub world_anchor: [f64; 3],
20    /// First -> second measured point direction, within the chosen world plane.
21    pub world_direction: [f64; 3],
22    /// Positive native source XY orientation; not the raster's downward Y axis.
23    pub plane_normal: [f64; 3],
24}
25
26#[derive(Debug, Clone, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct CalibratedPlane {
29    /// IFC UV origin is the raster's bottom-left, with V pointing upward.
30    pub mapping: Mapping,
31    /// Top-left, top-right, bottom-right, bottom-left, in IFC world metres.
32    pub raster_corners: [[f64; 3]; 4],
33    pub metres_per_source_unit: f64,
34}
35
36fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
37    a.iter().zip(b).map(|(x, y)| x * y).sum()
38}
39fn length(v: [f64; 3]) -> f64 { v[0].hypot(v[1]).hypot(v[2]) }
40// Bound each reconstructed vector independently to one part per million.
41// Checking only distinct corners misses partial coordinate collapse, while a
42// tolerance based on the longest edge hides distortion of narrow rectangles.
43fn preserves_vector(start: [f64; 3], end: [f64; 3], intended: [f64; 3]) -> bool {
44    let error = length(std::array::from_fn(|i| (end[i] - start[i]) - intended[i]));
45    error.is_finite() && error <= length(intended) * 1e-6
46}
47fn unit(v: [f64; 3]) -> Result<[f64; 3], String> {
48    let len = length(v);
49    if !v.iter().all(|v| v.is_finite()) || !len.is_finite() || len <= 0. {
50        return Err("Calibration needs finite, nonzero plane directions".into());
51    }
52    Ok(v.map(|value| value / len))
53}
54
55/// Establish one measured similarity transform for the whole source, then derive
56/// the current raster's world rectangle. A crop/rotation/DPI change only changes
57/// raster_to_source and raster_size; it never recalibrates the measured span.
58/// This computes placement only, not clipped projection or IFC mutations.
59pub fn calibrate_appearance_plane(request: &PlaneCalibrationRequest) -> Result<CalibratedPlane, String> {
60    let r = request;
61    if !r.raster_to_source.iter().chain(r.source_points.iter().flatten())
62        .chain(r.world_anchor.iter()).all(|v| v.is_finite())
63        || !r.distance_metres.is_finite() || r.distance_metres <= 0.
64        || r.raster_size.iter().any(|&v| v == 0 || v > 8192)
65        || u64::from(r.raster_size[0]) * u64::from(r.raster_size[1]) > 16 * 1024 * 1024 {
66        return Err("Calibration needs finite coordinates, a positive measured distance and a bounded raster".into());
67    }
68    let dx = r.source_points[1][0] - r.source_points[0][0];
69    let dy = r.source_points[1][1] - r.source_points[0][1];
70    let span = dx.hypot(dy);
71    if !span.is_finite() || span <= 0. {
72        return Err("Choose two distinct source points for calibration".into());
73    }
74    let scale = r.distance_metres / span;
75    if !scale.is_finite() || scale <= 0. {
76        return Err("The measured source scale cannot be represented".into());
77    }
78    let direction = unit(r.world_direction)?;
79    let normal = unit(r.plane_normal)?;
80    if dot(direction, normal).abs() > 1e-10 {
81        return Err("The measured direction must lie in the chosen plane".into());
82    }
83    let sideways = unit([
84        normal[1] * direction[2] - normal[2] * direction[1],
85        normal[2] * direction[0] - normal[0] * direction[2],
86        normal[0] * direction[1] - normal[1] * direction[0],
87    ])?;
88    let sx = dx / span;
89    let sy = dy / span;
90    let vector = |x: f64, y: f64| -> [f64; 3] {
91        let along = (x * sx + y * sy) * scale;
92        let across = (-x * sy + y * sx) * scale;
93        std::array::from_fn(|i| direction[i] * along + sideways[i] * across)
94    };
95    let [a, b, c, d, e, f] = r.raster_to_source;
96    let raster_x = vector(a, b);
97    let raster_y = vector(c, d);
98    let axis_u = unit(raster_x)?;
99    let axis_v = unit(raster_y.map(|v| -v))?;
100    if dot(axis_u, axis_v).abs() > 1e-10 {
101        return Err("A sheared raster needs rectification before planar projection".into());
102    }
103    let width = length(raster_x) * f64::from(r.raster_size[0]);
104    let height = length(raster_y) * f64::from(r.raster_size[1]);
105    let top_offset = vector(e - r.source_points[0][0], f - r.source_points[0][1]);
106    let top_left = std::array::from_fn(|i| r.world_anchor[i] + top_offset[i]);
107    let top_right = std::array::from_fn(|i| top_left[i] + axis_u[i] * width);
108    let bottom_left = std::array::from_fn(|i| top_left[i] - axis_v[i] * height);
109    let bottom_right = std::array::from_fn(|i| bottom_left[i] + axis_u[i] * width);
110    let corners = [top_left, top_right, bottom_right, bottom_left];
111    if !corners.iter().flatten().all(|v| v.is_finite())
112        || !width.is_finite() || !height.is_finite() || width <= 0. || height <= 0.
113        || !preserves_vector(r.world_anchor, top_left, top_offset)
114        || !preserves_vector(top_left, top_right, axis_u.map(|v| v * width))
115        || !preserves_vector(bottom_left, bottom_right, axis_u.map(|v| v * width))
116        || !preserves_vector(bottom_left, top_left, axis_v.map(|v| v * height))
117        || !preserves_vector(bottom_right, top_right, axis_v.map(|v| v * height)) {
118        return Err("The calibrated plane exceeds coordinate precision; use a local world frame".into());
119    }
120    Ok(CalibratedPlane {
121        mapping: Mapping::Planar { frame: MappingFrame::World, origin: bottom_left,
122            axis_u, axis_v, metres_per_tile: [width, height] },
123        raster_corners: corners,
124        metres_per_source_unit: scale,
125    })
126}
127
128#[cfg(test)]
129#[path = "calibration_tests.rs"]
130mod tests;