BREP_kernel 0.2.0

A boundary representation (BREP) geometry kernel for building CAD applications.
Documentation
use super::*;

/// Everything the native renderer needs to display one resident solid.
pub struct DisplaySolidPayload {
    /// Faces in shell/face order — the index into this Vec IS the watertight
    /// mesh's `face_ids` value for that face's triangles.
    pub faces: Vec<(u64, Option<String>)>,
    /// Watertight display mesh (chord-tolerance driven).
    pub mesh: Mesh,
    /// Non-degenerate edges `(edge_id, name, polyline)`, sorted by edge id.
    pub edges: Vec<(u64, Option<String>, Vec<Vec3>)>,
    /// Topology vertices `(vertex_id, point)`.
    pub vertices: Vec<(u64, Vec3)>,
    /// The chord tolerance actually used.
    pub chord_tolerance: f64,
}

/// The app's display chord tolerance (`BetterSolid._kernelTessellationOptions`):
/// vertex |coord| extent — falling back to the control-point hull / sqrt(2) for
/// vertex-free solids (full spheres/tori) — times 1.5e-3, times the render-LOD
/// factor (1.0 = the app's "Normal" preset).
pub fn display_chord_tolerance(solid: &BrepSolid, lod_factor: f64) -> f64 {
    let mut extent = 0.0f64;
    for vertex in &solid.vertices {
        extent = extent
            .max(vertex.point.x.abs())
            .max(vertex.point.y.abs())
            .max(vertex.point.z.abs());
    }
    if extent <= 0.0 {
        let mut control_point_extent = 0.0f64;
        for shell in &solid.shells {
            for face in &shell.faces {
                for row in &face.surface.control_points {
                    for cp in row {
                        let w = if cp.w != 0.0 { cp.w } else { 1.0 };
                        control_point_extent = control_point_extent
                            .max((cp.x / w).abs())
                            .max((cp.y / w).abs())
                            .max((cp.z / w).abs());
                    }
                }
            }
        }
        extent = control_point_extent / std::f64::consts::SQRT_2;
    }
    extent.max(1e-9) * 1.5e-3 * lod_factor
}

/// Native display payload for a resident solid: watertight mesh + face list (in
/// mesh `face_ids` order) + edge polylines + vertices, all in one registry
/// borrow. `lod_factor` scales the per-solid display chord tolerance (1.0 = the
/// app's "Normal" preset; higher = coarser mesh). Callers pass a sanitized,
/// finite, positive value — [`display_chord_tolerance`] multiplies it in directly.
pub fn display_payload_handle_native(
    handle: u32,
    lod_factor: f64,
) -> Result<DisplaySolidPayload, String> {
    with_registered_solid_str(handle, |solid| {
        let chord = display_chord_tolerance(solid, lod_factor);
        let mesh = tessellate_brep_watertight(solid, chord)?;
        let mut faces = Vec::new();
        for shell in &solid.shells {
            for face in &shell.faces {
                faces.push((face.id, face.name.clone()));
            }
        }
        let edge_names: std::collections::HashMap<u64, String> = solid
            .edges
            .iter()
            .filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name.clone())))
            .collect();
        let edges = sample_edge_polylines(solid, chord)?
            .into_iter()
            .map(|(id, points)| (id, edge_names.get(&id).cloned(), points))
            .collect();
        let vertices = solid
            .vertices
            .iter()
            .map(|vertex| (vertex.id, vertex.point))
            .collect();
        Ok(DisplaySolidPayload {
            faces,
            mesh,
            edges,
            vertices,
            chord_tolerance: chord,
        })
    })
}

