Skip to main content

ifc_lite_processing/appearance/
transfer.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//! Bounded registered scan observations (textured mesh or RGB point cloud)
5//! composed over canonical IFC appearance.
6use 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
17/// Textured mesh source. `rgba` carries the source image and the target's
18/// existing rasters; a point source is refused here.
19pub 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}
26/// RGB point-cloud source (#4381). `rgba` carries only the target's existing
27/// rasters; positions, colours and optional normals/stations arrive in `points`.
28pub 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    // Mapping is only canonical initial UV scaffolding; the sampler supplies all
119    // final charts, using the same planner/material preservation as page overlays.
120    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    // Length-prefix binary portions; JSON is last and streamed without a duplicate allocation.
204    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;