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