/// Native display payload for a solved sketch PROFILE — the SHEET-SOLID view of a
/// committed sketch: a planar FACE mesh + its named boundary EDGES + corner
/// VERTICES, so a sketch is pickable / selectable / measurable through the exact
/// same display path as a real solid. NO solid is registered — the payload is
/// synthesized directly from the profile, so it owns no scene handle (the display
/// carries `source_handle = 0`).
///
/// The FACE triangulates each region (outer boundary minus holes) via the
/// watertight planar path, mapped to world through the profile's frame; each
/// triangle rides face id 0 (one logical sheet face). EDGES are each boundary
/// curve sampled to a world polyline, named from the profile's `{sketchId}:G{gid}`
/// edge names; VERTICES are the loop corners. An empty profile (no closed region)
/// yields an empty payload (no face, no edges) — an open/underconstrained sketch
/// has no sheet.
pub fn sketch_profile_display_payload(
    profile: &crate::feature_pipeline::SketchProfile,
) -> DisplaySolidPayload {
    /// Samples per boundary curve — enough to resolve arcs/circles; straight
    /// segments oversample harmlessly.
    const SEGMENTS: usize = 24;
    let origin = profile.origin;
    let x_axis = profile.x_axis;
    let y_axis = profile.y_axis;
    let normal = profile.z_axis;
    let to_uv = |p: Vec3| {
        let d = p.sub(origin);
        [d.dot(x_axis), d.dot(y_axis)]
    };

    let mut mesh = Mesh::default();
    let mut edges: Vec<(u64, Option<String>, Vec<Vec3>)> = Vec::new();
    let mut vertices: Vec<(u64, Vec3)> = Vec::new();
    let mut next_edge_id: u64 = 0;
    let mut next_vertex_id: u64 = 0;
    let mut sketch_id: Option<String> = None;

    for region in &profile.regions {
        let Some((outer, holes)) = region.split_first() else {
            continue;
        };
        // Boundary vertices (uv, world) for triangulation: sample [t0, t1) per
        // curve so the next curve contributes the shared corner exactly once.
        let boundary = |lp: &crate::feature_pipeline::ProfileLoop| -> Vec<([f64; 2], Vec3)> {
            let mut out = Vec::new();
            for curve in &lp.curves {
                let Ok([t0, t1]) = curve.domain() else { continue };
                for step in 0..SEGMENTS {
                    let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
                    if let Ok(p) = curve.evaluate(t) {
                        out.push((to_uv(p), p));
                    }
                }
            }
            out
        };
        let outer_b = boundary(outer);
        let holes_b: Vec<Vec<([f64; 2], Vec3)>> = holes.iter().map(boundary).collect();
        for [a, b, c] in watertight_tessellation::triangulate_planar_region(&outer_b, &holes_b) {
            let base = (mesh.positions.len() / 3) as u32;
            for p in [a, b, c] {
                mesh.positions.extend([p.x, p.y, p.z]);
                mesh.normals.extend([normal.x, normal.y, normal.z]);
            }
            mesh.indices.extend([base, base + 1, base + 2]);
            mesh.face_ids.push(0);
        }

        // Named boundary EDGES + corner VERTICES for every loop of this region.
        for lp in region {
            for (index, curve) in lp.curves.iter().enumerate() {
                let Ok([t0, t1]) = curve.domain() else { continue };
                let mut polyline = Vec::with_capacity(SEGMENTS + 1);
                for step in 0..=SEGMENTS {
                    let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
                    if let Ok(p) = curve.evaluate(t) {
                        polyline.push(p);
                    }
                }
                let name = lp.edge_names.get(index).cloned().flatten();
                if sketch_id.is_none() {
                    // `{sketchId}:G{gid}` → the sheet's face inherits `{sketchId}`.
                    sketch_id = name
                        .as_deref()
                        .and_then(|n| n.split_once(":G").map(|(id, _)| id.to_string()));
                }
                if polyline.len() >= 2 {
                    edges.push((next_edge_id, name, polyline));
                    next_edge_id += 1;
                }
                if let Ok(p) = curve.evaluate(t0) {
                    vertices.push((next_vertex_id, p));
                    next_vertex_id += 1;
                }
            }
        }
    }

    // One logical sheet face (id 0), only if the mesh has triangles.
    let faces = if mesh.face_ids.is_empty() {
        Vec::new()
    } else {
        let face_name = sketch_id.map(|id| format!("{id}:FACE"));
        vec![(0u64, face_name)]
    };
    DisplaySolidPayload {
        faces,
        mesh,
        edges,
        vertices,
        chord_tolerance: 0.0,
    }
}

