use rustc_hash::FxHashSet;
use crate::feature_pipeline::{
AddedSolid, Axis, EdgeRef, FaceRef, FeatureContext, FeatureResult, Frame, SketchProfile,
};
use crate::{AffineTransform, BooleanOperation, BooleanOptions, BrepSolid, NurbsCurve, Vec3, transform_brep};
pub fn rotate_half_turn_about_x(solid: BrepSolid) -> Result<BrepSolid, String> {
let half_turn = AffineTransform::new([
1.0, 0.0, 0.0, 0.0,
0.0, -1.0, 0.0, 0.0,
0.0, 0.0, -1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
])?;
transform_brep(&solid, half_turn, false)
}
pub fn collect_face_names(solid: &BrepSolid) -> Vec<(u64, String)> {
let mut names = Vec::new();
for shell in &solid.shells {
for face in &shell.faces {
if let Some(name) = &face.name {
names.push((face.id, name.clone()));
}
}
}
names
}
pub fn collect_edge_names(solid: &BrepSolid) -> Vec<(u64, String)> {
solid
.edges
.iter()
.filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name.clone())))
.collect()
}
pub fn stamp_face_role(face_name: &str, role: &str, op_type: &str) {
let mut record = serde_json::Map::new();
record.insert("faceRole".into(), serde_json::Value::String(role.into()));
record.insert(
"operationFaceType".into(),
serde_json::Value::String(op_type.into()),
);
crate::feature_pipeline::scene_metadata::merge_record(face_name, &record, true);
}
pub fn stamp_sweep_roles(solid: &BrepSolid, wall_suffix: &str, start_name: &str, end_name: &str) {
stamp_sweep_roles_multi(
solid,
wall_suffix,
std::slice::from_ref(&start_name.to_string()),
std::slice::from_ref(&end_name.to_string()),
);
}
pub fn stamp_sweep_roles_multi(
solid: &BrepSolid,
wall_suffix: &str,
start_names: &[String],
end_names: &[String],
) {
for face in solid.shells.iter().flat_map(|shell| shell.faces.iter()) {
let Some(name) = face.name.as_deref() else {
continue;
};
if start_names.iter().any(|start| start == name) {
stamp_face_role(name, "start_cap", "STARTCAP");
} else if end_names.iter().any(|end| end == name) {
stamp_face_role(name, "end_cap", "ENDCAP");
} else if name.ends_with(wall_suffix) {
stamp_face_role(name, "sidewall", "SIDEWALL");
}
}
}
pub struct CapNames {
pub per_loop: Vec<(String, String)>,
pub start_container: String,
pub end_container: String,
}
impl CapNames {
pub fn new(cap_base: &str, regions: &[Vec<crate::feature_pipeline::ProfileLoop>]) -> Self {
let per_loop = regions
.iter()
.map(|region| {
match region.first().and_then(|outer| outer.key()) {
Some(key) => (
format!("{cap_base}:{key}_START"),
format!("{cap_base}:{key}_END"),
),
None => (format!("{cap_base}_START"), format!("{cap_base}_END")),
}
})
.collect();
Self {
per_loop,
start_container: format!("{cap_base}_START"),
end_container: format!("{cap_base}_END"),
}
}
pub fn containers(&self) -> Vec<(String, Vec<String>)> {
[
(self.start_container.clone(), self.starts()),
(self.end_container.clone(), self.ends()),
]
.into_iter()
.filter(|(container, members)| members.as_slice() != [container.clone()])
.collect()
}
pub fn interior(&self, region_index: usize, segment: &str) -> (String, String) {
let (start, end) = &self.per_loop[region_index];
let start_base = start.strip_suffix("_START").unwrap_or(start);
let end_base = end.strip_suffix("_END").unwrap_or(end);
(
format!("{start_base}:{segment}_START"),
format!("{end_base}:{segment}_END"),
)
}
pub fn starts(&self) -> Vec<String> {
self.per_loop.iter().map(|(start, _)| start.clone()).collect()
}
pub fn ends(&self) -> Vec<String> {
self.per_loop.iter().map(|(_, end)| end.clone()).collect()
}
}
pub(super) fn parse_boolean_operation(operation: &str) -> Result<BooleanOperation, String> {
match operation {
"UNION" => Ok(BooleanOperation::Union),
"SUBTRACT" => Ok(BooleanOperation::Subtract),
"INTERSECT" => Ok(BooleanOperation::Intersect),
other => Err(format!("unsupported boolean operation '{other}'")),
}
}
struct BooleanParam {
operation: String,
targets: Vec<String>,
merge_coplanar_faces: bool,
}
fn read_boolean_param(ctx: &FeatureContext) -> BooleanParam {
let boolean = ctx.param("boolean");
let operation = boolean
.and_then(|value| value.get("operation"))
.and_then(|value| value.as_str())
.unwrap_or("NONE")
.to_uppercase();
let targets = boolean
.and_then(|value| value.get("targets"))
.and_then(|value| value.as_array())
.map(|array| {
array
.iter()
.filter_map(|entry| entry.as_str())
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect()
})
.unwrap_or_default();
let merge_coplanar_faces = boolean
.and_then(|value| value.get("mergeCoplanarFaces"))
.and_then(|value| value.as_bool())
.unwrap_or(true);
BooleanParam {
operation,
targets,
merge_coplanar_faces,
}
}
fn collect_edge_face_names(
solid: &BrepSolid,
) -> (std::collections::HashMap<u64, Vec<String>>, Vec<u64>) {
use std::collections::HashMap;
let mut edge_faces: HashMap<u64, Vec<String>> = HashMap::new();
let mut encounter_order: Vec<u64> = Vec::new();
for shell in &solid.shells {
for face in &shell.faces {
let face_name = face.name.clone().unwrap_or_default();
for loop_record in &face.loops {
for coedge in &loop_record.coedges {
let entry = edge_faces.entry(coedge.edge_id).or_insert_with(|| {
encounter_order.push(coedge.edge_id);
Vec::new()
});
if entry.len() < 2 && !entry.contains(&face_name) {
entry.push(face_name.clone());
}
}
}
}
}
(edge_faces, encounter_order)
}
pub fn stamp_derived_edge_names(solid: &mut BrepSolid) {
use std::collections::HashMap;
let (edge_faces, encounter_order) = collect_edge_face_names(solid);
let solid_name_fallback = "Solid".to_string();
let mut base_counts: HashMap<String, usize> = HashMap::new();
for edge_id in encounter_order {
let Some(edge) = solid.edges.iter_mut().find(|edge| edge.id == edge_id) else {
continue;
};
if edge.degenerate {
continue;
}
let mut faces: Vec<String> = edge_faces
.get(&edge_id)
.map(|list| list.iter().filter(|n| !n.is_empty()).cloned().collect())
.unwrap_or_default();
faces.sort();
let base = if faces.len() >= 2 {
format!("{}|{}", faces[0], faces[1])
} else {
format!(
"{}|BOUNDARY",
faces.first().unwrap_or(&solid_name_fallback)
)
};
let is_topology_name = edge.name.as_deref().is_none_or(|n| n.contains('|'));
if is_topology_name {
let index = base_counts.entry(base.clone()).or_insert(0);
edge.name = Some(format!("{base}[{index}]"));
*index += 1;
}
}
}
pub fn namespace_copy_names(solid: &mut BrepSolid, suffix: &str) {
for shell in &mut solid.shells {
for face in &mut shell.faces {
if let Some(name) = face.name.as_ref() {
let trimmed = name.trim();
if !trimmed.is_empty() {
face.name = Some(format!("{trimmed}::{suffix}"));
}
}
}
}
for edge in &mut solid.edges {
if let Some(name) = edge.name.as_ref() {
let trimmed = name.trim();
if !trimmed.is_empty() && !trimmed.contains('|') {
edge.name = Some(format!("{trimmed}::{suffix}"));
}
}
}
stamp_derived_edge_names(solid);
}
pub fn ensure_unique_face_names(solid: &mut BrepSolid) {
use std::collections::HashMap;
let mut counts: HashMap<String, usize> = HashMap::new();
for shell in &solid.shells {
for face in &shell.faces {
if let Some(name) = &face.name {
*counts.entry(name.clone()).or_insert(0) += 1;
}
}
}
let mut running: HashMap<String, usize> = HashMap::new();
for shell in &mut solid.shells {
for face in &mut shell.faces {
let Some(name) = face.name.clone() else { continue };
if counts.get(&name).copied().unwrap_or(0) > 1 {
let index = running.entry(name.clone()).or_insert(0);
face.name = Some(format!("{name}[{index}]"));
*index += 1;
}
}
}
}
pub fn register_added(base: BrepSolid, name: &str) -> AddedSolid {
register_added_grouped(base, name, &[])
}
pub fn register_added_grouped(
mut base: BrepSolid,
name: &str,
containers: &[(String, Vec<String>)],
) -> AddedSolid {
ensure_unique_face_names(&mut base);
stamp_derived_edge_names(&mut base);
let face_names = collect_face_names(&base);
let edge_names = collect_edge_names(&base);
let face_groups = collect_face_groups(&face_names, containers);
let mut edge_groups = collect_edge_group_aliases(&base, containers);
collect_derived_edge_bases(&edge_names, &mut edge_groups);
let handle = crate::register_solid_value(base);
AddedSolid {
handle,
name: name.to_string(),
face_names,
edge_names,
face_groups,
edge_groups,
}
}
fn collect_derived_edge_bases(
edge_names: &[(u64, String)],
groups: &mut Vec<(String, Vec<String>)>,
) {
use std::collections::HashSet;
let taken: HashSet<&str> = groups.iter().map(|(name, _)| name.as_str()).collect();
let exact: HashSet<&str> = edge_names.iter().map(|(_, name)| name.as_str()).collect();
let mut bases: Vec<(String, Vec<String>)> = Vec::new();
for (_, name) in edge_names {
let Some(base) = name.strip_suffix(']').and_then(|head| {
let at = head.rfind('[')?;
let (base, index) = (&head[..at], &head[at + 1..]);
(!index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()))
.then_some(base)
}) else {
continue;
};
if base.is_empty() || !base.contains('|') {
continue;
}
if taken.contains(base) || exact.contains(base) {
continue;
}
match bases.iter_mut().find(|(existing, _)| existing == base) {
Some((_, members)) => members.push(name.clone()),
None => bases.push((base.to_string(), vec![name.clone()])),
}
}
groups.append(&mut bases);
}
fn collect_face_groups(
face_names: &[(u64, String)],
containers: &[(String, Vec<String>)],
) -> Vec<(String, Vec<String>)> {
let mut groups = Vec::new();
for (container, members) in containers {
let live: Vec<String> = members
.iter()
.filter(|member| face_names.iter().any(|(_, name)| name == *member))
.cloned()
.collect();
if !live.is_empty() {
groups.push((container.clone(), live));
}
}
groups
}
fn collect_edge_group_aliases(
solid: &BrepSolid,
containers: &[(String, Vec<String>)],
) -> Vec<(String, Vec<String>)> {
use std::collections::HashMap;
if containers.is_empty() {
return Vec::new();
}
let mut container_of: HashMap<&str, &str> = HashMap::new();
for (container, members) in containers {
for member in members {
container_of.insert(member.as_str(), container.as_str());
}
}
let (edge_faces, encounter_order) = collect_edge_face_names(solid);
let solid_name_fallback = "Solid".to_string();
let mut base_counts: HashMap<String, usize> = HashMap::new();
let mut aliases: HashMap<String, Vec<String>> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for edge_id in encounter_order {
let Some(edge) = solid.edges.iter().find(|edge| edge.id == edge_id) else {
continue;
};
if edge.degenerate {
continue;
}
if !edge.name.as_deref().is_none_or(|name| name.contains('|')) {
continue;
}
let mut faces: Vec<String> = edge_faces
.get(&edge_id)
.map(|list| list.iter().filter(|name| !name.is_empty()).cloned().collect())
.unwrap_or_default();
let substituted = faces.iter().any(|name| container_of.contains_key(name.as_str()));
for face in &mut faces {
if let Some(container) = container_of.get(face.as_str()) {
*face = (*container).to_string();
}
}
faces.sort();
let base = if faces.len() >= 2 {
format!("{}|{}", faces[0], faces[1])
} else {
format!("{}|BOUNDARY", faces.first().unwrap_or(&solid_name_fallback))
};
let index = base_counts.entry(base.clone()).or_insert(0);
let alias = format!("{base}[{index}]");
*index += 1;
let Some(live) = edge.name.clone() else { continue };
if !substituted || live == alias {
continue;
}
if aliases.entry(alias.clone()).or_default().is_empty() {
order.push(alias.clone());
}
aliases.get_mut(&alias).expect("just inserted").push(live);
}
order
.into_iter()
.map(|alias| {
let ids = aliases.remove(&alias).unwrap_or_default();
(alias, ids)
})
.filter(|(_, ids)| !ids.is_empty())
.collect()
}
pub fn finalize_solids(ctx: &FeatureContext, bodies: Vec<(String, BrepSolid)>) -> FeatureResult {
let boolean = read_boolean_param(ctx);
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
let separate = |mut result: FeatureResult, bodies: Vec<(String, BrepSolid)>| {
for (name, solid) in bodies {
result.added.push(register_added(solid, &name));
}
result
};
if boolean.operation == "NONE" || boolean.targets.is_empty() {
return separate(result, bodies);
}
let operation = match parse_boolean_operation(&boolean.operation) {
Ok(operation) => operation,
Err(error) => return ctx.fail(error),
};
let resolved = resolve_solid_names(ctx.scene, &boolean.targets, &mut result.unresolved);
if resolved.is_empty() {
return separate(result, bodies);
}
let options = BooleanOptions {
merge_coplanar_faces: boolean.merge_coplanar_faces,
..BooleanOptions::default()
};
let mut bodies = bodies.into_iter();
let Some((_, first)) = bodies.next() else {
return result; };
let mut current = first;
for (_, target_handle) in &resolved {
let operand = crate::register_solid_value(current);
let folded = crate::with_two_registered_solids(*target_handle, operand, |target, op_solid| {
crate::boolean_operation(target, op_solid, operation, &options)
});
crate::free_registered_solid(operand);
match folded {
Ok(solid) => current = solid,
Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
}
}
for (_, body) in bodies {
match crate::boolean_operation(¤t, &body, operation, &options) {
Ok(solid) => current = solid,
Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
}
}
result.added.push(register_added(current, &resolved[0].0));
result.removed = resolved.into_iter().map(|(name, _)| name).collect();
result
}
pub fn finalize_solid(ctx: &FeatureContext, base: BrepSolid, base_name: &str) -> FeatureResult {
finalize_solid_grouped(ctx, base, base_name, &[])
}
pub fn finalize_solid_grouped(
ctx: &FeatureContext,
base: BrepSolid,
base_name: &str,
containers: &[(String, Vec<String>)],
) -> FeatureResult {
let boolean = read_boolean_param(ctx);
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
if boolean.operation == "NONE" || boolean.targets.is_empty() {
result.added.push(register_added_grouped(base, base_name, containers));
return result;
}
let operation = match parse_boolean_operation(&boolean.operation) {
Ok(operation) => operation,
Err(error) => return ctx.fail(error),
};
let resolved = resolve_solid_names(ctx.scene, &boolean.targets, &mut result.unresolved);
if resolved.is_empty() {
result.added.push(register_added_grouped(base, base_name, containers));
return result;
}
let options = BooleanOptions {
merge_coplanar_faces: boolean.merge_coplanar_faces,
..BooleanOptions::default()
};
let mut current = base;
for (_, target_handle) in &resolved {
let operand_handle = crate::register_solid_value(current);
let folded = crate::with_two_registered_solids(*target_handle, operand_handle, |target, operand| {
crate::boolean_operation(target, operand, operation, &options)
});
crate::free_registered_solid(operand_handle);
match folded {
Ok(solid) => current = solid,
Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
}
}
let result_name = resolved[0].0.clone();
result
.added
.push(register_added_grouped(current, &result_name, containers));
result.removed = resolved.into_iter().map(|(name, _)| name).collect();
result
}
pub struct BlendTarget {
pub handle: u32,
pub name: String,
pub edge_points: Vec<Vec3>,
pub edge_names: Vec<String>,
}
pub struct BlendSelection {
pub target: Option<BlendTarget>,
pub multi_solid: bool,
pub unresolved: Vec<String>,
}
enum ResolvedRef {
Edge(u64),
Face(u64),
}
pub(crate) fn reference_name(value: &serde_json::Value) -> Option<String> {
let raw = match value {
serde_json::Value::String(text) => Some(text.as_str()),
serde_json::Value::Object(map) => map.get("name").and_then(|value| value.as_str()),
_ => None,
}?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn edge_name_or_id(solid: &BrepSolid, edge_id: u64) -> String {
solid
.edges
.iter()
.find(|edge| edge.id == edge_id)
.and_then(|edge| edge.name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| format!("E{edge_id}"))
}
fn sample_edge_midpoint(solid: &BrepSolid, edge_id: u64) -> Result<Vec3, String> {
let edge = solid
.edges
.iter()
.find(|edge| edge.id == edge_id)
.ok_or_else(|| format!("dressup: edge {edge_id} not found on target solid"))?;
let mid = (edge.t0 + edge.t1) * 0.5;
edge.curve
.evaluate(mid)
.map_err(|error| format!("dressup: edge {edge_id} midpoint sample failed: {error}"))
}
fn face_boundary_edges(solid: &BrepSolid, face_id: u64) -> Result<Vec<u64>, String> {
let face = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| face.id == face_id)
.ok_or_else(|| format!("dressup: face {face_id} not found on target solid"))?;
let mut ids = Vec::new();
let mut seen = FxHashSet::default();
for loop_record in &face.loops {
for coedge in &loop_record.coedges {
let degenerate = solid
.edges
.iter()
.find(|edge| edge.id == coedge.edge_id)
.map(|edge| edge.degenerate)
.unwrap_or(false);
if degenerate {
continue;
}
if seen.insert(coedge.edge_id) {
ids.push(coedge.edge_id);
}
}
}
Ok(ids)
}
pub fn resolve_blend_selection(ctx: &FeatureContext) -> Result<BlendSelection, String> {
let names = reference_names(ctx.param("edges"));
let mut unresolved = Vec::new();
let mut resolved: Vec<(u32, ResolvedRef)> = Vec::new();
for name in names {
let edges = ctx.scene.resolve_edge_group(&name);
let faces = ctx.scene.resolve_face_group(&name);
if edges.is_empty() && faces.is_empty() {
unresolved.push(name);
continue;
}
for edge in edges {
resolved.push((edge.handle, ResolvedRef::Edge(edge.edge_id)));
}
for face in faces {
resolved.push((face.handle, ResolvedRef::Face(face.face_id)));
}
}
let mut handles: Vec<u32> = resolved.iter().map(|(handle, _)| *handle).collect();
handles.sort_unstable();
handles.dedup();
if handles.len() > 1 {
return Ok(BlendSelection {
target: None,
multi_solid: true,
unresolved,
});
}
let Some(&handle) = handles.first() else {
return Ok(BlendSelection {
target: None,
multi_solid: false,
unresolved,
});
};
let name = ctx
.scene
.solids
.iter()
.find(|(_, ®istered)| registered == handle)
.map(|(name, _)| name.clone())
.ok_or_else(|| format!("dressup: target handle {handle} has no scene name"))?;
let (edge_points, edge_names) = crate::with_registered_solid_str(handle, |solid| {
let mut seen = FxHashSet::default();
let mut points = Vec::new();
let mut names = Vec::new();
for (_, entity) in &resolved {
match entity {
ResolvedRef::Edge(edge_id) => {
if seen.insert(*edge_id) {
points.push(sample_edge_midpoint(solid, *edge_id)?);
names.push(edge_name_or_id(solid, *edge_id));
}
}
ResolvedRef::Face(face_id) => {
for edge_id in face_boundary_edges(solid, *face_id)? {
if seen.insert(edge_id) {
points.push(sample_edge_midpoint(solid, edge_id)?);
names.push(edge_name_or_id(solid, edge_id));
}
}
}
}
}
Ok((points, names))
})?;
Ok(BlendSelection {
target: Some(BlendTarget {
handle,
name,
edge_points,
edge_names,
}),
multi_solid: false,
unresolved,
})
}
pub fn blend_face_base(id: &str) -> String {
format!("{}:BLEND", if id.is_empty() { "F" } else { id })
}
pub fn blend_face_name(id: &str, edge_name: &str) -> String {
format!("{}:{}", blend_face_base(id), edge_name)
}
pub fn require_blend_direction(ctx: &FeatureContext, operation: &str) -> Result<(), String> {
let direction = ctx
.param("direction")
.and_then(|value| value.as_str())
.map(|text| text.trim().to_uppercase())
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "AUTO".to_string());
if direction != "AUTO" && direction != "INSET" {
return Err(format!(
"{operation} direction '{direction}' belonged to the removed legacy mesh pipeline (only AUTO/INSET exist)"
));
}
Ok(())
}
pub(super) fn number_or_default(ctx: &FeatureContext, key: &str, default: f64) -> f64 {
match ctx.param(key) {
None | Some(serde_json::Value::Null) => default,
Some(_) => match ctx.number(key) {
Ok(value) if value.is_finite() => value,
_ => default,
},
}
}
pub fn optional_number(ctx: &FeatureContext, key: &str) -> Result<Option<f64>, String> {
if ctx.param(key).is_some() {
ctx.number(key).map(Some)
} else {
Ok(None)
}
}
pub fn compose_trs_matrix(
translate: [f64; 3],
rotate_rad: [f64; 3],
scale: [f64; 3],
pivot: [f64; 3],
) -> [f64; 16] {
let (a, b) = (rotate_rad[0].cos(), rotate_rad[0].sin());
let (c, d) = (rotate_rad[1].cos(), rotate_rad[1].sin());
let (e, f) = (rotate_rad[2].cos(), rotate_rad[2].sin());
let (ae, af, be, bf) = (a * e, a * f, b * e, b * f);
let r = [
[c * e, -c * f, d],
[af + be * d, ae - bf * d, -b * c],
[bf - ae * d, be + af * d, a * c],
];
let mut rs = [[0.0f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
rs[i][j] = r[i][j] * scale[j];
}
}
let rs_pivot = [
rs[0][0] * pivot[0] + rs[0][1] * pivot[1] + rs[0][2] * pivot[2],
rs[1][0] * pivot[0] + rs[1][1] * pivot[1] + rs[1][2] * pivot[2],
rs[2][0] * pivot[0] + rs[2][1] * pivot[1] + rs[2][2] * pivot[2],
];
let tx = translate[0] + pivot[0] - rs_pivot[0];
let ty = translate[1] + pivot[1] - rs_pivot[1];
let tz = translate[2] + pivot[2] - rs_pivot[2];
[
rs[0][0], rs[0][1], rs[0][2], tx, rs[1][0], rs[1][1], rs[1][2], ty, rs[2][0], rs[2][1], rs[2][2], tz, 0.0, 0.0, 0.0, 1.0,
]
}
pub fn vec3_from_value(
env: &crate::feature_pipeline::Env,
value: Option<&serde_json::Value>,
key: &str,
default: [f64; 3],
) -> Result<[f64; 3], String> {
let Some(value) = value.filter(|value| !value.is_null()) else {
return Ok(default);
};
let component = |slot: Option<&serde_json::Value>, fallback: f64| -> Result<f64, String> {
match slot {
None | Some(serde_json::Value::Null) => Ok(fallback),
Some(serde_json::Value::Number(number)) => number
.as_f64()
.ok_or_else(|| format!("param `{key}` has a non-finite component")),
Some(serde_json::Value::String(source)) => {
let number = env
.eval(source)
.map_err(|error| format!("param `{key}`: {error}"))?;
if !number.is_finite() {
return Err(format!(
"param `{key}`: `{source}` evaluated to {number}"
));
}
Ok(number)
}
Some(other) => Err(format!(
"param `{key}` component must be a number or expression, found {other}"
)),
}
};
if let Some(array) = value.as_array() {
return Ok([
component(array.first(), default[0])?,
component(array.get(1), default[1])?,
component(array.get(2), default[2])?,
]);
}
if value.is_object() {
return Ok([
component(value.get("x"), default[0])?,
component(value.get("y"), default[1])?,
component(value.get("z"), default[2])?,
]);
}
Err(format!("param `{key}` must be a vec3 array or object"))
}
pub(super) fn resolve_solid_names(
scene: &crate::feature_pipeline::SceneMap,
names: &[String],
unresolved: &mut Vec<String>,
) -> Vec<(String, u32)> {
let mut resolved = Vec::new();
for name in names {
match scene.resolve_solid(name) {
Some(handle) => resolved.push((name.clone(), handle)),
None => unresolved.push(name.clone()),
}
}
resolved
}
pub(super) fn sheet_body_name(ctx: &FeatureContext) -> Option<String> {
first_reference_name(ctx.param("sheet")).or_else(|| {
let mut bodies = ctx.scene.solids.iter().filter_map(|(name, &handle)| {
crate::feature_pipeline::sheet_metal::get_tree(handle).map(|_| name.clone())
});
match (bodies.next(), bodies.next()) {
(Some(only), None) => Some(only),
_ => None,
}
})
}
pub(super) fn single_reference_name(value: Option<&serde_json::Value>) -> Option<String> {
let name = match value {
Some(serde_json::Value::String(text)) => Some(text.clone()),
Some(serde_json::Value::Array(array)) => array.iter().find_map(|entry| {
entry
.as_str()
.or_else(|| entry.get("name").and_then(|name| name.as_str()))
.map(str::to_string)
}),
Some(object @ serde_json::Value::Object(_)) => {
object.get("name").and_then(|name| name.as_str()).map(str::to_string)
}
_ => None,
}?;
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub fn first_reference_name(value: Option<&serde_json::Value>) -> Option<String> {
fn one(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Array(items) => items.iter().find_map(one),
other => reference_name(other),
}
}
value.and_then(one)
}
pub fn normalize_profile_alias(name: String) -> String {
match name.strip_suffix(":FACE") {
Some(base) => base.to_string(),
None => name,
}
}
pub(crate) fn reference_name_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(serde_json::Value::as_array)
.map(|items| items.iter().filter_map(reference_name).collect())
.unwrap_or_default()
}
pub fn reference_names(value: Option<&serde_json::Value>) -> Vec<String> {
match value {
Some(serde_json::Value::Array(items)) => items.iter().filter_map(reference_name).collect(),
Some(other) => reference_name(other).into_iter().collect(),
None => Vec::new(),
}
}
pub(super) fn unique_reference_names(value: Option<&serde_json::Value>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
reference_names(value)
.into_iter()
.filter(|name| seen.insert(name.clone()))
.collect()
}
pub fn consume_profile_sketch(ctx: &FeatureContext) -> bool {
!matches!(
ctx.param("consumeProfileSketch"),
Some(serde_json::Value::Bool(false))
)
}
pub fn consume_sketch(ctx: &FeatureContext, reference_name: &str, result: &mut FeatureResult) {
if !consume_profile_sketch(ctx) {
return;
}
consume_sketch_always(reference_name, result);
}
pub fn consume_sketch_always(reference_name: &str, result: &mut FeatureResult) {
let base = reference_name
.strip_suffix(":PROFILE")
.unwrap_or(reference_name)
.to_string();
if !result.removed.contains(&base) {
result.removed.push(base);
}
}
pub fn profile_loop_uv_curves(
profile: &crate::feature_pipeline::SketchProfile,
profile_loop: &crate::feature_pipeline::ProfileLoop,
) -> Result<Vec<NurbsCurve>, String> {
let mut curves = Vec::with_capacity(profile_loop.curves.len());
for curve in &profile_loop.curves {
let mut control_points = Vec::with_capacity(curve.control_points.len());
for cp in &curve.control_points {
let delta = cp.point()?.sub(profile.origin);
control_points.push(crate::Vec4::from_point(
Vec3::new(delta.dot(profile.x_axis), delta.dot(profile.y_axis), 0.0),
cp.w,
));
}
curves.push(NurbsCurve::new(
curve.degree,
curve.knots.clone(),
control_points,
)?);
}
Ok(curves)
}
pub fn resolve_path(ctx: &FeatureContext, name: &str) -> Result<Vec<NurbsCurve>, String> {
if let Some(chain) = ctx.scene.resolve_path(name) {
return Ok(chain.clone());
}
if let Some(edge) = ctx.scene.resolve_edge(name) {
return Ok(vec![edge_curve(edge)?]);
}
if let Some((owner, geometry)) = name.rsplit_once(':') {
if geometry.len() > 1 && geometry.starts_with('G') {
if let Some(chain) = ctx.scene.resolve_path(owner) {
return Ok(chain.clone());
}
}
}
Err(format!(
"path '{name}' not found (no sketch path or resident edge)"
))
}
#[derive(Debug, Clone)]
pub struct PathSegment {
pub name: String,
pub curve: NurbsCurve,
}
pub fn resolve_path_chain(
ctx: &FeatureContext,
names: &[String],
) -> Result<Vec<PathSegment>, String> {
let mut segments: Vec<PathSegment> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for reference in names {
let curves = resolve_path(ctx, reference)?;
let published = ctx.scene.resolve_path_segment_names(reference);
let single = curves.len() == 1;
for (index, curve) in curves.into_iter().enumerate() {
let name = published
.and_then(|names| names.get(index).cloned().flatten())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| {
if single {
reference.clone()
} else {
format!("{reference}[{index}]")
}
});
if seen.insert(name.clone()) {
segments.push(PathSegment { name, curve });
}
}
}
if segments.is_empty() {
return Err("path selection resolved to no curves".into());
}
chain_path_segments(segments)
}
fn path_endpoints(curve: &NurbsCurve) -> Result<(Vec3, Vec3), String> {
let [t0, t1] = curve.domain()?;
Ok((curve.evaluate(t0)?, curve.evaluate(t1)?))
}
fn chain_path_segments(segments: Vec<PathSegment>) -> Result<Vec<PathSegment>, String> {
if segments.len() < 2 {
return Ok(segments);
}
let ends: Vec<(Vec3, Vec3)> = segments
.iter()
.map(|segment| path_endpoints(&segment.curve))
.collect::<Result<_, _>>()?;
let scale = ends
.iter()
.flat_map(|(a, b)| [a, b])
.map(|point| point.sub(ends[0].0).length())
.fold(1.0_f64, f64::max);
let tolerance = 1e-5 * scale;
let joins = |a: Vec3, b: Vec3| a.sub(b).length() <= tolerance;
let mut used = vec![false; segments.len()];
used[0] = true;
let mut order: Vec<(usize, bool)> = vec![(0, false)];
let mut head = ends[0].0;
let mut tail = ends[0].1;
loop {
if order.len() > 1 && joins(tail, head) {
break;
}
if let Some((index, reversed, far)) = attach_path_segment(&ends, &used, tail, tolerance) {
used[index] = true;
order.push((index, reversed));
tail = far;
continue;
}
if let Some((index, reversed, far)) = attach_path_segment(&ends, &used, head, tolerance) {
used[index] = true;
order.insert(0, (index, !reversed));
head = far;
continue;
}
break;
}
let stranded: Vec<&str> = segments
.iter()
.zip(&used)
.filter(|(_, used)| !**used)
.map(|(segment, _)| segment.name.as_str())
.collect();
if !stranded.is_empty() {
return Err(format!(
"the selected path edges do not form ONE connected chain — {} joins neither \
end of the chain the other selections form (a sweep path must be a single \
head-to-tail run; select connected edges, or one branch at a time)",
stranded.join(", ")
));
}
let mut chained = Vec::with_capacity(segments.len());
let mut taken: Vec<Option<PathSegment>> = segments.into_iter().map(Some).collect();
for (index, reversed) in order {
let mut segment = taken[index].take().expect("each segment placed once");
if reversed {
segment.curve = segment.curve.reversed()?;
}
chained.push(segment);
}
Ok(chained)
}
fn attach_path_segment(
ends: &[(Vec3, Vec3)],
used: &[bool],
cursor: Vec3,
tolerance: f64,
) -> Option<(usize, bool, Vec3)> {
for (index, (start, end)) in ends.iter().enumerate() {
if used[index] {
continue;
}
if start.sub(cursor).length() <= tolerance {
return Some((index, false, *end));
}
if end.sub(cursor).length() <= tolerance {
return Some((index, true, *start));
}
}
None
}
pub fn translate_curves(curves: &[NurbsCurve], offset: Vec3) -> Result<Vec<NurbsCurve>, String> {
curves
.iter()
.map(|curve| translate_curve(curve, offset))
.collect()
}
pub fn sketch_plane_frame(ctx: &FeatureContext, names: &[String]) -> Option<Frame> {
for name in names {
if let Some(frame) = ctx.scene.resolve_frame(name) {
return Some(frame);
}
if let Some((owner, _)) = name.rsplit_once(':') {
if let Some(frame) = ctx.scene.resolve_frame(owner) {
return Some(frame);
}
}
}
None
}
pub fn sketch_base_name(ctx: &FeatureContext, name: &str) -> String {
if let Some((owner, tail)) = name.rsplit_once(':') {
if tail.len() > 1 && tail.starts_with('G') && ctx.scene.resolve_frame(owner).is_some() {
return owner.to_string();
}
}
name.to_string()
}
pub fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
let [start, end] = curve.domain()?;
let epsilon = (1e-9 * (end - start)).max(2e-9);
let mut result = curve.clone();
if t0 > start + epsilon && t0 < end - epsilon {
result = result.split(t0)?.1;
}
let domain = result.domain()?;
if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
result = result.split(t1)?.0;
}
Ok(result)
}
pub fn edge_curve(edge: EdgeRef) -> Result<NurbsCurve, String> {
crate::with_registered_solid_str(edge.handle, |solid| {
let record = solid
.edges
.iter()
.find(|candidate| candidate.id == edge.edge_id)
.ok_or_else(|| format!("edge {} not found on solid", edge.edge_id))?;
trimmed_curve(&record.curve, record.t0, record.t1)
})
}
pub fn edge_axis(edge: EdgeRef) -> Result<Axis, String> {
crate::with_registered_solid_str(edge.handle, |solid| {
let record = solid
.edges
.iter()
.find(|candidate| candidate.id == edge.edge_id)
.ok_or_else(|| format!("edge {} not found on solid", edge.edge_id))?;
let start = record.curve.evaluate(record.t0)?;
let end = record.curve.evaluate(record.t1)?;
let direction = end.sub(start).normalized()?;
Ok(Axis {
point: start,
direction,
})
})
}
pub fn translate_curve(curve: &NurbsCurve, offset: Vec3) -> Result<NurbsCurve, String> {
let mut control_points = Vec::with_capacity(curve.control_points.len());
for cp in &curve.control_points {
let cartesian = cp.point()?;
control_points.push(crate::Vec4::from_point(cartesian.add(offset), cp.w));
}
NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
}
pub struct HoleGroup {
pub index: usize,
pub depth: usize,
pub children: Vec<HoleGroup>,
}
pub fn hole_nesting(
profile: &SketchProfile,
region: &[crate::feature_pipeline::ProfileLoop],
) -> Result<Vec<HoleGroup>, String> {
let count = region.len().saturating_sub(1);
if count == 0 {
return Ok(Vec::new());
}
let mut polygons: Vec<Vec<[f64; 2]>> = Vec::with_capacity(count);
for hole in ®ion[1..] {
let mut polygon = Vec::new();
for curve in &hole.curves {
let [t0, t1] = curve.domain()?;
for step in 0..8 {
let t = t0 + (t1 - t0) * (step as f64 / 8.0);
let point = curve.evaluate(t)?;
let delta = point.sub(profile.origin);
polygon.push([delta.dot(profile.x_axis), delta.dot(profile.y_axis)]);
}
}
polygons.push(polygon);
}
let mut contains = vec![vec![false; count]; count];
for i in 0..count {
let Some(representative) = polygons[i].first().copied() else {
continue;
};
for j in 0..count {
if i != j && point_in_polygon_uv(representative, &polygons[j]) {
contains[i][j] = true;
}
}
}
let depths: Vec<usize> = (0..count)
.map(|i| contains[i].iter().filter(|inside| **inside).count())
.collect();
Ok((0..count)
.filter(|&i| depths[i] == 0)
.map(|i| hole_group(i, &contains, &depths))
.collect())
}
fn hole_group(i: usize, contains: &[Vec<bool>], depths: &[usize]) -> HoleGroup {
let children = (0..depths.len())
.filter(|&k| contains[k][i] && depths[k] == depths[i] + 1)
.map(|k| hole_group(k, contains, depths))
.collect();
HoleGroup {
index: i + 1,
depth: depths[i],
children,
}
}
fn point_in_polygon_uv(point: [f64; 2], polygon: &[[f64; 2]]) -> bool {
let count = polygon.len();
if count < 3 {
return false;
}
let mut inside = false;
let mut j = count - 1;
for i in 0..count {
let pi = polygon[i];
let pj = polygon[j];
let intersects = (pi[1] > point[1]) != (pj[1] > point[1])
&& point[0] < (pj[0] - pi[0]) * (point[1] - pi[1]) / (pj[1] - pi[1]) + pi[0];
if intersects {
inside = !inside;
}
j = i;
}
inside
}
pub fn name_hole_cutter_faces(cutter: &mut BrepSolid, id: &str, key: &str, segment: Option<&str>) {
let name = hole_face_name(id, key, segment);
for face in cutter
.shells
.iter_mut()
.flat_map(|shell| shell.faces.iter_mut())
{
if face.name.is_none() {
face.name = Some(name.clone());
}
}
}
pub fn hole_face_name(id: &str, key: &str, segment: Option<&str>) -> String {
match segment {
Some(segment) => format!("{id}:HOLE:{key}:{segment}"),
None => format!("{id}:HOLE:{key}"),
}
}
pub fn subtract_solid(body: BrepSolid, cutter: BrepSolid) -> Result<BrepSolid, String> {
let options = BooleanOptions {
merge_coplanar_faces: true,
..BooleanOptions::default()
};
let body_handle = crate::register_solid_value(body);
let cutter_handle = crate::register_solid_value(cutter);
let cut = crate::with_two_registered_solids(body_handle, cutter_handle, |body, tool| {
crate::boolean_operation(body, tool, BooleanOperation::Subtract, &options)
});
crate::free_registered_solid(body_handle);
crate::free_registered_solid(cutter_handle);
cut.map_err(String::from)
}
pub fn hole_prism(
hole_curves: &[NurbsCurve],
direction: Vec3,
distance: f64,
depth: usize,
) -> Result<BrepSolid, String> {
let dir = direction.normalized()?;
let span = distance.abs();
let margin = span.max(1.0) * (depth as f64 + 1.0);
let start_t = distance.min(0.0) - margin;
let length = span + 2.0 * margin;
let offset = dir.scale(start_t);
let mut curves = Vec::with_capacity(hole_curves.len());
for curve in hole_curves {
curves.push(translate_curve(curve, offset)?);
}
crate::extrude_profile_brep(&curves, dir, length)
}
pub fn subtract_hole_prism(
feature: &str,
body: BrepSolid,
hole_curves: &[NurbsCurve],
direction: Vec3,
distance: f64,
id: &str,
key: &str,
) -> Result<BrepSolid, String> {
if hole_curves.len() < 2 {
return Ok(body);
}
let mut cutter = hole_prism(hole_curves, direction, distance, 0)?;
name_hole_cutter_faces(&mut cutter, id, key, None);
subtract_solid(body, cutter)
.map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))
}
pub fn subtract_region_holes(
mut body: BrepSolid,
profile: &SketchProfile,
region: &[crate::feature_pipeline::ProfileLoop],
feature: &str,
id: &str,
segment: Option<&str>,
build: &mut dyn FnMut(usize, usize) -> Result<BrepSolid, String>,
) -> Result<BrepSolid, String> {
for group in hole_nesting(profile, region)? {
let key = hole_key(®ion[group.index], group.index);
let Some(cutter) = hole_cutter(region, &group, id, segment, build)
.map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))?
else {
continue;
};
body = subtract_solid(body, cutter)
.map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))?;
}
Ok(body)
}
pub fn hole_key(hole: &crate::feature_pipeline::ProfileLoop, index: usize) -> String {
hole.key().unwrap_or_else(|| index.to_string())
}
fn hole_cutter(
region: &[crate::feature_pipeline::ProfileLoop],
group: &HoleGroup,
id: &str,
segment: Option<&str>,
build: &mut dyn FnMut(usize, usize) -> Result<BrepSolid, String>,
) -> Result<Option<BrepSolid>, String> {
if region[group.index].curves.len() < 2 {
return Ok(None);
}
let mut cutter = build(group.index, group.depth)?;
name_hole_cutter_faces(
&mut cutter,
id,
&hole_key(®ion[group.index], group.index),
segment,
);
for child in &group.children {
let Some(child_cutter) = hole_cutter(region, child, id, segment, build)? else {
continue;
};
cutter = subtract_solid(cutter, child_cutter).map_err(|error| {
format!("island {} carve from hole {} failed: {error}", child.index, group.index)
})?;
}
Ok(Some(cutter))
}
pub enum PlaneLikeRef {
Face(FaceRef),
Frame(Frame),
}
impl PlaneLikeRef {
pub fn point_normal(&self) -> Result<(Vec3, Vec3), String> {
match self {
PlaneLikeRef::Frame(frame) => Ok((frame.origin, frame.z_axis)),
PlaneLikeRef::Face(face) => face_point_normal(*face),
}
}
pub fn frame(&self) -> Result<Frame, String> {
match self {
PlaneLikeRef::Frame(frame) => Ok(*frame),
PlaneLikeRef::Face(face) => face_frame(*face),
}
}
}
pub fn resolve_plane_reference(ctx: &FeatureContext, name: &str) -> Option<PlaneLikeRef> {
if let Some(face) = ctx.scene.resolve_face(name) {
return Some(PlaneLikeRef::Face(face));
}
ctx.scene.resolve_frame(name).map(PlaneLikeRef::Frame)
}
pub fn face_point_normal(face: FaceRef) -> Result<(Vec3, Vec3), String> {
crate::with_registered_solid_str(face.handle, |solid| {
for shell in &solid.shells {
for record in &shell.faces {
if record.id != face.face_id {
continue;
}
let [u0, u1] = record.surface.domain_u()?;
let [v0, v1] = record.surface.domain_v()?;
let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
let point = record.surface.evaluate(um, vm)?;
let mut normal = record.surface.normal(um, vm)?;
if !record.same_sense {
normal = normal.scale(-1.0);
}
return Ok((point, normal));
}
}
Err(format!("face {} not found on solid", face.face_id))
})
}
pub fn face_boundary_points(
solid: &BrepSolid,
record: &crate::FaceRecord,
) -> Result<Vec<Vec3>, String> {
let mut points = Vec::new();
for loop_record in &record.loops {
for coedge in &loop_record.coedges {
let Some(edge) = solid.edges.iter().find(|e| e.id == coedge.edge_id) else {
continue;
};
if edge.degenerate {
continue;
}
for step in 0..=4 {
let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 4.0);
points.push(edge.curve.evaluate(t)?);
}
}
}
Ok(points)
}
pub fn bounds_of(points: &[Vec3]) -> Option<(Vec3, Vec3)> {
let mut iter = points.iter();
let first = *iter.next()?;
let (mut min, mut max) = (first, first);
for point in iter {
min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
}
Some((min, max))
}
pub fn face_frame(face: FaceRef) -> Result<Frame, String> {
crate::with_registered_solid_str(face.handle, |solid| {
let record = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|candidate| candidate.id == face.face_id)
.ok_or_else(|| format!("face {} not found on solid", face.face_id))?;
let [u0, u1] = record.surface.domain_u()?;
let [v0, v1] = record.surface.domain_v()?;
let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
let center_normal = record.surface.normal(um, vm)?;
for (u, v) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)] {
let normal = record.surface.normal(u, v)?;
if normal.dot(center_normal).abs() < 1.0 - 1e-6 {
return Err(
"sketch/plane on a non-planar face is not yet migrated to the Rust pipeline"
.into(),
);
}
}
let normal = if record.same_sense {
center_normal
} else {
center_normal.scale(-1.0)
};
let boundary = face_boundary_points(solid, record)?;
let plane_point = record.surface.evaluate(um, vm)?;
let center = match bounds_of(&boundary) {
Some((min, max)) => min.add(max).scale(0.5),
None => plane_point,
};
let unit = normal.normalized()?;
let signed = center.sub(plane_point).dot(unit);
let origin = center.sub(unit.scale(signed));
Frame::from_origin_normal(origin, normal)
})
}
pub fn union_region_solids(solids: Vec<BrepSolid>) -> Result<BrepSolid, String> {
union_solids_keeping(solids, &[])
}
pub fn union_solids_keeping(
solids: Vec<BrepSolid>,
keep_unmerged: &[String],
) -> Result<BrepSolid, String> {
let mut iter = solids.into_iter();
let mut current = iter
.next()
.ok_or("profile produced no region solids to union")?;
let options = BooleanOptions {
merge_coplanar_faces: true,
keep_unmerged_name_substrs: keep_unmerged.to_vec(),
..BooleanOptions::default()
};
for next in iter {
let a = crate::register_solid_value(current);
let b = crate::register_solid_value(next);
let unioned = crate::with_two_registered_solids(a, b, |left, right| {
crate::boolean_operation(left, right, BooleanOperation::Union, &options)
});
crate::free_registered_solid(a);
crate::free_registered_solid(b);
current = unioned.map_err(|error| format!("region union failed: {error}"))?;
}
Ok(current)
}
pub(super) fn primitive_transform_schema() -> serde_json::Value {
serde_json::json!({
"type": "transform",
"default_value": {
"position": [
0,
0,
0
],
"rotationEuler": [
0,
0,
0
],
"scale": [
1,
1,
1
]
},
"referenceSelectionFilter": [
"FACE",
"EDGE",
"VERTEX",
"PLANE",
"DATUM"
],
"referenceLabel": "Start Reference",
"referencePlaceholder": "Select point, edge, or face…",
"hint": "Select a start reference, then position, rotate, and scale the solid relative to it."
})
}
pub(super) fn optional_boolean_schema() -> serde_json::Value {
serde_json::json!({
"type": "boolean_operation",
"default_value": {
"targets": [],
"operation": "NONE",
"mergeCoplanarFaces": true
},
"hint": "Optional boolean operation with selected solids"
})
}