use std::collections::BTreeMap;
use serde::Deserialize;
use super::constraints;
use super::{AssemblyState, ConstraintEntry};
use crate::feature_pipeline::features::common::{bounds_of, face_boundary_points};
use crate::feature_pipeline::SceneMap;
use crate::{resolve_face_selection, SelectionGeometry, Vec3};
pub struct InferenceRule {
pub type_id: &'static str,
pub detects: &'static str,
pub default_on: bool,
}
pub const INFERENCE_RULES: [InferenceRule; 3] = [
InferenceRule {
type_id: "concentric",
detects: "Cylindrical, conical or toroidal faces on two parts that share \
one centreline — a shaft in a bore, a bolt through a stack. \
Neither the radii nor the distance along the axis need match.",
default_on: true,
},
InferenceRule {
type_id: "touch_align",
detects: "Planar faces on two parts that lie in one plane, face each \
other, and overlap — parts resting or bolted flat together.",
default_on: true,
},
InferenceRule {
type_id: "coincident",
detects: "Spherical faces sharing a centre, and corners of two already \
touching parts that sit on the same point.",
default_on: true,
},
];
pub fn inferable_types_json() -> serde_json::Value {
serde_json::Value::Array(
INFERENCE_RULES
.iter()
.filter_map(|rule| {
let def = constraints::constraint_type(rule.type_id)?;
Some(serde_json::json!({
"type": def.type_id,
"label": def.label,
"icon": def.icon,
"longName": def.long_name,
"detects": rule.detects,
"defaultOn": rule.default_on,
}))
})
.collect(),
)
}
fn default_tolerance() -> f64 {
1e-3
}
fn default_angle_tolerance_deg() -> f64 {
0.1
}
fn default_max_pairs() -> usize {
4096
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct InferOptions {
pub types: Option<Vec<String>>,
pub tolerance: f64,
pub angle_tolerance_deg: f64,
pub max_pairs: usize,
}
impl Default for InferOptions {
fn default() -> Self {
Self {
types: None,
tolerance: default_tolerance(),
angle_tolerance_deg: default_angle_tolerance_deg(),
max_pairs: default_max_pairs(),
}
}
}
impl InferOptions {
pub fn parse(json: &str) -> Result<Self, String> {
let trimmed = json.trim();
if trimmed.is_empty() || trimmed == "null" {
return Ok(Self::default());
}
let mut options: Self =
serde_json::from_str(trimmed).map_err(|error| format!("infer options: {error}"))?;
if !(options.tolerance > 0.0) {
options.tolerance = default_tolerance();
}
if !(options.angle_tolerance_deg > 0.0) {
options.angle_tolerance_deg = default_angle_tolerance_deg();
}
Ok(options)
}
fn enabled(&self, type_id: &str) -> bool {
match &self.types {
Some(list) => list.iter().any(|entry| entry == type_id),
None => INFERENCE_RULES.iter().any(|rule| rule.type_id == type_id),
}
}
fn dot_slack(&self) -> f64 {
1.0 - self.angle_tolerance_deg.to_radians().cos()
}
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub type_id: &'static str,
pub elements: [String; 2],
pub components: [String; 2],
pub detail: String,
score: f64,
}
impl Candidate {
pub fn params(&self) -> serde_json::Value {
serde_json::json!({
"elements": [self.elements[0].clone(), self.elements[1].clone()],
})
}
fn row(&self) -> serde_json::Value {
serde_json::json!({
"type": self.type_id,
"elements": [self.elements[0].clone(), self.elements[1].clone()],
"components": [self.components[0].clone(), self.components[1].clone()],
"detail": self.detail,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Suppressed {
Redundant,
PairLocked,
}
impl Suppressed {
fn word(self) -> &'static str {
match self {
Suppressed::Redundant => "redundant",
Suppressed::PairLocked => "pair-fully-constrained",
}
}
fn explanation(self) -> &'static str {
match self {
Suppressed::Redundant => {
"the constraints already accepted for this pair hold it the same way"
}
Suppressed::PairLocked => "this pair already has no freedom left to remove",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Gates {
pub planes_apart: usize,
pub nearest_plane_gap: Option<f64>,
pub same_facing: usize,
pub no_overlap: usize,
pub axes_apart: usize,
pub nearest_axis_gap: Option<f64>,
pub coaxial_but_apart: usize,
pub no_extent: usize,
pub also_on_carrier: usize,
}
impl Gates {
fn note_plane_gap(&mut self, gap: f64) {
self.planes_apart += 1;
self.nearest_plane_gap = Some(match self.nearest_plane_gap {
Some(best) => best.min(gap),
None => gap,
});
}
fn note_axis_gap(&mut self, gap: f64) {
self.axes_apart += 1;
self.nearest_axis_gap = Some(match self.nearest_axis_gap {
Some(best) => best.min(gap),
None => gap,
});
}
fn json(&self) -> serde_json::Value {
serde_json::json!({
"planesApart": self.planes_apart,
"nearestPlaneGap": self.nearest_plane_gap,
"sameFacing": self.same_facing,
"noOverlap": self.no_overlap,
"axesApart": self.axes_apart,
"nearestAxisGap": self.nearest_axis_gap,
"coaxialButApart": self.coaxial_but_apart,
"noExtent": self.no_extent,
"alsoOnCarrier": self.also_on_carrier,
})
}
}
#[derive(Debug, Default, Clone)]
pub struct InferScan {
pub candidates: Vec<Candidate>,
pub suppressed: Vec<(Candidate, Suppressed)>,
pub component_count: usize,
pub pairs_considered: usize,
pub pairs_apart: usize,
pub pairs_constrained: usize,
pub pairs_skipped: usize,
pub faces_scanned: usize,
pub gates: Gates,
pub warnings: Vec<String>,
}
impl InferScan {
pub fn by_type(&self) -> serde_json::Value {
let mut counts = serde_json::Map::new();
for rule in INFERENCE_RULES.iter() {
let found = self
.candidates
.iter()
.filter(|candidate| candidate.type_id == rule.type_id)
.count();
counts.insert(rule.type_id.to_string(), serde_json::json!(found));
}
serde_json::Value::Object(counts)
}
pub fn report(&self) -> serde_json::Value {
serde_json::json!({
"ok": true,
"componentCount": self.component_count,
"pairsConsidered": self.pairs_considered,
"pairsApart": self.pairs_apart,
"pairsAlreadyConstrained": self.pairs_constrained,
"pairsOverBudget": self.pairs_skipped,
"facesScanned": self.faces_scanned,
"byType": self.by_type(),
"candidates": self
.candidates
.iter()
.map(Candidate::row)
.collect::<Vec<_>>(),
"suppressed": self
.suppressed
.iter()
.map(|(candidate, why)| {
let mut row = candidate.row();
let object = row.as_object_mut().expect("row is an object");
object.insert("reason".into(), serde_json::json!(why.word()));
object.insert("why".into(), serde_json::json!(why.explanation()));
row
})
.collect::<Vec<_>>(),
"gates": self.gates.json(),
"warnings": self.warnings.clone(),
})
}
}
struct FaceGeom {
name: String,
geometry: SelectionGeometry,
points: Vec<Vec3>,
}
struct Carrier {
direction: Vec3,
foot: Vec3,
faces: Vec<usize>,
}
struct VertexGeom {
reference: String,
world: Vec3,
}
struct ComponentGeom {
id: String,
faces: Vec<FaceGeom>,
planes: Vec<Carrier>,
axes: Vec<Carrier>,
spheres: Vec<usize>,
vertices: Vec<VertexGeom>,
min: Vec3,
max: Vec3,
}
fn loop_vertex_points(solid: &crate::BrepSolid, record: &crate::FaceRecord) -> Vec<Vec3> {
let mut points = Vec::new();
for loop_record in &record.loops {
for coedge in &loop_record.coedges {
let Some(edge) = solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
continue;
};
for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
if let Some(vertex) = solid.vertices.iter().find(|v| v.id == vertex_id) {
points.push(vertex.point);
}
}
}
}
points
}
fn canonical(direction: Vec3) -> Vec3 {
let components = [direction.x, direction.y, direction.z];
let mut dominant = 0;
for (index, value) in components.iter().enumerate() {
if value.abs() > components[dominant].abs() {
dominant = index;
}
}
if components[dominant] < 0.0 {
direction.scale(-1.0)
} else {
direction
}
}
fn group_into(
groups: &mut Vec<Carrier>,
direction: Vec3,
foot: Vec3,
face: usize,
linear_tol: f64,
dot_slack: f64,
) {
for group in groups.iter_mut() {
if group.direction.dot(direction) > 1.0 - dot_slack
&& group.foot.sub(foot).length() <= linear_tol
{
group.faces.push(face);
return;
}
}
groups.push(Carrier {
direction,
foot,
faces: vec![face],
});
}
fn component_geometry(
scene: &SceneMap,
id: &str,
options: &InferOptions,
scan: &mut InferScan,
) -> Option<ComponentGeom> {
let record = scene.components.get(id)?;
let inverse = match record.transform.rigid_inverse() {
Ok(inverse) => inverse,
Err(error) => {
scan.warnings
.push(format!("{id}: non-rigid pose, skipped ({error})"));
return None;
}
};
let mut faces: Vec<FaceGeom> = Vec::new();
let mut vertices: Vec<VertexGeom> = Vec::new();
for (solid_name, handle) in scene.component_solids(id) {
let read = crate::with_registered_solid_str(handle, |solid| {
let mut out = Vec::new();
for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
let Some(name) = face.name.clone() else {
continue; };
let Ok(geometry) = resolve_face_selection(solid, face.id) else {
continue; };
let mut points = face_boundary_points(solid, face).unwrap_or_default();
if points.is_empty() {
points = loop_vertex_points(solid, face);
}
out.push(FaceGeom {
name,
geometry,
points,
});
}
let corners: Vec<Vec3> = solid.vertices.iter().map(|vertex| vertex.point).collect();
Ok((out, corners))
});
match read {
Ok((solid_faces, corners)) => {
faces.extend(solid_faces);
for world in corners {
let local = inverse.point(world);
vertices.push(VertexGeom {
reference: format!("{solid_name}@{},{},{}", local.x, local.y, local.z),
world,
});
}
}
Err(error) => scan
.warnings
.push(format!("{id}: member '{solid_name}' unreadable ({error})")),
}
}
if faces.is_empty() {
return None;
}
scan.faces_scanned += faces.len();
let mut planes = Vec::new();
let mut axes = Vec::new();
let mut spheres = Vec::new();
for (index, face) in faces.iter().enumerate() {
match face.geometry {
SelectionGeometry::Plane { origin, normal } => {
let Ok(unit) = normal.normalized() else { continue };
let direction = canonical(unit);
let foot = direction.scale(origin.dot(direction));
group_into(
&mut planes,
direction,
foot,
index,
options.tolerance,
options.dot_slack(),
);
}
SelectionGeometry::Axis {
origin, direction, ..
} => {
let Ok(unit) = direction.normalized() else { continue };
let axis = canonical(unit);
let foot = origin.sub(axis.scale(origin.dot(axis)));
group_into(
&mut axes,
axis,
foot,
index,
options.tolerance,
options.dot_slack(),
);
}
SelectionGeometry::Sphere { .. } => spheres.push(index),
_ => {}
}
}
let cloud: Vec<Vec3> = faces
.iter()
.flat_map(|face| face.points.iter().copied())
.chain(faces.iter().map(|face| face.geometry.representative_point()))
.collect();
let (min, max) = bounds_of(&cloud)?;
Some(ComponentGeom {
id: id.to_string(),
faces,
planes,
axes,
spheres,
vertices,
min,
max,
})
}
#[derive(Default)]
struct PairDof {
rotation: Vec<Vec3>,
translation: Vec<Vec3>,
points: Vec<Vec3>,
}
fn span_push(span: &mut Vec<Vec3>, candidate: Vec3) -> bool {
let mut residual = candidate;
for basis in span.iter() {
residual = residual.sub(basis.scale(residual.dot(*basis)));
}
if residual.length() <= 1e-6 {
return false;
}
match residual.normalized() {
Ok(unit) => {
span.push(unit);
true
}
Err(_) => false,
}
}
impl PairDof {
fn removed(&self) -> usize {
let rotational = match self.rotation.len() {
0 => 0,
1 => 2,
_ => 3,
};
rotational + self.translation.len()
}
fn locked(&self) -> bool {
self.removed() >= 6
}
fn accept(&mut self, geometry: &CandidateDof) -> bool {
let before = self.removed();
let mut folded = PairDof {
rotation: self.rotation.clone(),
translation: self.translation.clone(),
points: self.points.clone(),
};
for direction in &geometry.rotation {
span_push(&mut folded.rotation, *direction);
}
for direction in &geometry.translation {
span_push(&mut folded.translation, *direction);
}
if let Some(point) = geometry.point {
for existing in &folded.points {
let chord = point.sub(*existing);
if chord.length() > 1e-6 {
span_push(&mut folded.rotation, chord);
}
}
folded.points.push(point);
}
if folded.removed() <= before {
return false;
}
*self = folded;
true
}
}
struct CandidateDof {
rotation: Vec<Vec3>,
translation: Vec<Vec3>,
point: Option<Vec3>,
}
fn constrained_pairs(state: &AssemblyState, scene: &SceneMap) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for entry in &state.constraints {
let mut owners: Vec<String> = entry
.elements()
.iter()
.filter_map(|element| scene.owning_component(element).map(|record| record.id.clone()))
.collect();
owners.sort();
owners.dedup();
if owners.len() == 2 {
pairs.push((owners[0].clone(), owners[1].clone()));
}
}
pairs
}
fn boxes_touch(a: &ComponentGeom, b: &ComponentGeom, slack: f64) -> bool {
a.min.x - slack <= b.max.x
&& b.min.x - slack <= a.max.x
&& a.min.y - slack <= b.max.y
&& b.min.y - slack <= a.max.y
&& a.min.z - slack <= b.max.z
&& b.min.z - slack <= a.max.z
}
fn prefilter_slack(a: &ComponentGeom, b: &ComponentGeom, tolerance: f64) -> f64 {
let diagonal = |component: &ComponentGeom| component.max.sub(component.min).length();
tolerance.max(0.01 * diagonal(a).min(diagonal(b)))
}
fn extent_along(points: &[Vec3], direction: Vec3) -> Option<(f64, f64)> {
let mut iter = points.iter().map(|point| point.dot(direction));
let first = iter.next()?;
let (mut low, mut high) = (first, first);
for value in iter {
low = low.min(value);
high = high.max(value);
}
Some((low, high))
}
fn overlap(a: (f64, f64), b: (f64, f64)) -> f64 {
a.1.min(b.1) - a.0.max(b.0)
}
fn measure(value: f64) -> String {
let rounded = (value * 1000.0).round() / 1000.0 + 0.0;
format!("{rounded}")
}
pub fn scan(state: &AssemblyState, scene: &SceneMap, options: &InferOptions) -> InferScan {
let mut result = InferScan::default();
let ids: Vec<String> = scene.components.keys().cloned().collect();
result.component_count = ids.len();
if ids.len() < 2 {
return result;
}
if !scene.components.values().any(|record| record.fixed) {
result.warnings.push(
"no component is grounded — inferred constraints will position parts relative to one \
another, but the assembly as a whole is still free to move"
.to_string(),
);
}
let mut geometry: BTreeMap<String, ComponentGeom> = BTreeMap::new();
for id in &ids {
if let Some(component) = component_geometry(scene, id, options, &mut result) {
geometry.insert(id.clone(), component);
}
}
let existing = constrained_pairs(state, scene);
let present: Vec<String> = geometry.keys().cloned().collect();
for (index, first) in present.iter().enumerate() {
for second in present.iter().skip(index + 1) {
if existing
.iter()
.any(|(a, b)| a == first && b == second)
{
result.pairs_constrained += 1;
continue;
}
let (Some(a), Some(b)) = (geometry.get(first), geometry.get(second)) else {
continue;
};
let touching = boxes_touch(a, b, prefilter_slack(a, b, options.tolerance));
if !touching {
result.pairs_apart += 1;
if !options.enabled("concentric") {
continue;
}
}
if result.pairs_considered >= options.max_pairs {
result.pairs_skipped += 1;
continue;
}
result.pairs_considered += 1;
let (accepted, suppressed) =
pair_candidates(a, b, options, &mut result.gates, touching);
result.candidates.extend(accepted);
result.suppressed.extend(suppressed);
}
}
result
}
fn pair_candidates(
a: &ComponentGeom,
b: &ComponentGeom,
options: &InferOptions,
gates: &mut Gates,
touching: bool,
) -> (Vec<Candidate>, Vec<(Candidate, Suppressed)>) {
let mut proposals: Vec<(Candidate, CandidateDof)> = Vec::new();
if options.enabled("concentric") {
proposals.extend(concentric_candidates(a, b, options, gates));
}
if touching && options.enabled("touch_align") {
proposals.extend(touch_align_candidates(a, b, options, gates));
}
let touches = touching && !proposals.is_empty();
if touching && options.enabled("coincident") {
proposals.extend(sphere_candidates(a, b, options));
if touches {
proposals.extend(vertex_candidates(a, b, options));
}
}
let rule_order = |type_id: &str| {
INFERENCE_RULES
.iter()
.position(|rule| rule.type_id == type_id)
.unwrap_or(usize::MAX)
};
proposals.sort_by(|(left, _), (right, _)| {
rule_order(left.type_id)
.cmp(&rule_order(right.type_id))
.then(
right
.score
.partial_cmp(&left.score)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(left.elements.cmp(&right.elements))
});
let mut dof = PairDof::default();
let mut accepted = Vec::new();
let mut suppressed = Vec::new();
for (candidate, geometry) in proposals {
if dof.locked() {
suppressed.push((candidate, Suppressed::PairLocked));
} else if dof.accept(&geometry) {
accepted.push(candidate);
} else {
suppressed.push((candidate, Suppressed::Redundant));
}
}
(accepted, suppressed)
}
fn concentric_candidates(
a: &ComponentGeom,
b: &ComponentGeom,
options: &InferOptions,
gates: &mut Gates,
) -> Vec<(Candidate, CandidateDof)> {
let mut out = Vec::new();
for group_a in &a.axes {
for group_b in &b.axes {
if group_a.direction.dot(group_b.direction) <= 1.0 - options.dot_slack() {
continue;
}
let gap = group_a.foot.sub(group_b.foot).length();
if gap > options.tolerance {
gates.note_axis_gap(gap);
continue;
}
let axis = group_a.direction;
let mut best: Option<(f64, usize, usize)> = None;
for &face_a in &group_a.faces {
let Some(span_a) = extent_along(&a.faces[face_a].points, axis) else {
gates.no_extent += 1;
continue;
};
for &face_b in &group_b.faces {
let Some(span_b) = extent_along(&b.faces[face_b].points, axis) else {
gates.no_extent += 1;
continue;
};
let engaged = overlap(span_a, span_b);
if engaged <= options.tolerance {
gates.coaxial_but_apart += 1;
}
gates.also_on_carrier += 1;
if best.map(|(score, _, _)| engaged > score).unwrap_or(true) {
best = Some((engaged, face_a, face_b));
}
}
}
let Some((engaged, face_a, face_b)) = best else {
continue;
};
gates.also_on_carrier -= 1; let radius = match a.faces[face_a].geometry {
SelectionGeometry::Axis { radius, .. } => radius,
_ => None,
};
let along = if engaged > options.tolerance {
format!("{} mm engaged", measure(engaged))
} else {
format!("{} mm apart on the axis", measure(-engaged))
};
let detail = match radius {
Some(radius) => {
format!("\u{2300}{} \u{00b7} {along}", measure(radius * 2.0))
}
None => along,
};
let (u, v) = basis_of(axis);
out.push((
Candidate {
type_id: "concentric",
elements: [
a.faces[face_a].name.clone(),
b.faces[face_b].name.clone(),
],
components: [a.id.clone(), b.id.clone()],
detail,
score: engaged,
},
CandidateDof {
rotation: vec![axis],
translation: vec![u, v],
point: None,
},
));
}
}
out
}
fn touch_align_candidates(
a: &ComponentGeom,
b: &ComponentGeom,
options: &InferOptions,
gates: &mut Gates,
) -> Vec<(Candidate, CandidateDof)> {
let mut out = Vec::new();
for group_a in &a.planes {
for group_b in &b.planes {
if group_a.direction.dot(group_b.direction) <= 1.0 - options.dot_slack() {
continue;
}
let gap = group_a.foot.sub(group_b.foot).length();
if gap > options.tolerance {
gates.note_plane_gap(gap);
continue;
}
let normal = group_a.direction;
let (u, v) = basis_of(normal);
let mut best: Option<(f64, usize, usize)> = None;
for &face_a in &group_a.faces {
let SelectionGeometry::Plane { normal: na, .. } = a.faces[face_a].geometry else {
continue;
};
let (Some(span_au), Some(span_av)) = (
extent_along(&a.faces[face_a].points, u),
extent_along(&a.faces[face_a].points, v),
) else {
gates.no_extent += 1;
continue;
};
for &face_b in &group_b.faces {
let SelectionGeometry::Plane { normal: nb, .. } = b.faces[face_b].geometry
else {
continue;
};
if na.dot(nb) >= 0.0 {
gates.same_facing += 1;
continue;
}
let (Some(span_bu), Some(span_bv)) = (
extent_along(&b.faces[face_b].points, u),
extent_along(&b.faces[face_b].points, v),
) else {
gates.no_extent += 1;
continue;
};
let across = overlap(span_au, span_bu);
let along = overlap(span_av, span_bv);
if across <= options.tolerance || along <= options.tolerance {
gates.no_overlap += 1;
continue;
}
let area = across * along;
gates.also_on_carrier += 1;
if best.map(|(score, _, _)| area > score).unwrap_or(true) {
best = Some((area, face_a, face_b));
}
}
}
let Some((area, face_a, face_b)) = best else {
continue;
};
gates.also_on_carrier -= 1; out.push((
Candidate {
type_id: "touch_align",
elements: [
a.faces[face_a].name.clone(),
b.faces[face_b].name.clone(),
],
components: [a.id.clone(), b.id.clone()],
detail: format!("{} mm\u{00b2} of contact", measure(area)),
score: area,
},
CandidateDof {
rotation: vec![normal],
translation: vec![normal],
point: None,
},
));
}
}
out
}
fn sphere_candidates(
a: &ComponentGeom,
b: &ComponentGeom,
options: &InferOptions,
) -> Vec<(Candidate, CandidateDof)> {
let mut out = Vec::new();
for &face_a in &a.spheres {
let SelectionGeometry::Sphere { center: ca, .. } = a.faces[face_a].geometry else {
continue;
};
for &face_b in &b.spheres {
let SelectionGeometry::Sphere { center: cb, .. } = b.faces[face_b].geometry else {
continue;
};
let gap = ca.sub(cb).length();
if gap > options.tolerance {
continue;
}
out.push((
Candidate {
type_id: "coincident",
elements: [
a.faces[face_a].name.clone(),
b.faces[face_b].name.clone(),
],
components: [a.id.clone(), b.id.clone()],
detail: "shared sphere centre".to_string(),
score: 1.0 / (1.0 + gap),
},
CandidateDof {
rotation: Vec::new(),
translation: unit_basis(),
point: Some(ca),
},
));
}
}
out
}
fn vertex_candidates(
a: &ComponentGeom,
b: &ComponentGeom,
options: &InferOptions,
) -> Vec<(Candidate, CandidateDof)> {
let mut out = Vec::new();
for vertex_a in &a.vertices {
for vertex_b in &b.vertices {
let gap = vertex_a.world.sub(vertex_b.world).length();
if gap > options.tolerance {
continue;
}
out.push((
Candidate {
type_id: "coincident",
elements: [vertex_a.reference.clone(), vertex_b.reference.clone()],
components: [a.id.clone(), b.id.clone()],
detail: "corners meet".to_string(),
score: 1.0 / (1.0 + gap),
},
CandidateDof {
rotation: Vec::new(),
translation: unit_basis(),
point: Some(vertex_a.world),
},
));
}
}
out
}
fn basis_of(direction: Vec3) -> (Vec3, Vec3) {
let u = direction
.perpendicular()
.and_then(|vector| vector.normalized())
.unwrap_or(Vec3::new(1.0, 0.0, 0.0));
(u, direction.cross(u))
}
fn unit_basis() -> Vec<Vec3> {
vec![
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
]
}
pub fn apply(state: &mut AssemblyState, candidates: &[Candidate]) -> Vec<String> {
let mut created = Vec::new();
for candidate in candidates {
let Some(def) = constraints::constraint_type(candidate.type_id) else {
continue;
};
state.id_counter += 1;
let id = format!("{}{}", def.short_name, state.id_counter);
let mut params = candidate.params();
params
.as_object_mut()
.expect("candidate params is an object")
.insert("id".into(), serde_json::Value::String(id.clone()));
state.constraints.push(ConstraintEntry {
constraint_type: candidate.type_id.to_string(),
input_params: params,
persistent_data: serde_json::Value::Object(serde_json::Map::new()),
enabled: true,
open: false,
});
created.push(id);
}
created
}