/// Full mass properties of a resident solid, scaled to `density` (the native,
/// non-wasm sibling of [`mass_properties_handle`] for the in-process renderer):
/// volume + surface area + centroid + centroidal inertia tensor + principal
/// axes/moments (Golovanov §8.11). The underlying geometry is unit-density; here
/// `mass = density * volume` and every inertia quantity scales linearly with
/// density (centroid + principal axes are density-independent). `density` is in
/// mass units per mm³ (the kernel's length convention is millimetres); pass
/// `1.0` for the raw geometric result (`mass == volume`). Reads the solid in one
/// registry borrow; the topology never crosses a boundary.
pub fn mass_properties_handle_native(
    handle: u32,
    density: f64,
) -> Result<DensityMassProperties, String> {
    with_registered_solid_str(handle, |solid| {
        Ok(solid_mass_properties_full(solid)?.with_density(density))
    })
}

/// Total 3D arc length (mm) of every non-degenerate edge of a resident solid —
/// the Properties panel's "total edge length" measurement. Native sibling of the
/// mass-properties accessors; one short registry borrow, topology never crosses
/// the boundary.
pub fn solid_edge_length_total_native(handle: u32) -> Result<f64, String> {
    with_registered_solid_str(handle, solid_edge_length_total)
}

/// `(area, boundary_edge_total_length)` of a resident solid's named face
/// (mm² / mm): the face's surface area plus the summed arc length of its boundary
/// edges. Errs if no face carries `face_name`.
pub fn face_measurements_native(handle: u32, face_name: &str) -> Result<(f64, f64), String> {
    with_registered_solid_str(handle, |solid| {
        let face = solid
            .shells
            .iter()
            .flat_map(|shell| &shell.faces)
            .find(|face| face.name.as_deref() == Some(face_name))
            .ok_or_else(|| format!("face '{face_name}' not found"))?;
        Ok((face_area(face)?, face_boundary_length(solid, face)?))
    })
}

/// 3D arc length (mm) of a resident solid's named edge. Errs if no edge carries
/// `edge_name`.
pub fn edge_length_native(handle: u32, edge_name: &str) -> Result<f64, String> {
    with_registered_solid_str(handle, |solid| {
        let edge = solid
            .edges
            .iter()
            .find(|edge| edge.name.as_deref() == Some(edge_name))
            .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
        edge_arc_length(edge)
    })
}

/// Escape hatch: pull a resident solid's full topology across the boundary (for
/// STEP export or JSON-only lanes during the migration). Prefer handle-native
/// ops — this re-incurs the serialization cost the registry exists to avoid.
#[wasm_bindgen]
pub fn solid_handle_to_buffer(handle: u32) -> Result<WasmSolidBuffer, JsValue> {
    with_registered_solid(handle, |solid| solid_buffer(solid, "{}".into()))
}

/// Native: export the CURRENT resident solids (by handle) to an ISO-10303-21
/// STEP document. Reads each resident solid out of the thread-local registry —
/// the SAME registry [`display_payload_handle_native`] reads — clones them into
/// a `Vec<BrepSolid>`, and hands the batch to [`export_step`]. The engine-native
/// app's Export→STEP lane calls this with the handles the pipeline left resident
/// after the last history run, so the topology never crosses a boundary as JSON.
/// `String` error (JsValue-free — it links + runs on native and wasm alike).
pub fn export_step_handles(
    handles: &[u32],
    name: &str,
    unit: &str,
    timestamp: &str,
) -> Result<String, String> {
    if handles.is_empty() {
        return Err("export_step_handles: no solids to export".into());
    }
    let mut solids = Vec::with_capacity(handles.len());
    for &handle in handles {
        let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
        solids.push(solid);
    }
    export_step(&solids, name, unit, timestamp)
}