axioval_rules/
free_floor_circle.rs

1//! Exact source-neutral free-floor-circle capability.
2
3use axioval_engine::{
4    CapabilityEvaluation, ClearanceShape, CompiledRule, CylinderClearance, FreeSpaceError,
5    FreeSpaceServiceHandle, NotEvaluatedReason, ParameterDescriptor, ParameterType,
6    PlacementDomain, PlacementOutcome, PlacementRequest, RuleCapability, RuleContext,
7    SupportedPlacement,
8};
9use axioval_ir::contract::ParameterValue;
10use axioval_ir::{Finding, Object, Severity};
11
12use crate::selection::select_objects;
13
14/// Exact free-floor circle placement using a trusted free-space service.
15pub struct FreeFloorCircle;
16
17impl RuleCapability for FreeFloorCircle {
18    fn id(&self) -> &'static str {
19        "axioval:capability.free-floor-circle"
20    }
21    fn parameters(&self) -> Vec<ParameterDescriptor> {
22        vec![
23            ParameterDescriptor::required("diameter_metres", ParameterType::Number),
24            ParameterDescriptor::required("height_metres", ParameterType::Number),
25        ]
26    }
27    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
28        let (selected, mut evaluation) = select_objects(context, &rule.selector);
29        let Some((diameter, height)) = dimensions(rule) else {
30            return invalid_parameters(
31                selected,
32                "free-floor circle dimensions are invalid",
33                evaluation,
34            );
35        };
36        let Ok(shape) = CylinderClearance::try_new(diameter / 2.0, height) else {
37            return invalid_parameters(
38                selected,
39                "free-floor circle dimensions must be positive and finite",
40                evaluation,
41            );
42        };
43        let Some(service) = context.services.get::<FreeSpaceServiceHandle>() else {
44            return unavailable(
45                selected,
46                &NotEvaluatedReason::MissingService,
47                "free-space service is not registered",
48                evaluation,
49            );
50        };
51        let all_objects: Vec<_> = context
52            .project
53            .objects()
54            .map(|object| object.id.clone())
55            .collect();
56        for space in selected {
57            let obstacles = all_objects
58                .iter()
59                .filter(|id| *id != &space.id)
60                .cloned()
61                .collect();
62            let support = match SupportedPlacement::try_new(space.id.clone(), 0.0) {
63                Ok(support) => support,
64                Err(error) => {
65                    evaluation.push_object_not_evaluated(
66                        space.id.clone(),
67                        NotEvaluatedReason::InvalidEvidence,
68                        error.to_string(),
69                    );
70                    continue;
71                }
72            };
73            let request = match PlacementRequest::new_in_domain(
74                space.id.clone(),
75                ClearanceShape::Cylinder(shape),
76                obstacles,
77                PlacementDomain::Supported(support),
78            ) {
79                Ok(request) => request,
80                Err(error) => {
81                    evaluation.push_object_not_evaluated(
82                        space.id.clone(),
83                        NotEvaluatedReason::InvalidEvidence,
84                        error.to_string(),
85                    );
86                    continue;
87                }
88            };
89            match service.find_placement(&request) {
90                Ok(PlacementOutcome::Found(_)) => {}
91                Ok(PlacementOutcome::NoPlacement(proof)) => evaluation.push_finding(Finding {
92                    rule_id: rule.id.clone(),
93                    object_id: space.id.clone(),
94                    severity: severity(rule),
95                    message: "NO_FREE_FLOOR_SPACE_FOR_CIRCLE".into(),
96                    evidence: vec![proof.evidence().clone()],
97                }),
98                Err(error) => evaluation.push_object_not_evaluated(
99                    space.id.clone(),
100                    reason(&error),
101                    error.to_string(),
102                ),
103            }
104        }
105        evaluation
106    }
107}
108
109fn dimensions(rule: &CompiledRule) -> Option<(f64, f64)> {
110    let number = |name: &str| match rule.parameters.get(name)? {
111        ParameterValue::Number { value } => Some(*value),
112        _ => None,
113    };
114    Some((number("diameter_metres")?, number("height_metres")?))
115}
116fn severity(rule: &CompiledRule) -> Severity {
117    match rule.severity {
118        axioval_ir::contract::Severity::Error => Severity::Error,
119        axioval_ir::contract::Severity::Warning => Severity::Warning,
120        axioval_ir::contract::Severity::Info => Severity::Info,
121    }
122}
123fn invalid_parameters(
124    selected: Vec<&Object>,
125    message: &str,
126    evaluation: CapabilityEvaluation,
127) -> CapabilityEvaluation {
128    unavailable(
129        selected,
130        &NotEvaluatedReason::InvalidDeclaration,
131        message,
132        evaluation,
133    )
134}
135fn unavailable(
136    selected: Vec<&Object>,
137    reason: &NotEvaluatedReason,
138    message: &str,
139    mut evaluation: CapabilityEvaluation,
140) -> CapabilityEvaluation {
141    for object in selected {
142        evaluation.push_object_not_evaluated(object.id.clone(), reason.clone(), message);
143    }
144    evaluation
145}
146fn reason(error: &FreeSpaceError) -> NotEvaluatedReason {
147    match error {
148        FreeSpaceError::MissingGeometry(_) | FreeSpaceError::Unavailable(_) => {
149            NotEvaluatedReason::BackendUnavailable
150        }
151        FreeSpaceError::IncompleteClearanceEvidence => NotEvaluatedReason::IncompleteEvidence,
152        _ => NotEvaluatedReason::InvalidEvidence,
153    }
154}