forked from 1b-agent/opencadstudio-upstream
Scene meshes store each position as an f32 half plus a low residual so geometry stays precise at survey coordinates, and the renderer adds the two back together. STEP export read only the f32 half. Near x = 500000 that half steps in 1/32 units, so a solid at 500000.123 was written as 500000.125, and the edge directions and planes were built from the same snapped points. Build each exported vertex from both halves in f64 and derive directions and the fallback face normal from those. Meshes without a residual export as before. Co-Authored-By: Claude Opus 5 <[email protected]>
334 lines
11 KiB
Rust
334 lines
11 KiB
Rust
// STEP AP203 export — converts tessellated MeshModels to ISO 10303-21 format.
|
|
//
|
|
// The output is a minimal but valid STEP AP203 file containing:
|
|
// - One SHAPE_REPRESENTATION per solid mesh
|
|
// - ADVANCED_FACE → PLANE → AXIS2_PLACEMENT_3D for each triangle
|
|
// - VERTEX_POINT / EDGE_CURVE / ORIENTED_EDGE topology
|
|
//
|
|
// Because building full B-Rep topology from a triangle soup is complex, we use
|
|
// a simplified encoding: each triangle becomes a CLOSED_SHELL with three
|
|
// ADVANCED_FACEs, each face bounded by three oriented edges.
|
|
//
|
|
// This produces larger-than-optimal files but is universally importable by
|
|
// CAD systems that accept AP203.
|
|
|
|
use crate::scene::model::mesh_model::MeshModel;
|
|
use std::fmt::Write as FmtWrite;
|
|
|
|
/// Build a STEP AP203 text representation from a slice of mesh models.
|
|
///
|
|
/// Returns `None` if there are no triangles to export.
|
|
pub fn build_step(meshes: &[&MeshModel]) -> Option<String> {
|
|
// Collect all triangles as (v0, v1, v2, normal).
|
|
struct Tri {
|
|
v: [[f64; 3]; 3],
|
|
n: [f32; 3],
|
|
}
|
|
|
|
let mut tris: Vec<Tri> = Vec::new();
|
|
for mesh in meshes {
|
|
let verts = &mesh.verts;
|
|
let normals = &mesh.normals;
|
|
let idx = &mesh.indices;
|
|
// Positions are stored as an f32 half plus a low residual so they stay
|
|
// precise at survey coordinates; write the sum, as the renderer uses.
|
|
let point = |i: usize| {
|
|
let high = verts[i];
|
|
let low = mesh.verts_low.get(i).copied().unwrap_or([0.0; 3]);
|
|
[0, 1, 2].map(|k| high[k] as f64 + low[k] as f64)
|
|
};
|
|
let n_tri = idx.len() / 3;
|
|
for t in 0..n_tri {
|
|
let i0 = idx[t * 3] as usize;
|
|
let i1 = idx[t * 3 + 1] as usize;
|
|
let i2 = idx[t * 3 + 2] as usize;
|
|
if i0 >= verts.len() || i1 >= verts.len() || i2 >= verts.len() {
|
|
continue;
|
|
}
|
|
let a = point(i0);
|
|
let b = point(i1);
|
|
let c = point(i2);
|
|
let n = if !normals.is_empty() && i0 < normals.len() {
|
|
normals[i0]
|
|
} else {
|
|
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
|
let nx = ab[1] * ac[2] - ab[2] * ac[1];
|
|
let ny = ab[2] * ac[0] - ab[0] * ac[2];
|
|
let nz = ab[0] * ac[1] - ab[1] * ac[0];
|
|
let len = (nx * nx + ny * ny + nz * nz).sqrt().max(f64::EPSILON);
|
|
[(nx / len) as f32, (ny / len) as f32, (nz / len) as f32]
|
|
};
|
|
tris.push(Tri { v: [a, b, c], n });
|
|
}
|
|
}
|
|
|
|
if tris.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// ── Emit STEP ─────────────────────────────────────────────────────────
|
|
// Entity ID counter (STEP ids start at #1).
|
|
let mut next_id: usize = 1;
|
|
let mut data = String::new();
|
|
|
|
// Closure to allocate the next ID.
|
|
let mut alloc = || {
|
|
let id = next_id;
|
|
next_id += 1;
|
|
id
|
|
};
|
|
|
|
// Collect face IDs for the shell.
|
|
let mut face_ids: Vec<usize> = Vec::with_capacity(tris.len());
|
|
|
|
for tri in &tris {
|
|
// Each triangle: 3 vertices, 3 edges, 1 face.
|
|
|
|
// Vertex points.
|
|
let vp: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Cartesian points for vertices.
|
|
let cp: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Line curves for edges.
|
|
let lc: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Direction refs for lines (reusing cp[0] as direction — simplified).
|
|
let dir: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Vertex-point refs.
|
|
let vpref: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Edge curves.
|
|
let ec: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Oriented edges.
|
|
let oe: [usize; 3] = [alloc(), alloc(), alloc()];
|
|
// Edge loop.
|
|
let el = alloc();
|
|
// Plane normal direction and axis placement.
|
|
let norm_dir = alloc();
|
|
let plane_ax = alloc();
|
|
let plane = alloc();
|
|
// Advanced face.
|
|
let face_id = alloc();
|
|
face_ids.push(face_id);
|
|
|
|
// Emit cartesian points.
|
|
for k in 0..3 {
|
|
let [x, y, z] = tri.v[k];
|
|
writeln!(
|
|
data,
|
|
"#{} = CARTESIAN_POINT('',({:.6},{:.6},{:.6}));",
|
|
cp[k], x, y, z
|
|
)
|
|
.ok();
|
|
writeln!(data, "#{} = VERTEX_POINT('',#{});", vpref[k], cp[k]).ok();
|
|
}
|
|
// Emit vertex points (binding).
|
|
for k in 0..3 {
|
|
writeln!(data, "#{} = VERTEX_POINT('',#{});", vp[k], cp[k]).ok();
|
|
// (duplicate of vpref; we keep vp[] to reference in edge curves)
|
|
let _ = vp[k]; // suppress unused warning
|
|
}
|
|
|
|
// Emit edge directions and line curves.
|
|
for k in 0..3 {
|
|
let k1 = (k + 1) % 3;
|
|
let [dx, dy, dz] = [
|
|
tri.v[k1][0] - tri.v[k][0],
|
|
tri.v[k1][1] - tri.v[k][1],
|
|
tri.v[k1][2] - tri.v[k][2],
|
|
];
|
|
let len = (dx * dx + dy * dy + dz * dz).sqrt().max(f64::EPSILON);
|
|
writeln!(
|
|
data,
|
|
"#{} = DIRECTION('',({:.6},{:.6},{:.6}));",
|
|
dir[k],
|
|
dx / len,
|
|
dy / len,
|
|
dz / len
|
|
)
|
|
.ok();
|
|
writeln!(
|
|
data,
|
|
"#{} = LINE('',#{},VECTOR('',#{},1.));",
|
|
lc[k], cp[k], dir[k]
|
|
)
|
|
.ok();
|
|
writeln!(
|
|
data,
|
|
"#{} = EDGE_CURVE('',#{},#{},#{},.T.);",
|
|
ec[k], vpref[k], vpref[k1], lc[k]
|
|
)
|
|
.ok();
|
|
writeln!(data, "#{} = ORIENTED_EDGE('',*,*,#{},.T.);", oe[k], ec[k]).ok();
|
|
}
|
|
|
|
// Edge loop and face normal.
|
|
writeln!(
|
|
data,
|
|
"#{} = EDGE_LOOP('',({},{},{}));",
|
|
el,
|
|
format!("#{}", oe[0]),
|
|
format!("#{}", oe[1]),
|
|
format!("#{}", oe[2])
|
|
)
|
|
.ok();
|
|
|
|
let [nx, ny, nz] = tri.n;
|
|
writeln!(
|
|
data,
|
|
"#{} = DIRECTION('',({:.6},{:.6},{:.6}));",
|
|
norm_dir, nx, ny, nz
|
|
)
|
|
.ok();
|
|
writeln!(
|
|
data,
|
|
"#{} = AXIS2_PLACEMENT_3D('',#{},#{},#{});",
|
|
plane_ax, cp[0], norm_dir, dir[0]
|
|
)
|
|
.ok();
|
|
writeln!(data, "#{} = PLANE('',#{});", plane, plane_ax).ok();
|
|
writeln!(
|
|
data,
|
|
"#{} = ADVANCED_FACE('',(FACE_BOUND('',#{},.T.)),#{},.T.);",
|
|
face_id, el, plane
|
|
)
|
|
.ok();
|
|
}
|
|
|
|
// Closed shell wrapping all faces.
|
|
let shell_id = alloc();
|
|
let face_list: String = face_ids
|
|
.iter()
|
|
.map(|id| format!("#{id}"))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
writeln!(data, "#{} = CLOSED_SHELL('',({face_list}));", shell_id).ok();
|
|
|
|
// Manifold solid B-rep.
|
|
let msb_id = alloc();
|
|
writeln!(
|
|
data,
|
|
"#{} = MANIFOLD_SOLID_BREP('OpenCADStudio_Solid',#{});",
|
|
msb_id, shell_id
|
|
)
|
|
.ok();
|
|
|
|
// Shape representation.
|
|
let sr_id = alloc();
|
|
let pu_id = alloc();
|
|
let gc_id = alloc();
|
|
writeln!(
|
|
data,
|
|
"#{} = (LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));",
|
|
pu_id
|
|
)
|
|
.ok();
|
|
writeln!(data, "#{} = GEOMETRIC_REPRESENTATION_CONTEXT(3);", gc_id).ok();
|
|
writeln!(
|
|
data,
|
|
"#{sr_id} = SHAPE_REPRESENTATION('OpenCADStudio_Shape',(#{}),#{gc_id});",
|
|
msb_id
|
|
)
|
|
.ok();
|
|
|
|
// ── Assemble file ─────────────────────────────────────────────────────
|
|
let ts = chrono_timestamp();
|
|
let file = format!(
|
|
"ISO-10303-21;\n\
|
|
HEADER;\n\
|
|
FILE_DESCRIPTION(('Open CAD Studio STEP export'),'2;1');\n\
|
|
FILE_NAME('{ts}','','',(''),'',' ',' ');\n\
|
|
FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));\n\
|
|
ENDSEC;\n\
|
|
DATA;\n\
|
|
{data}\
|
|
ENDSEC;\n\
|
|
END-ISO-10303-21;\n"
|
|
);
|
|
|
|
Some(file)
|
|
}
|
|
|
|
/// Returns an ISO 8601-like timestamp string for the STEP file header.
|
|
fn chrono_timestamp() -> String {
|
|
// Use seconds since Unix epoch for a simple timestamp without chrono dep.
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
// Format: YYYY-MM-DDTHH:MM:SS (approximate UTC from epoch seconds).
|
|
let s = secs;
|
|
let mins = s / 60;
|
|
let hours = mins / 60;
|
|
let days = hours / 24;
|
|
let hh = hours % 24;
|
|
let mm = mins % 60;
|
|
let ss = s % 60;
|
|
// Days since epoch → year/month/day (approximate, ignoring leap years).
|
|
let year = 1970 + days / 365;
|
|
let doy = days % 365;
|
|
let month = doy / 30 + 1;
|
|
let day = doy % 30 + 1;
|
|
format!("{year:04}-{month:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::build_step;
|
|
use crate::scene::model::mesh_model::MeshModel;
|
|
|
|
fn triangle(verts: Vec<[f32; 3]>, verts_low: Vec<[f32; 3]>) -> MeshModel {
|
|
MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals: Vec::new(),
|
|
indices: vec![0, 1, 2],
|
|
triangle_material_handles: Vec::new(),
|
|
triangle_colors: Vec::new(),
|
|
color: [1.0; 4],
|
|
selected: false,
|
|
}
|
|
}
|
|
|
|
/// Split an absolute position into the f32 pair the tessellator stores.
|
|
fn split(point: [f64; 3]) -> ([f32; 3], [f32; 3]) {
|
|
let high = point.map(|v| v as f32);
|
|
let low = [0, 1, 2].map(|i| (point[i] - high[i] as f64) as f32);
|
|
(high, low)
|
|
}
|
|
|
|
/// At survey coordinates the f32 half alone is 6 cm coarse: 500000.123
|
|
/// is stored as 500000.125 plus a -0.002 residual. The export has to add
|
|
/// the residual back, as the renderer does.
|
|
#[test]
|
|
fn large_coordinates_keep_their_low_residual() {
|
|
let (verts, verts_low): (Vec<_>, Vec<_>) = [
|
|
[500000.123, 500010.456, 0.0],
|
|
[500001.123, 500010.456, 0.0],
|
|
[500000.123, 500011.456, 0.0],
|
|
]
|
|
.into_iter()
|
|
.map(split)
|
|
.unzip();
|
|
let step = build_step(&[&triangle(verts, verts_low)]).expect("step");
|
|
assert!(
|
|
step.contains("CARTESIAN_POINT('',(500000.123000,500010.456000,0.000000));"),
|
|
"first vertex lost its residual:\n{}",
|
|
step.lines()
|
|
.find(|line| line.contains("CARTESIAN_POINT"))
|
|
.unwrap_or("")
|
|
);
|
|
}
|
|
|
|
/// Meshes without residuals (imported OBJ, legacy meshes) export as before.
|
|
#[test]
|
|
fn a_mesh_without_low_residuals_exports_its_positions() {
|
|
let mesh = triangle(
|
|
vec![[1.5, 2.0, 0.0], [2.5, 2.0, 0.0], [1.5, 3.0, 0.0]],
|
|
Vec::new(),
|
|
);
|
|
let step = build_step(&[&mesh]).expect("step");
|
|
assert!(step.contains("CARTESIAN_POINT('',(1.500000,2.000000,0.000000));"));
|
|
}
|
|
}
|