ifc_lite_processing/appearance/
transfer.rs1use super::{
7 page_raster::Raster,
8 transfer_budget::{self, TransferBudget},
9 transfer_math::validate_frame,
10 transfer_sampler::{accumulate, TransferSampler},
11 transfer_source::ScanSource,
12 transfer_types::*,
13 *,
14};
15use sha2::{Digest, Sha256};
16
17pub fn plan_mesh_transfer(
20 bytes: &[u8],
21 request: &MeshTransferRequest,
22 rgba: &[u8],
23) -> Result<MeshTransferPlan, String> {
24 plan_transfer(bytes, request, rgba, None)
25}
26pub fn plan_point_transfer(
29 bytes: &[u8],
30 request: &MeshTransferRequest,
31 rgba: &[u8],
32 points: &TransferPointPayload<'_>,
33) -> Result<MeshTransferPlan, String> {
34 plan_transfer(bytes, request, rgba, Some(points))
35}
36fn plan_transfer(
37 bytes: &[u8],
38 request: &MeshTransferRequest,
39 rgba: &[u8],
40 points: Option<&TransferPointPayload<'_>>,
41) -> Result<MeshTransferPlan, String> {
42 if points.is_some_and(|p| p.positions.len() > 6 * 2_000_000) {
43 return Err("Transfer point payload exceeds its budget".into());
44 }
45 if request.product_ids.is_empty()
46 || request.product_ids.len() > 10_000
47 || bytes.len() > 128 * 1024 * 1024
48 || rgba.len() > 128 * 1024 * 1024
49 {
50 return Err("Transfer input or product scope exceeds its budget".into());
51 }
52 if !request.max_distance_metres.is_finite()
53 || request.max_distance_metres <= 0.
54 || request.max_distance_metres > 10.
55 || !request.min_normal_dot.is_finite()
56 || request.min_normal_dot <= 0.
57 || request.min_normal_dot > 1.
58 || !request.ambiguity_distance_metres.is_finite()
59 || request.ambiguity_distance_metres < 0.
60 || request.ambiguity_distance_metres > request.max_distance_metres
61 || !request.max_behind_metres.is_finite()
62 || request.max_behind_metres < 0.
63 || request.max_behind_metres > request.max_distance_metres
64 {
65 return Err("Transfer requires bounded positive distance, oriented normal threshold, and nonnegative ambiguity and behind-surface distances within it".into());
66 }
67 if !matches!(request.schema.as_str(), "IFC4" | "IFC4X3")
68 || request.source_revision.len() > 256
69 || request.source_images.len() > 10_000
70 || request
71 .source_images
72 .iter()
73 .any(|image| image.image_uri.is_empty() || image.image_uri.len() > 4096)
74 {
75 return Err(
76 "Transfer schema, revision or target raster identities exceed their bounds".into(),
77 );
78 }
79 validate_frame(&request.target_from_ifc_world)?;
80 let registration = register_scan_correspondences(&request.registration)?;
81 if registration.request_sha256 != request.registration_sha256 {
82 return Err(
83 "Transfer registration digest does not match its frozen correspondence request".into(),
84 );
85 }
86 if format!("{:x}", Sha256::digest(bytes)) != registration.target_frame.asset_sha256 {
87 return Err("Transfer target snapshot does not match the frozen registration frame".into());
88 }
89 let source_frame = TransferFrame {
90 rotation: registration.rotation,
91 source_anchor: registration.source_anchor,
92 target_anchor: registration.target_anchor,
93 };
94 let mut budget = TransferBudget::new();
95 budget.reserve(rgba.len())?;
96 let (image, repeat) = match (&request.source, &request.source_image) {
97 (TransferSource::Mesh(mesh), Some(spec)) => {
98 let image = Raster::supplied(spec, rgba)?;
99 budget.charge(image.rgba.len() / 4)?;
100 if image.rgba.chunks_exact(4).any(|p| p[3] != 255) {
101 return Err("Transfer source image must be opaque; alpha appearance is unsupported".into());
102 }
103 (Some(image), [mesh.repeat_s, mesh.repeat_t])
104 }
105 (TransferSource::Points(_), None) => (None, [false; 2]),
106 (TransferSource::Mesh(_), None) => return Err("Transfer mesh source needs its source image".into()),
107 (TransferSource::Points(_), Some(_)) => return Err("Transfer point source carries colours per point, not a source image".into()),
108 };
109 let source = ScanSource::new(request, points, &source_frame, &mut budget)?;
110 let prepared_sha256 = digest(bytes, request, rgba, points)?;
111 let mut sampler = TransferSampler::new(
112 source,
113 budget,
114 image,
115 &request.target_from_ifc_world,
116 repeat,
117 );
118 let spec = AppearanceRequest {
121 representation_policy: RepresentationPolicy::Preserve,
122 schema: request.schema.clone(),
123 source_revision: request.source_revision.clone(),
124 next_express_id: request.next_express_id,
125 product_ids: request.product_ids.clone(),
126 image_uri: "textures/prepared-transfer.png".into(),
127 repeat_s: false,
128 repeat_t: false,
129 mapping: Mapping::Box {
130 frame: MappingFrame::Item,
131 origin: [0.; 3],
132 metres_per_tile: [1.; 3],
133 },
134 face_masks: Vec::new(),
135 };
136 let output = atlas_plan::plan_sampled_appearance(
137 bytes,
138 &spec,
139 &request.source_images,
140 rgba,
141 request.texels_per_metre,
142 &mut sampler,
143 )?;
144 let mut coverage = TransferCoverage::default();
145 for item in &sampler.items {
146 accumulate(&mut coverage, &item.coverage);
147 }
148 let sufficient_counts =
149 request.registration.fit.len() >= 4 && request.registration.held_out.len() >= 4;
150 let applicable = coverage.observed_raster_interior_texels > 0 && sufficient_counts;
151 let mut diagnostics=vec!["Coverage is a triangle-area-weighted centroid/interior-texel estimate, not a registration accuracy approval".into(),
152 "Unknown samples preserve the existing target albedo through the shared atlas; source GLB byte-to-decoded-mesh/image identity is verified by the host".into()];
153 diagnostics.extend(registration.diagnostics.clone());
154 if !sufficient_counts {
155 diagnostics.push("Insufficient operational registration evidence: application acceptance requires at least 4 fit and 4 spatially distributed held-out observations; this is a calculation-only plan".into());
156 }
157 if coverage.observed_samples == 0 {
158 diagnostics
159 .push("No observed target samples; no applicable mutation plan was produced".into());
160 }
161 if coverage.observed_samples > 0 && coverage.observed_raster_interior_texels == 0 {
162 diagnostics.push("No observed interior raster texels; centroid observations alone do not establish emitted scan appearance, so no applicable mutation plan was produced".into());
163 }
164 let exclusions = output.plan.exclusions.clone();
165 Ok(MeshTransferPlan {
166 output: applicable.then_some(output),
167 texels_per_metre: request.texels_per_metre,
168 transfer: MeshTransferSummary {
169 prepared_sha256,
170 source: ScanSource::summary(request),
171 budget: TransferBudgetReport {
172 work_used: (transfer_budget::WORK_LIMIT - sampler.budget.work) as u64,
173 work_limit: transfer_budget::WORK_LIMIT as u64,
174 },
175 registration_sha256: registration.request_sha256.clone(),
176 registration,
177 applicable,
178 coverage,
179 items: sampler.items,
180 exclusions,
181 diagnostics,
182 },
183 })
184}
185fn digest(
186 bytes: &[u8],
187 request: &MeshTransferRequest,
188 rgba: &[u8],
189 points: Option<&TransferPointPayload<'_>>,
190) -> Result<String, String> {
191 struct Writer(Sha256);
192 impl std::io::Write for Writer {
193 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
194 self.0.update(bytes);
195 Ok(bytes.len())
196 }
197 fn flush(&mut self) -> std::io::Result<()> {
198 Ok(())
199 }
200 }
201 let mut hash = Writer(Sha256::new());
202 hash.0.update(b"ifclite-scan-transfer-v4-source-kind\0");
203 hash.0.update((bytes.len() as u64).to_le_bytes());
205 hash.0.update(bytes);
206 hash.0.update((rgba.len() as u64).to_le_bytes());
207 hash.0.update(rgba);
208 if let Some(points) = points {
209 for (label, length) in [("positions", points.positions.len()), ("colors", points.colors.len()), ("normals", points.normals.len()), ("stations", points.stations.len())] {
210 hash.0.update(label.as_bytes());
211 hash.0.update((length as u64).to_le_bytes());
212 }
213 for v in points.positions {
214 hash.0.update(v.to_le_bytes());
215 }
216 hash.0.update(points.colors);
217 for v in points.normals {
218 hash.0.update(v.to_le_bytes());
219 }
220 for v in points.stations {
221 hash.0.update(v.to_le_bytes());
222 }
223 }
224 serde_json::to_writer(&mut hash, request).map_err(|e| e.to_string())?;
225 Ok(format!("{:x}", hash.0.finalize()))
226}
227#[cfg(test)]
228#[path = "transfer_tests.rs"]
229pub(super) mod tests;
230#[cfg(test)]
231#[path = "transfer_acceptance_tests.rs"]
232pub(super) mod acceptance_tests;