1use axiolid_core::{Frame2, Point2, Vec2};
4use axiolid_curve::{Circle2, Curve2, Line2};
5use axiolid_model::{
6 CurveRelation, CurveSegment, GeometryGraph, GeometryGraphBuilder, GeometryNode, NodeId,
7 Transition, TrimSelector, TrimmingPreference,
8};
9use ifc_model::{EntityId, Model};
10
11use crate::cant::CantLayout;
12use crate::curve::spiral::{is_exactly_lowerable, spiral_curve};
13use crate::error::{AlignmentError, AlignmentResult};
14use crate::horizontal::{
15 read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
16};
17use crate::vertical::{read_vertical_segment, VerticalSegment, VerticalSegmentType};
18use crate::view::AlignmentView;
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct LoweredAlignmentCurve {
23 pub graph: GeometryGraph,
26 pub root: NodeId,
28 pub sources: Vec<EntityId>,
30}
31
32fn observed_transition(end: Point2, next_start: Point2, tolerance: f64) -> Option<Transition> {
41 if end.distance(next_start) <= tolerance {
42 Some(Transition::Continuous)
43 } else {
44 None
45 }
46}
47
48fn closed_form_end_point(segment: &HorizontalSegment) -> Option<Point2> {
56 match segment.segment_type {
57 HorizontalSegmentType::Line => {
58 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
59 Some(segment.start_point + direction * segment.segment_length)
60 }
61 HorizontalSegmentType::CircularArc if segment.start_radius != 0.0 => {
62 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
63 let left = Vec2::new(-direction.y, direction.x);
64 let centre = segment.start_point + left * segment.start_radius;
65 let sweep = segment.segment_length / segment.start_radius;
66 let radial = segment.start_point - centre;
67 let (sin_s, cos_s) = sweep.sin_cos();
68 Some(
69 centre
70 + Vec2::new(
71 radial.x * cos_s - radial.y * sin_s,
72 radial.x * sin_s + radial.y * cos_s,
73 ),
74 )
75 }
76 _ => None,
77 }
78}
79
80#[derive(Debug, Clone, PartialEq)]
86pub struct RefusedSegment {
87 pub entity: EntityId,
89 pub type_name: String,
91 pub reason: AlignmentError,
93}
94
95#[derive(Debug, Clone, PartialEq)]
107pub struct PartialHorizontalLayout {
108 pub runs: Vec<LoweredAlignmentCurve>,
111 pub refused: Vec<RefusedSegment>,
113 pub segment_count: usize,
115}
116
117impl PartialHorizontalLayout {
118 #[must_use]
123 pub fn is_complete(&self) -> bool {
124 self.refused.is_empty()
125 }
126
127 #[must_use]
129 pub fn lowered_count(&self) -> usize {
130 self.segment_count - self.refused.len()
131 }
132}
133
134pub fn lower_horizontal_segment(
142 model: &Model,
143 id: EntityId,
144 units: AlignmentUnits,
145) -> AlignmentResult<LoweredAlignmentCurve> {
146 let segment = read_horizontal_segment(model, id, units)?;
147 let mut builder = GeometryGraphBuilder::new();
148 let root =
149 match &segment.segment_type {
150 HorizontalSegmentType::Line => push_line(&mut builder, &segment)?,
151 HorizontalSegmentType::CircularArc => push_arc(&mut builder, &segment)?,
152 HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, false) => {
153 push_spiral(&mut builder, &segment, name, None, 0.0)?
154 }
155 kind => return Err(AlignmentError::Unsupported {
156 entity: id,
157 type_name: kind.source_name().to_owned(),
158 detail:
159 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
160 }),
161 };
162 finish(builder, root, vec![id])
163}
164
165pub fn lower_vertical_segment(
172 model: &Model,
173 id: EntityId,
174 units: AlignmentUnits,
175) -> AlignmentResult<LoweredAlignmentCurve> {
176 let segment = read_vertical_segment(model, id, units)?;
177 let mut builder = GeometryGraphBuilder::new();
178 let root = push_constant_gradient(&mut builder, &segment)?;
179 finish(builder, root, vec![id])
180}
181
182fn push_line(
184 builder: &mut GeometryGraphBuilder,
185 segment: &HorizontalSegment,
186) -> AlignmentResult<NodeId> {
187 if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
188 return Err(AlignmentError::InvalidSegment {
189 entity: segment.entity,
190 detail: "LINE requires zero start and end radii",
191 });
192 }
193 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
194 let basis = push(
195 builder,
196 GeometryNode::Curve2(Curve2::Line(Line2 {
197 origin: segment.start_point,
198 direction,
199 })),
200 )?;
201 push(
202 builder,
203 GeometryNode::CurveRelation(CurveRelation::Trimmed {
204 basis,
205 start: vec![TrimSelector::Parameter(0.0)],
206 end: vec![TrimSelector::Parameter(segment.segment_length)],
207 sense_agreement: true,
208 preference: TrimmingPreference::Parameter,
209 }),
210 )
211}
212
213fn push_arc(
216 builder: &mut GeometryGraphBuilder,
217 segment: &HorizontalSegment,
218) -> AlignmentResult<NodeId> {
219 if segment.start_radius == 0.0
220 || segment.start_radius != segment.end_radius
221 || !segment.start_radius.is_finite()
222 {
223 return Err(AlignmentError::InvalidSegment {
224 entity: segment.entity,
225 detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
226 });
227 }
228 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
229 let left = Vec2::new(-direction.y, direction.x);
230 let signed_radius = segment.start_radius;
231 let radius = signed_radius.abs();
232 let centre = segment.start_point + left * signed_radius;
233 let x = left * -signed_radius.signum();
237 let y = Vec2::new(-x.y, x.x);
240 let sweep = segment.segment_length / signed_radius;
241 if [centre.x, centre.y, x.x, x.y, y.x, y.y, radius, sweep]
242 .iter()
243 .any(|value| !value.is_finite())
244 {
245 return Err(AlignmentError::InvalidSegment {
246 entity: segment.entity,
247 detail: "CIRCULARARC derived frame and trim parameters must be finite",
248 });
249 }
250 let basis = push(
251 builder,
252 GeometryNode::Curve2(Curve2::Circle(Circle2 {
253 frame: Frame2 {
254 origin: centre,
255 x,
256 y,
257 },
258 radius,
259 })),
260 )?;
261 push(
262 builder,
263 GeometryNode::CurveRelation(CurveRelation::Trimmed {
264 basis,
265 start: vec![TrimSelector::Parameter(0.0)],
266 end: vec![TrimSelector::Parameter(sweep)],
267 sense_agreement: true,
268 preference: TrimmingPreference::Parameter,
269 }),
270 )
271}
272
273fn push_constant_gradient(
276 builder: &mut GeometryGraphBuilder,
277 segment: &VerticalSegment,
278) -> AlignmentResult<NodeId> {
279 if !matches!(
280 segment.predefined_type,
281 VerticalSegmentType::ConstantGradient
282 ) {
283 return Err(AlignmentError::Unsupported {
284 entity: segment.entity,
285 type_name: segment.predefined_type.source_name().to_owned(),
286 detail: "exact neutral vertical lowering is currently limited to constant gradient",
287 });
288 }
289 if segment.radius_of_curvature.is_some() || segment.start_gradient != segment.end_gradient {
290 return Err(AlignmentError::InvalidSegment {
291 entity: segment.entity,
292 detail: "CONSTANTGRADIENT requires equal gradients and no curvature radius",
293 });
294 }
295 let basis = push(
296 builder,
297 GeometryNode::Curve2(Curve2::Line(Line2 {
298 origin: Point2::new(segment.start_dist_along, segment.start_height),
299 direction: Vec2::new(1.0, segment.start_gradient),
300 })),
301 )?;
302 push(
303 builder,
304 GeometryNode::CurveRelation(CurveRelation::Trimmed {
305 basis,
306 start: vec![TrimSelector::Parameter(0.0)],
307 end: vec![TrimSelector::Parameter(segment.horizontal_length)],
308 sense_agreement: true,
309 preference: TrimmingPreference::Parameter,
310 }),
311 )
312}
313
314fn push_spiral(
317 builder: &mut GeometryGraphBuilder,
318 segment: &HorizontalSegment,
319 name: &str,
320 cant: Option<&CantLayout>,
321 start_distance: f64,
322) -> AlignmentResult<NodeId> {
323 let curve = spiral_curve(segment, name, cant, start_distance)?;
324 let basis = push(builder, GeometryNode::Curve2(curve))?;
325 push(
326 builder,
327 GeometryNode::CurveRelation(CurveRelation::Trimmed {
328 basis,
329 start: vec![TrimSelector::Parameter(0.0)],
330 end: vec![TrimSelector::Parameter(segment.segment_length)],
331 sense_agreement: true,
332 preference: TrimmingPreference::Parameter,
333 }),
334 )
335}
336
337fn push(builder: &mut GeometryGraphBuilder, node: GeometryNode) -> AlignmentResult<NodeId> {
338 builder.push(node).map_err(|error| AlignmentError::Graph {
339 detail: error.to_string(),
340 })
341}
342
343pub(super) fn finish(
344 builder: GeometryGraphBuilder,
345 root: NodeId,
346 sources: Vec<EntityId>,
347) -> AlignmentResult<LoweredAlignmentCurve> {
348 let graph = builder
349 .finish(vec![root])
350 .map_err(|error| AlignmentError::Graph {
351 detail: error.to_string(),
352 })?;
353 Ok(LoweredAlignmentCurve {
354 graph,
355 root,
356 sources,
357 })
358}
359
360pub fn lower_horizontal_layout(
364 model: &Model,
365 entity: EntityId,
366 units: AlignmentUnits,
367 cant: Option<&CantLayout>,
368) -> AlignmentResult<LoweredAlignmentCurve> {
369 let view = AlignmentView::for_model(model)?;
370 let horizontal_entity = model
371 .get(entity)
372 .ok_or(AlignmentError::MissingEntity { entity })?;
373 if !view
374 .schema
375 .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
376 {
377 return Err(AlignmentError::WrongType {
378 entity,
379 expected: "IfcAlignmentHorizontal",
380 actual: horizontal_entity.type_name.to_string(),
381 });
382 }
383 let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
384 if ids.is_empty() {
385 return Err(AlignmentError::SemanticViolation {
386 entity: Some(entity),
387 rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
388 });
389 }
390
391 let mut segments = Vec::with_capacity(ids.len());
392 for id in &ids {
393 segments.push(read_horizontal_segment(model, *id, units)?);
394 }
395
396 let mut builder = GeometryGraphBuilder::new();
397 let mut composite_segments = Vec::with_capacity(segments.len());
398 let mut station = 0.0_f64;
399 for (index, segment) in segments.iter().enumerate() {
400 let curve = match &segment.segment_type {
401 HorizontalSegmentType::Line => push_line(&mut builder, segment)?,
402 HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment)?,
403 HorizontalSegmentType::Transition(name)
404 if is_exactly_lowerable(name, cant.is_some()) =>
405 {
406 push_spiral(&mut builder, segment, name, cant, station)?
407 }
408 kind => return Err(AlignmentError::Unsupported {
409 entity: segment.entity,
410 type_name: kind.source_name().to_owned(),
411 detail:
412 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
413 }),
414 };
415 let transition = if index == 0 {
416 Transition::Discontinuous
417 } else {
418 let previous = &segments[index - 1];
419 let previous_end =
424 closed_form_end_point(previous).ok_or(AlignmentError::Unsupported {
425 entity: previous.entity,
426 type_name: previous.segment_type.source_name().to_owned(),
427 detail: "continuity across a transition spiral is not provable in closed form",
428 })?;
429 observed_transition(previous_end, segment.start_point, 1e-6).ok_or(
430 AlignmentError::SemanticViolation {
431 entity: Some(segment.entity),
432 rule: "consecutive horizontal segments must share an endpoint exactly",
433 },
434 )?
435 };
436 composite_segments.push(CurveSegment {
437 curve,
438 same_sense: true,
439 transition,
440 });
441 station += segment.segment_length;
442 }
443
444 let root = push(
445 &mut builder,
446 GeometryNode::CurveRelation(CurveRelation::Composite {
447 segments: composite_segments,
448 }),
449 )?;
450 finish(builder, root, ids)
451}
452
453pub fn lower_horizontal_layout_partial(
469 model: &Model,
470 entity: EntityId,
471 units: AlignmentUnits,
472 cant: Option<&CantLayout>,
473) -> AlignmentResult<PartialHorizontalLayout> {
474 let view = AlignmentView::for_model(model)?;
475 let horizontal_entity = model
476 .get(entity)
477 .ok_or(AlignmentError::MissingEntity { entity })?;
478 if !view
479 .schema
480 .is_a(&horizontal_entity.type_name, "IfcAlignmentHorizontal")
481 {
482 return Err(AlignmentError::WrongType {
483 entity,
484 expected: "IfcAlignmentHorizontal",
485 actual: horizontal_entity.type_name.to_string(),
486 });
487 }
488 let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
489 if ids.is_empty() {
490 return Err(AlignmentError::SemanticViolation {
491 entity: Some(entity),
492 rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
493 });
494 }
495
496 let mut segments = Vec::with_capacity(ids.len());
497 for id in &ids {
498 segments.push(read_horizontal_segment(model, *id, units)?);
499 }
500
501 let mut runs = Vec::new();
502 let mut refused = Vec::new();
503 let mut builder = GeometryGraphBuilder::new();
506 let mut pending: Vec<(usize, CurveSegment)> = Vec::new();
507 let mut pending_ids: Vec<EntityId> = Vec::new();
508 let mut station = 0.0_f64;
509
510 for (index, segment) in segments.iter().enumerate() {
511 if let Some((previous_index, _)) = pending.last() {
516 if closed_form_end_point(&segments[*previous_index]).is_none() {
517 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
518 }
519 }
520 let lowered = match &segment.segment_type {
521 HorizontalSegmentType::Line => push_line(&mut builder, segment),
522 HorizontalSegmentType::CircularArc => push_arc(&mut builder, segment),
523 HorizontalSegmentType::Transition(name)
524 if is_exactly_lowerable(name, cant.is_some()) =>
525 {
526 push_spiral(&mut builder, segment, name, cant, station)
527 }
528 kind => Err(AlignmentError::Unsupported {
529 entity: segment.entity,
530 type_name: kind.source_name().to_owned(),
531 detail:
532 "the pinned neutral curve vocabulary has no exact transition-curve primitive",
533 }),
534 };
535 station += segment.segment_length;
538 let curve = match lowered {
539 Ok(curve) => curve,
540 Err(reason) => {
541 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
544 refused.push(RefusedSegment {
545 entity: segment.entity,
546 type_name: segment.segment_type.source_name().to_owned(),
547 reason,
548 });
549 continue;
550 }
551 };
552 let transition = match pending.last() {
555 None => Transition::Discontinuous,
556 Some((previous_index, _)) => {
557 let previous = &segments[*previous_index];
558 let previous_end = closed_form_end_point(previous)
561 .expect("run boundary guarantees a closed-form predecessor end point");
562 match observed_transition(previous_end, segment.start_point, 1e-6) {
563 Some(transition) => transition,
564 None => {
565 return Err(AlignmentError::SemanticViolation {
566 entity: Some(segment.entity),
567 rule: "consecutive horizontal segments must share an endpoint exactly",
568 })
569 }
570 }
571 }
572 };
573 pending.push((
574 index,
575 CurveSegment {
576 curve,
577 same_sense: true,
578 transition,
579 },
580 ));
581 pending_ids.push(segment.entity);
582 }
583 flush_run(&mut runs, &mut builder, &mut pending, &mut pending_ids)?;
584
585 Ok(PartialHorizontalLayout {
586 runs,
587 refused,
588 segment_count: segments.len(),
589 })
590}
591
592fn flush_run(
597 runs: &mut Vec<LoweredAlignmentCurve>,
598 builder: &mut GeometryGraphBuilder,
599 pending: &mut Vec<(usize, CurveSegment)>,
600 pending_ids: &mut Vec<EntityId>,
601) -> AlignmentResult<()> {
602 if pending.is_empty() {
603 *builder = GeometryGraphBuilder::new();
606 pending_ids.clear();
607 return Ok(());
608 }
609 let mut finished = GeometryGraphBuilder::new();
610 core::mem::swap(builder, &mut finished);
611 let composite_segments: Vec<CurveSegment> =
612 pending.drain(..).map(|(_, segment)| segment).collect();
613 let root = push(
614 &mut finished,
615 GeometryNode::CurveRelation(CurveRelation::Composite {
616 segments: composite_segments,
617 }),
618 )?;
619 let sources = core::mem::take(pending_ids);
620 runs.push(finish(finished, root, sources)?);
621 Ok(())
622}