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 mesh observations composed over canonical IFC appearance.
5use super::{
6    page_raster::Raster,
7    transfer_budget::TransferBudget,
8    transfer_math::validate_frame,
9    transfer_sampler::{accumulate, TransferSampler},
10    transfer_surface::Surface,
11    transfer_types::*,
12    *,
13};
14use sha2::{Digest, Sha256};
15
16pub fn plan_mesh_transfer(
17    bytes: &[u8],
18    request: &MeshTransferRequest,
19    rgba: &[u8],
20) -> Result<MeshTransferPlan, String> {
21    if request.product_ids.is_empty()
22        || request.product_ids.len() > 10_000
23        || bytes.len() > 128 * 1024 * 1024
24        || rgba.len() > 128 * 1024 * 1024
25    {
26        return Err("Transfer input or product scope exceeds its budget".into());
27    }
28    if !request.max_distance_metres.is_finite()
29        || request.max_distance_metres <= 0.
30        || request.max_distance_metres > 10.
31        || !request.min_normal_dot.is_finite()
32        || request.min_normal_dot <= 0.
33        || request.min_normal_dot > 1.
34        || !request.ambiguity_distance_metres.is_finite()
35        || request.ambiguity_distance_metres < 0.
36        || request.ambiguity_distance_metres > request.max_distance_metres
37    {
38        return Err("Transfer requires bounded positive distance, oriented normal threshold, and nonnegative ambiguity distance".into());
39    }
40    if !matches!(request.schema.as_str(), "IFC4" | "IFC4X3")
41        || request.source_revision.len() > 256
42        || request.source_images.len() > 10_000
43        || request
44            .source_images
45            .iter()
46            .any(|image| image.image_uri.is_empty() || image.image_uri.len() > 4096)
47    {
48        return Err(
49            "Transfer schema, revision or target raster identities exceed their bounds".into(),
50        );
51    }
52    validate_frame(&request.target_from_ifc_world)?;
53    let registration = register_scan_correspondences(&request.registration)?;
54    if registration.request_sha256 != request.registration_sha256 {
55        return Err(
56            "Transfer registration digest does not match its frozen correspondence request".into(),
57        );
58    }
59    if format!("{:x}", Sha256::digest(bytes)) != registration.target_frame.asset_sha256 {
60        return Err("Transfer target snapshot does not match the frozen registration frame".into());
61    }
62    let source_frame = TransferFrame {
63        rotation: registration.rotation,
64        source_anchor: registration.source_anchor,
65        target_anchor: registration.target_anchor,
66    };
67    let mut budget = TransferBudget::new();
68    budget.reserve(rgba.len())?;
69    let image = Raster::supplied(&request.source_image, rgba)?;
70    budget.charge(image.rgba.len() / 4)?;
71    if image.rgba.chunks_exact(4).any(|p| p[3] != 255) {
72        return Err("Transfer source image must be opaque; alpha appearance is unsupported".into());
73    }
74    let surface = Surface::new(request, &source_frame, &mut budget)?;
75    let prepared_sha256 = digest(bytes, request, rgba)?;
76    let mut sampler = TransferSampler::new(
77        surface,
78        budget,
79        image,
80        &request.target_from_ifc_world,
81        [request.source_mesh.repeat_s, request.source_mesh.repeat_t],
82    );
83    // Mapping is only canonical initial UV scaffolding; the sampler supplies all
84    // final charts, using the same planner/material preservation as page overlays.
85    let spec = AppearanceRequest {
86        representation_policy: RepresentationPolicy::Preserve,
87        schema: request.schema.clone(),
88        source_revision: request.source_revision.clone(),
89        next_express_id: request.next_express_id,
90        product_ids: request.product_ids.clone(),
91        image_uri: "textures/prepared-transfer.png".into(),
92        repeat_s: false,
93        repeat_t: false,
94        mapping: Mapping::Box {
95            frame: MappingFrame::Item,
96            origin: [0.; 3],
97            metres_per_tile: [1.; 3],
98        },
99    };
100    let output = atlas_plan::plan_sampled_appearance(
101        bytes,
102        &spec,
103        &request.source_images,
104        rgba,
105        request.texels_per_metre,
106        &mut sampler,
107    )?;
108    let mut coverage = TransferCoverage::default();
109    for item in &sampler.items {
110        accumulate(&mut coverage, &item.coverage);
111    }
112    let sufficient_counts =
113        request.registration.fit.len() >= 4 && request.registration.held_out.len() >= 4;
114    let applicable = coverage.observed_raster_interior_texels > 0 && sufficient_counts;
115    let mut diagnostics=vec!["Coverage is a triangle-area-weighted centroid/interior-texel estimate, not a registration accuracy approval".into(),
116        "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()];
117    diagnostics.extend(registration.diagnostics.clone());
118    if !sufficient_counts {
119        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());
120    }
121    if coverage.observed_samples == 0 {
122        diagnostics
123            .push("No observed target samples; no applicable mutation plan was produced".into());
124    }
125    if coverage.observed_samples > 0 && coverage.observed_raster_interior_texels == 0 {
126        diagnostics.push("No observed interior raster texels; centroid observations alone do not establish emitted scan appearance, so no applicable mutation plan was produced".into());
127    }
128    let exclusions = output.plan.exclusions.clone();
129    Ok(MeshTransferPlan {
130        output: applicable.then_some(output),
131        texels_per_metre: request.texels_per_metre,
132        transfer: MeshTransferSummary {
133            prepared_sha256,
134            registration_sha256: registration.request_sha256.clone(),
135            registration,
136            applicable,
137            coverage,
138            items: sampler.items,
139            exclusions,
140            diagnostics,
141        },
142    })
143}
144fn digest(bytes: &[u8], request: &MeshTransferRequest, rgba: &[u8]) -> Result<String, String> {
145    struct Writer(Sha256);
146    impl std::io::Write for Writer {
147        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
148            self.0.update(bytes);
149            Ok(bytes.len())
150        }
151        fn flush(&mut self) -> std::io::Result<()> {
152            Ok(())
153        }
154    }
155    let mut hash = Writer(Sha256::new());
156    hash.0.update(b"ifclite-mesh-transfer-v2-raster-guards\0");
157    // Length-prefix binary portions; JSON is last and streamed without a duplicate allocation.
158    hash.0.update((bytes.len() as u64).to_le_bytes());
159    hash.0.update(bytes);
160    hash.0.update((rgba.len() as u64).to_le_bytes());
161    hash.0.update(rgba);
162    serde_json::to_writer(&mut hash, request).map_err(|e| e.to_string())?;
163    Ok(format!("{:x}", hash.0.finalize()))
164}
165#[cfg(test)]
166#[path = "transfer_tests.rs"]
167mod tests;