1use axiolid_core::{Frame2, Point2, Vec2};
15use axiolid_curve::{Circle2, Curve2, Line2};
16use axiolid_model::{
17 CurveRelation, CurveSegment, GeometryGraph, GeometryGraphBuilder, GeometryNode, NodeId,
18 Transition, TrimSelector, TrimmingPreference,
19};
20use ifc_model::{EntityId, Model};
21
22use crate::cant::CantLayout;
23use crate::curve::spiral::{is_exactly_lowerable, spiral_curve};
24use crate::error::{AlignmentError, AlignmentResult};
25use crate::horizontal::{
26 read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
27};
28use crate::vertical::{read_vertical_segment, VerticalSegment, VerticalSegmentType};
29use crate::view::AlignmentView;
30
31#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub struct LoweredAlignmentCurve {
35 pub graph: GeometryGraph,
38 pub root: NodeId,
40 pub sources: Vec<EntityId>,
42}
43
44fn observed_transition(end: Point2, next_start: Point2, tolerance: f64) -> Option<Transition> {
53 if end.distance(next_start) <= tolerance {
54 Some(Transition::Continuous)
55 } else {
56 None
57 }
58}
59
60fn closed_form_end_point(segment: &HorizontalSegment) -> Option<Point2> {
68 match segment.segment_type {
69 HorizontalSegmentType::Line => {
70 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
71 Some(segment.start_point + direction * segment.segment_length)
72 }
73 HorizontalSegmentType::CircularArc if segment.start_radius != 0.0 => {
74 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
75 let left = Vec2::new(-direction.y, direction.x);
76 let centre = segment.start_point + left * segment.start_radius;
77 let sweep = segment.segment_length / segment.start_radius;
78 let radial = segment.start_point - centre;
79 let (sin_s, cos_s) = sweep.sin_cos();
80 Some(
81 centre
82 + Vec2::new(
83 radial.x * cos_s - radial.y * sin_s,
84 radial.x * sin_s + radial.y * cos_s,
85 ),
86 )
87 }
88 _ => None,
89 }
90}
91
92#[derive(Debug, Clone, PartialEq)]
98#[non_exhaustive]
99pub struct RefusedSegment {
100 pub entity: EntityId,
102 pub type_name: String,
104 pub reason: AlignmentError,
106}
107
108#[derive(Debug, Clone, PartialEq)]
120#[non_exhaustive]
121pub struct PartialHorizontalLayout {
122 pub runs: Vec<LoweredAlignmentCurve>,
125 pub refused: Vec<RefusedSegment>,
127 pub segment_count: usize,
129}
130
131impl PartialHorizontalLayout {
132 #[must_use]
137 pub fn is_complete(&self) -> bool {
138 self.refused.is_empty()
139 }
140
141 #[must_use]
143 pub fn lowered_count(&self) -> usize {
144 self.segment_count - self.refused.len()
145 }
146}
147
148pub fn lower_horizontal_segment(
156 model: &Model,
157 id: EntityId,
158 units: AlignmentUnits,
159) -> AlignmentResult<LoweredAlignmentCurve> {
160 let segment = read_horizontal_segment(model, id, units)?;
161 let mut builder = GeometryGraphBuilder::new();
162 let root =
163 match &segment.segment_type {
164 HorizontalSegmentType::Line => push_line(&mut builder, &segment)?,
165 HorizontalSegmentType::CircularArc => push_arc(&mut builder, &segment)?,
166 HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, false) => {
167 push_spiral(&mut builder, &segment, name, None, 0.0)?
168 }
169 kind => return Err(AlignmentError::Unsupported {
170 entity: id,
171 type_name: kind.source_name().to_owned(),
172 detail:
173 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
174 }),
175 };
176 finish(builder, root, vec![id])
177}
178
179pub fn lower_vertical_segment(
186 model: &Model,
187 id: EntityId,
188 units: AlignmentUnits,
189) -> AlignmentResult<LoweredAlignmentCurve> {
190 let segment = read_vertical_segment(model, id, units)?;
191 let mut builder = GeometryGraphBuilder::new();
192 let root = push_constant_gradient(&mut builder, &segment)?;
193 finish(builder, root, vec![id])
194}
195
196fn push_line(
198 builder: &mut GeometryGraphBuilder,
199 segment: &HorizontalSegment,
200) -> AlignmentResult<NodeId> {
201 if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
202 return Err(AlignmentError::InvalidSegment {
203 entity: segment.entity,
204 detail: "LINE requires zero start and end radii",
205 });
206 }
207 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
208 let basis = push(
209 builder,
210 GeometryNode::Curve2(Curve2::Line(Line2 {
211 origin: segment.start_point,
212 direction,
213 })),
214 )?;
215 push(
216 builder,
217 GeometryNode::CurveRelation(CurveRelation::Trimmed {
218 basis,
219 start: vec![TrimSelector::Parameter(0.0)],
220 end: vec![TrimSelector::Parameter(segment.segment_length)],
221 sense_agreement: true,
222 preference: TrimmingPreference::Parameter,
223 }),
224 )
225}
226
227fn push_arc(
230 builder: &mut GeometryGraphBuilder,
231 segment: &HorizontalSegment,
232) -> AlignmentResult<NodeId> {
233 if segment.start_radius == 0.0
234 || segment.start_radius != segment.end_radius
235 || !segment.start_radius.is_finite()
236 {
237 return Err(AlignmentError::InvalidSegment {
238 entity: segment.entity,
239 detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
240 });
241 }
242 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
243 let left = Vec2::new(-direction.y, direction.x);
244 let signed_radius = segment.start_radius;
245 let radius = signed_radius.abs();
246 let centre = segment.start_point + left * signed_radius;
247 let x = left * -signed_radius.signum();
251 let y = Vec2::new(-x.y, x.x);
254 let sweep = segment.segment_length / signed_radius;
255 if [centre.x, centre.y, x.x, x.y, y.x, y.y, radius, sweep]
256 .iter()
257 .any(|value| !value.is_finite())
258 {
259 return Err(AlignmentError::InvalidSegment {
260 entity: segment.entity,
261 detail: "CIRCULARARC derived frame and trim parameters must be finite",
262 });
263 }
264 let basis = push(
265 builder,
266 GeometryNode::Curve2(Curve2::Circle(Circle2 {
267 frame: Frame2 {
268 origin: centre,
269 x,
270 y,
271 },
272 radius,
273 })),
274 )?;
275 push(
276 builder,
277 GeometryNode::CurveRelation(CurveRelation::Trimmed {
278 basis,
279 start: vec![TrimSelector::Parameter(0.0)],
280 end: vec![TrimSelector::Parameter(sweep)],
281 sense_agreement: true,
282 preference: TrimmingPreference::Parameter,
283 }),
284 )
285}
286
287fn push_constant_gradient(
290 builder: &mut GeometryGraphBuilder,
291 segment: &VerticalSegment,
292) -> AlignmentResult<NodeId> {
293 if !matches!(
294 segment.predefined_type,
295 VerticalSegmentType::ConstantGradient
296 ) {
297 return Err(AlignmentError::Unsupported {
298 entity: segment.entity,
299 type_name: segment.predefined_type.source_name().to_owned(),
300 detail: "exact neutral vertical lowering is currently limited to constant gradient",
301 });
302 }
303 if segment.radius_of_curvature.is_some() || segment.start_gradient != segment.end_gradient {
304 return Err(AlignmentError::InvalidSegment {
305 entity: segment.entity,
306 detail: "CONSTANTGRADIENT requires equal gradients and no curvature radius",
307 });
308 }
309 let basis = push(
310 builder,
311 GeometryNode::Curve2(Curve2::Line(Line2 {
312 origin: Point2::new(segment.start_dist_along, segment.start_height),
313 direction: Vec2::new(1.0, segment.start_gradient),
314 })),
315 )?;
316 push(
317 builder,
318 GeometryNode::CurveRelation(CurveRelation::Trimmed {
319 basis,
320 start: vec![TrimSelector::Parameter(0.0)],
321 end: vec![TrimSelector::Parameter(segment.horizontal_length)],
322 sense_agreement: true,
323 preference: TrimmingPreference::Parameter,
324 }),
325 )
326}
327
328fn push_spiral(
331 builder: &mut GeometryGraphBuilder,
332 segment: &HorizontalSegment,
333 name: &str,
334 cant: Option<&CantLayout>,
335 start_distance: f64,
336) -> AlignmentResult<NodeId> {
337 let curve = spiral_curve(segment, name, cant, start_distance)?;
338 let basis = push(builder, GeometryNode::Curve2(curve))?;
339 push(
340 builder,
341 GeometryNode::CurveRelation(CurveRelation::Trimmed {
342 basis,
343 start: vec![TrimSelector::Parameter(0.0)],
344 end: vec![TrimSelector::Parameter(segment.segment_length)],
345 sense_agreement: true,
346 preference: TrimmingPreference::Parameter,
347 }),
348 )
349}
350
351fn push(builder: &mut GeometryGraphBuilder, node: GeometryNode) -> AlignmentResult<NodeId> {
352 builder.push(node).map_err(|error| AlignmentError::Graph {
353 detail: error.to_string(),
354 })
355}
356
357pub(super) fn finish(
358 builder: GeometryGraphBuilder,
359 root: NodeId,
360 sources: Vec<EntityId>,
361) -> AlignmentResult<LoweredAlignmentCurve> {
362 let graph = builder
363 .finish(vec![root])
364 .map_err(|error| AlignmentError::Graph {
365 detail: error.to_string(),
366 })?;
367 Ok(LoweredAlignmentCurve {
368 graph,
369 root,
370 sources,
371 })
372}
373
374pub fn lower_horizontal_layout(
378 model: &Model,
379 entity: EntityId,
380 units: AlignmentUnits,
381 cant: Option<&CantLayout>,
382) -> AlignmentResult<LoweredAlignmentCurve> {
383 let view = AlignmentView::for_model(model)?;
384 let horizontal_entity = model
385 .get(entity)
386 .ok_or(AlignmentError::MissingEntity { entity })?;
387 if !view
388 .schema
389 .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
390 {
391 return Err(AlignmentError::WrongType {
392 entity,
393 expected: "IfcAlignmentHorizontal",
394 actual: horizontal_entity.type_name.to_string(),
395 });
396 }
397 let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
398 if ids.is_empty() {
399 return Err(AlignmentError::SemanticViolation {
400 entity: Some(entity),
401 rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
402 });
403 }
404
405 let mut segments = Vec::with_capacity(ids.len());
406 for id in &ids {
407 segments.push(read_horizontal_segment(model, *id, units)?);
408 }
409
410 let mut builder = GeometryGraphBuilder::new();
411 let mut composite_segments = Vec::with_capacity(segments.len());
412 let mut station = 0.0_f64;
413 for (index, segment) in segments.iter().enumerate() {
414 let curve = match &segment.segment_type {
415 HorizontalSegmentType::Line => push_line(&mut builder, segment)?,
416 HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment)?,
417 HorizontalSegmentType::Transition(name)
418 if is_exactly_lowerable(name, cant.is_some()) =>
419 {
420 push_spiral(&mut builder, segment, name, cant, station)?
421 }
422 kind => return Err(AlignmentError::Unsupported {
423 entity: segment.entity,
424 type_name: kind.source_name().to_owned(),
425 detail:
426 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
427 }),
428 };
429 let transition = if index == 0 {
430 Transition::Discontinuous
431 } else {
432 let previous = &segments[index - 1];
433 let previous_end =
438 closed_form_end_point(previous).ok_or(AlignmentError::Unsupported {
439 entity: previous.entity,
440 type_name: previous.segment_type.source_name().to_owned(),
441 detail: "continuity across a transition spiral is not provable in closed form",
442 })?;
443 observed_transition(previous_end, segment.start_point, 1e-6).ok_or(
444 AlignmentError::SemanticViolation {
445 entity: Some(segment.entity),
446 rule: "consecutive horizontal segments must share an endpoint exactly",
447 },
448 )?
449 };
450 composite_segments.push(CurveSegment {
451 curve,
452 same_sense: true,
453 transition,
454 });
455 station += segment.segment_length;
456 }
457
458 let root = push(
459 &mut builder,
460 GeometryNode::CurveRelation(CurveRelation::Composite {
461 segments: composite_segments,
462 }),
463 )?;
464 finish(builder, root, ids)
465}
466
467pub fn lower_horizontal_layout_partial(
483 model: &Model,
484 entity: EntityId,
485 units: AlignmentUnits,
486 cant: Option<&CantLayout>,
487) -> AlignmentResult<PartialHorizontalLayout> {
488 let view = AlignmentView::for_model(model)?;
489 let horizontal_entity = model
490 .get(entity)
491 .ok_or(AlignmentError::MissingEntity { entity })?;
492 if !view
493 .schema
494 .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
495 {
496 return Err(AlignmentError::WrongType {
497 entity,
498 expected: "IfcAlignmentHorizontal",
499 actual: horizontal_entity.type_name.to_string(),
500 });
501 }
502 let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
503 if ids.is_empty() {
504 return Err(AlignmentError::SemanticViolation {
505 entity: Some(entity),
506 rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
507 });
508 }
509
510 let mut segments = Vec::with_capacity(ids.len());
511 for id in &ids {
512 segments.push(read_horizontal_segment(model, *id, units)?);
513 }
514
515 let mut runs = Vec::new();
516 let mut refused = Vec::new();
517 let mut builder = GeometryGraphBuilder::new();
520 let mut pending: Vec<(usize, CurveSegment)> = Vec::new();
521 let mut pending_ids: Vec<EntityId> = Vec::new();
522 let mut station = 0.0_f64;
523
524 for (index, segment) in segments.iter().enumerate() {
525 if let Some((previous_index, _)) = pending.last() {
530 if closed_form_end_point(&segments[*previous_index]).is_none() {
531 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
532 }
533 }
534 let lowered = match &segment.segment_type {
535 HorizontalSegmentType::Line => push_line(&mut builder, segment),
536 HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment),
537 HorizontalSegmentType::Transition(name)
538 if is_exactly_lowerable(name, cant.is_some()) =>
539 {
540 push_spiral(&mut builder, segment, name, cant, station)
541 }
542 kind => Err(AlignmentError::Unsupported {
543 entity: segment.entity,
544 type_name: kind.source_name().to_owned(),
545 detail:
546 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
547 }),
548 };
549 station += segment.segment_length;
552 let curve = match lowered {
553 Ok(curve) => curve,
554 Err(reason) => {
555 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
558 refused.push(RefusedSegment {
559 entity: segment.entity,
560 type_name: segment.segment_type.source_name().to_owned(),
561 reason,
562 });
563 continue;
564 }
565 };
566 let transition = match pending.last() {
569 None => Transition::Discontinuous,
570 Some((previous_index, _)) => {
571 let previous = &segments[*previous_index];
572 let previous_end = closed_form_end_point(previous)
575 .expect("run boundary guarantees a closed-form predecessor end point");
576 match observed_transition(previous_end, segment.start_point, 1e-6) {
577 Some(transition) => transition,
578 None => {
579 return Err(AlignmentError::SemanticViolation {
580 entity: Some(segment.entity),
581 rule: "consecutive horizontal segments must share an endpoint exactly",
582 })
583 }
584 }
585 }
586 };
587 pending.push((
588 index,
589 CurveSegment {
590 curve,
591 same_sense: true,
592 transition,
593 },
594 ));
595 pending_ids.push(segment.entity);
596 }
597 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
598
599 Ok(PartialHorizontalLayout {
600 runs,
601 refused,
602 segment_count: segments.len(),
603 })
604}
605
606fn flush_run(
611 runs: &mut Vec<LoweredAlignmentCurve>,
612 builder: &mut GeometryGraphBuilder,
613 pending: &mut Vec<(usize, CurveSegment)>,
614 pending_ids: &mut Vec<EntityId>,
615) -> AlignmentResult<()> {
616 if pending.is_empty() {
617 *builder = GeometryGraphBuilder::new();
620 pending_ids.clear();
621 return Ok(());
622 }
623 let mut finished = GeometryGraphBuilder::new();
624 core::mem::swap(builder, &mut finished);
625 let composite_segments: Vec<CurveSegment> =
626 pending.drain(..).map(|(_, segment)| segment).collect();
627 let root = push(
628 &mut finished,
629 GeometryNode::CurveRelation(CurveRelation::Composite {
630 segments: composite_segments,
631 }),
632 )?;
633 let sources = core::mem::take(pending_ids);
634 runs.push(finish(finished, root, sources)?);
635 Ok(())
636}