mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 12:13:43 +00:00
Adding spirals to alignments
This commit is contained in:
@@ -42,14 +42,33 @@ improvements:
|
||||
command.
|
||||
6. Click each PI (or only the PIs of interest) and input the smoothing type and its parameters.
|
||||
Smoothing types include: Circular, Spiral-Circular, Circular-Spiral, Spiral-Circular-Spiral.
|
||||
**Implemented** (`ALIGN_OT_apply_pi_curve`, `PICurveMarkerProperties.curve_type` in the
|
||||
Alignments tab panel) for the clothoid spiral family, via the PI method: each PI marker still
|
||||
stands for one combined "curve" in the UI, but resolves to a run of independent
|
||||
`IfcAlignmentSegment`s underneath (tangent run / entry spiral / arc / exit spiral / tangent
|
||||
run), placed by `ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method` and
|
||||
written via `layout_horizontal_alignment_by_pi_method`.
|
||||
7. In a pop-up (or other appropriate UI element), input the parameters:
|
||||
- **Circular curve**: radius only.
|
||||
- **Spiral curve**: spiral type (Clothoid, Bloss, Cosine, Helmert, etc.) and spiral length.
|
||||
This assumes all spirals have infinite start/end radius and share the circular arc's radius.
|
||||
- **Spiral curve**: spiral length(s) (entry, exit, or both). **Implemented for the clothoid
|
||||
family only** — this assumes all spirals have infinite start/end radius and share the
|
||||
circular arc's radius. Other spiral families (Bloss, Cosine, Sine, Cubic, Helmert) are not
|
||||
yet supported by the PI-method solver; each would need its own curvature-vs-length
|
||||
integrand substituted into `solve_horizontal_alignment_by_pi_method`'s displacement
|
||||
composition (the tangent-distance projection itself is spiral-family agnostic).
|
||||
|
||||
**Open question**: other cases exist that this doesn't cover, e.g. a spiral between two
|
||||
circular arcs of different radius (Spiral-Circular-Spiral-Circular-Spiral). No UI is proposed
|
||||
for this yet — it may require selecting 2 PIs and defining all parameters together.
|
||||
**Confirmed future requirement**: compound curves (PCC, point of compound curvature — two
|
||||
arcs curving the same direction) and reverse curves (PRC, point of reverse curvature — two
|
||||
arcs curving opposite directions), joined directly with no tangent run between them, optionally
|
||||
with a spiral on the outer/non-joined side of either curve (e.g.
|
||||
Spiral-Circular-Spiral-Circular-Spiral). A PCC/PRC-capable solver variant (`join_next` on a PI's
|
||||
radii entry, closure-validated so the two curves' tangent lengths exactly span the PI-to-PI
|
||||
distance) exists as prior art on another branch, ported from upstream PR #8833, but was not
|
||||
brought in with the clothoid spiral-circular-spiral pass.
|
||||
|
||||
**Open question**: the UI for this — since a compound/reverse curve junction spans two PIs, it
|
||||
may need selecting 2 PIs and defining both curves' parameters together, rather than the
|
||||
single-PI marker interaction used for §2 steps 6-7 today.
|
||||
8. Right-click (or whatever is standard) to end the command. Generate the alignment automatically.
|
||||
|
||||
## 3. Interrogating an alignment
|
||||
|
||||
@@ -611,10 +611,10 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
|
||||
aren't IFC-linked, so tool.Alignment.get_active_alignment() can't find
|
||||
the alignment from them.
|
||||
|
||||
Uses layout_horizontal_alignment_by_pi_method for now (circular curves
|
||||
only). Once spiral segments are needed this has to become genuinely
|
||||
one-segment-at-a-time authoring via create_layout_segment(), since that
|
||||
API only ever emits LINE/CIRCULARARC — see REQUIREMENTS.md §2 step 7.
|
||||
Uses layout_horizontal_alignment_by_pi_method, which now also accepts
|
||||
(radius, entry_length, exit_length) tuples for clothoid spiral-circular,
|
||||
circular-spiral, and spiral-circular-spiral PIs alongside plain-radius
|
||||
circular curves — see solve_horizontal_alignment_by_pi_method.
|
||||
"""
|
||||
ifc = tool.Ifc.get()
|
||||
|
||||
@@ -638,7 +638,7 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
|
||||
|
||||
tool.Alignment.refresh_alignment_representation_object(alignment)
|
||||
|
||||
n_curved = sum(1 for r in radii if r)
|
||||
n_curved = sum(1 for r in radii if (r[0] if isinstance(r, tuple) else r))
|
||||
return True, f"Drew alignment '{alignment.Name}' with {len(hpoints)} PIs ({n_curved} curved)"
|
||||
|
||||
|
||||
@@ -707,6 +707,43 @@ def _is_interior_pi_marker(obj) -> bool:
|
||||
return obj.bonsai_pi_curve_marker.is_pi_marker
|
||||
|
||||
|
||||
def _pi_curve_radii_entry(marker):
|
||||
"""One radii[] element (see solve_horizontal_alignment_by_pi_method) for a PI marker.
|
||||
|
||||
TANGENT stays a plain 0.0 (no curve). CIRCULAR stays a plain radius float
|
||||
for backward compatibility. The three spiral curve types become a
|
||||
(radius, entry_length, exit_length) tuple with whichever length(s) don't
|
||||
apply left at 0.0 — only the clothoid spiral family is supported, per
|
||||
solve_horizontal_alignment_by_pi_method.
|
||||
"""
|
||||
curve_type = marker.curve_type
|
||||
if curve_type == "TANGENT":
|
||||
return 0.0
|
||||
if curve_type == "CIRCULAR":
|
||||
return marker.radius
|
||||
if curve_type == "SPIRAL_CIRCULAR":
|
||||
return (marker.radius, marker.spiral_in_length, 0.0)
|
||||
if curve_type == "CIRCULAR_SPIRAL":
|
||||
return (marker.radius, 0.0, marker.spiral_out_length)
|
||||
# SPIRAL_CIRCULAR_SPIRAL
|
||||
return (marker.radius, marker.spiral_in_length, marker.spiral_out_length)
|
||||
|
||||
|
||||
def _pi_curve_marker_label(marker) -> str:
|
||||
"""Short label for a PI marker's name, reflecting its curve settings."""
|
||||
curve_type = marker.curve_type
|
||||
if curve_type == "TANGENT":
|
||||
return "tangent"
|
||||
if curve_type == "CIRCULAR":
|
||||
return f"R={marker.radius:.2f}"
|
||||
if curve_type == "SPIRAL_CIRCULAR":
|
||||
return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}"
|
||||
if curve_type == "CIRCULAR_SPIRAL":
|
||||
return f"R={marker.radius:.2f}, Lout={marker.spiral_out_length:.2f}"
|
||||
# SPIRAL_CIRCULAR_SPIRAL
|
||||
return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}, Lout={marker.spiral_out_length:.2f}"
|
||||
|
||||
|
||||
class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator):
|
||||
"""Regenerate the alignment using the active PI marker's curve settings.
|
||||
|
||||
@@ -753,19 +790,12 @@ class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator):
|
||||
+ [_world_point_to_local_ifc(ifc, unit_scale, m.location) for m in interior_markers]
|
||||
+ [end]
|
||||
)
|
||||
radii = [
|
||||
(m.bonsai_pi_curve_marker.radius if m.bonsai_pi_curve_marker.curve_type == "CIRCULAR" else 0.0)
|
||||
for m in interior_markers
|
||||
]
|
||||
radii = [_pi_curve_radii_entry(m.bonsai_pi_curve_marker) for m in interior_markers]
|
||||
|
||||
ok, message = _generate_alignment_segments(context, alignment, hpoints, radii)
|
||||
|
||||
marker = marker_obj.bonsai_pi_curve_marker
|
||||
marker_obj.name = (
|
||||
f"PI {marker.pi_index} (R={marker.radius:.2f})"
|
||||
if marker.curve_type == "CIRCULAR"
|
||||
else f"PI {marker.pi_index} (tangent)"
|
||||
)
|
||||
marker_obj.name = f"PI {marker.pi_index} ({_pi_curve_marker_label(marker)})"
|
||||
# _generate_alignment_segments() replaces every IfcAlignmentSegment
|
||||
# with a new one, so a previously-highlighted segment's id is gone —
|
||||
# refreshing it would silently keep showing the old, now-stale
|
||||
@@ -822,7 +852,9 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t
|
||||
immediately generates the alignment with every PI a sharp corner. If
|
||||
there are interior PIs, a marker empty is left at each one — select a
|
||||
marker and use "Apply Curve" (see the Alignments tab panel) to give it a
|
||||
circular curve and regenerate. ESC cancels without creating anything.
|
||||
circular arc, a clothoid spiral-circular/circular-spiral transition, or a
|
||||
symmetric spiral-circular-spiral, and regenerate. ESC cancels without
|
||||
creating anything.
|
||||
|
||||
Numeric Distance/Angle input is available via the D/A keys, same as the
|
||||
rest of Bonsai's polyline tools.
|
||||
|
||||
@@ -286,11 +286,40 @@ class PICurveMarkerProperties(PropertyGroup):
|
||||
items=[
|
||||
("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"),
|
||||
("CIRCULAR", "Circular", "A simple circular arc"),
|
||||
# Spiral-Circular / Circular-Spiral / Spiral-Circular-Spiral are not
|
||||
# implemented yet. See REQUIREMENTS.md §2 step 7 — they need each
|
||||
# segment authored individually (create_layout_segment), which
|
||||
# layout_horizontal_alignment_by_pi_method does not support.
|
||||
(
|
||||
"SPIRAL_CIRCULAR",
|
||||
"Spiral-Circular",
|
||||
"An entry clothoid spiral transitions into the circular arc, which runs to the forward tangent",
|
||||
),
|
||||
(
|
||||
"CIRCULAR_SPIRAL",
|
||||
"Circular-Spiral",
|
||||
"The circular arc leaves the back tangent directly and transitions to the forward tangent via an exit clothoid spiral",
|
||||
),
|
||||
(
|
||||
"SPIRAL_CIRCULAR_SPIRAL",
|
||||
"Spiral-Circular-Spiral",
|
||||
"An entry clothoid spiral, a circular arc, and an exit clothoid spiral, symmetric about the PI",
|
||||
),
|
||||
# Only the clothoid spiral family is supported for now — see
|
||||
# solve_horizontal_alignment_by_pi_method. Other families (Bloss,
|
||||
# cosine, sine, cubic, Helmert) would need their own
|
||||
# curvature-vs-length integrand.
|
||||
],
|
||||
default="TANGENT",
|
||||
)
|
||||
radius: FloatProperty(name="Radius", default=100.0, min=0.0001, unit="LENGTH")
|
||||
spiral_in_length: FloatProperty(
|
||||
name="Entry Spiral Length",
|
||||
description="Length of the clothoid spiral ahead of the circular arc",
|
||||
default=100.0,
|
||||
min=0.0001,
|
||||
unit="LENGTH",
|
||||
)
|
||||
spiral_out_length: FloatProperty(
|
||||
name="Exit Spiral Length",
|
||||
description="Length of the clothoid spiral following the circular arc",
|
||||
default=100.0,
|
||||
min=0.0001,
|
||||
unit="LENGTH",
|
||||
)
|
||||
|
||||
@@ -219,8 +219,12 @@ class ALIGN_PT_alignment_authoring(Panel):
|
||||
pi_data = marker.bonsai_pi_curve_marker
|
||||
box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS")
|
||||
box.prop(pi_data, "curve_type")
|
||||
if pi_data.curve_type == "CIRCULAR":
|
||||
if pi_data.curve_type != "TANGENT":
|
||||
box.prop(pi_data, "radius")
|
||||
if pi_data.curve_type in {"SPIRAL_CIRCULAR", "SPIRAL_CIRCULAR_SPIRAL"}:
|
||||
box.prop(pi_data, "spiral_in_length")
|
||||
if pi_data.curve_type in {"CIRCULAR_SPIRAL", "SPIRAL_CIRCULAR_SPIRAL"}:
|
||||
box.prop(pi_data, "spiral_out_length")
|
||||
box.operator("align.apply_pi_curve", icon="CHECKMARK")
|
||||
else:
|
||||
box.label(text="Select a PI marker to define its curve", icon="INFO")
|
||||
|
||||
@@ -34,7 +34,11 @@ This API does not determine alignment parameters based on rules, such as minimum
|
||||
This API is under development and subject to code breaking changes in the future.
|
||||
|
||||
Presently, this API supports:
|
||||
1. Creating alignments, both horizontal and vertical, using the PI method. Alignment definition can be read from a CSV file.
|
||||
1. Creating alignments, both horizontal and vertical, using the PI method, including clothoid
|
||||
transition spirals in the horizontal layout. The horizontal PI solve is also available as a
|
||||
pure geometric computation (solve_horizontal_alignment_by_pi_method) for callers that need
|
||||
segment parameters without writing to a file, such as interactive editors. Alignment
|
||||
definition can be read from a CSV file.
|
||||
2. Creating alignments segment by segment.
|
||||
3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve)
|
||||
4. Explicit definition of stationing, including station equations and reverse (decreasing) stationing
|
||||
@@ -42,7 +46,8 @@ Presently, this API supports:
|
||||
6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations
|
||||
|
||||
Future versions of this API may support:
|
||||
1. Defining alignments using the PI method, including transition spirals
|
||||
1. Transition spiral families other than the clothoid (Bloss, cosine, sine, cubic, Helmert) in
|
||||
the PI method solver.
|
||||
2. Updating horizontal curve definitions by revising transition spiral parameters and circular curve radii
|
||||
3. Updating vertical curve definitions by revising horizontal length of curves
|
||||
4. Removing a segment at any location along a curve
|
||||
@@ -94,6 +99,12 @@ from .layout_vertical_alignment_by_pi_method import (
|
||||
)
|
||||
from .name_segments import name_segments
|
||||
from .segment_vertices import segment_vertices
|
||||
from .solve_horizontal_alignment_by_pi_method import (
|
||||
HorizontalSegmentDefinition,
|
||||
compute_clothoid_end,
|
||||
compute_horizontal_segment_end,
|
||||
solve_horizontal_alignment_by_pi_method,
|
||||
)
|
||||
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
|
||||
from .update_end_point import update_end_point
|
||||
from .update_fallback_position import update_fallback_position
|
||||
@@ -101,6 +112,10 @@ from .update_key_point_referents import update_key_point_referents
|
||||
from .util import *
|
||||
|
||||
__all__ = [
|
||||
"HorizontalSegmentDefinition",
|
||||
"compute_clothoid_end",
|
||||
"compute_horizontal_segment_end",
|
||||
"solve_horizontal_alignment_by_pi_method",
|
||||
"add_positioning_referent",
|
||||
"add_stationing_referent",
|
||||
"add_vertical_layout",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
@@ -29,7 +29,7 @@ def create_by_pi_method(
|
||||
file: ifcopenshell.file,
|
||||
name: str,
|
||||
hpoints: Sequence[Sequence[float]],
|
||||
radii: Sequence[float],
|
||||
radii: Sequence[Union[float, Sequence[float]]],
|
||||
vpoints: Sequence[Sequence[float]] = None,
|
||||
lengths: Sequence[float] = None,
|
||||
start_station: Optional[float] = None,
|
||||
@@ -38,9 +38,13 @@ def create_by_pi_method(
|
||||
Create an alignment using the PI layout method for both horizontal and vertical alignments.
|
||||
If vpoints and lengths are omitted, only a horizontal alignment is created.
|
||||
|
||||
Each element of radii is either a circular curve radius R, or a (R, Lin, Lout) sequence with
|
||||
clothoid spiral transition curve lengths ahead of and following the circular curve (see
|
||||
layout_horizontal_alignment_by_pi_method / solve_horizontal_alignment_by_pi_method).
|
||||
|
||||
:param name: value for Name attribute
|
||||
:param points: (X,Y) pairs denoting the location of the horizontal PIs, including start and end
|
||||
:param radii: radii values to use for transition
|
||||
:param radii: radii values to use for transition, optionally with spiral transition lengths
|
||||
:param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end.
|
||||
:param lengths: parabolic vertical curve horizontal length values to use for transition
|
||||
:param start_station: if given, the starting station value. A STATION IfcReferent named
|
||||
|
||||
+73
-97
@@ -16,128 +16,104 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method import (
|
||||
HorizontalSegmentDefinition,
|
||||
solve_horizontal_alignment_by_pi_method,
|
||||
)
|
||||
|
||||
|
||||
def _create_cant_segment(
|
||||
file: ifcopenshell.file,
|
||||
cant_layout: entity_instance,
|
||||
segment: HorizontalSegmentDefinition,
|
||||
) -> None:
|
||||
"""
|
||||
Appends the cant segment corresponding to one horizontal segment definition. Cant is applied
|
||||
to a single rail (the rail on the outside of the curve). Constant cant is modeled with
|
||||
CONSTANTCANT and varying cant (over a transition curve) with LINEARTRANSITION.
|
||||
"""
|
||||
is_transition = segment.start_cant != segment.end_cant
|
||||
if segment.raise_left_rail:
|
||||
start_left, start_right = segment.start_cant, 0.0
|
||||
end_left, end_right = segment.end_cant, 0.0
|
||||
else:
|
||||
start_left, start_right = 0.0, segment.start_cant
|
||||
end_left, end_right = 0.0, segment.end_cant
|
||||
|
||||
design_parameters = file.createIfcAlignmentCantSegment(
|
||||
StartTag=None,
|
||||
EndTag=None,
|
||||
StartDistAlong=segment.start_dist_along,
|
||||
HorizontalLength=segment.segment_length,
|
||||
StartCantLeft=start_left,
|
||||
EndCantLeft=end_left if is_transition else None,
|
||||
StartCantRight=start_right,
|
||||
EndCantRight=end_right if is_transition else None,
|
||||
PredefinedType="LINEARTRANSITION" if is_transition else "CONSTANTCANT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, cant_layout, design_parameters)
|
||||
|
||||
|
||||
def layout_horizontal_alignment_by_pi_method(
|
||||
file: ifcopenshell.file, layout: entity_instance, hpoints: Sequence[Sequence[float]], radii: Sequence[float]
|
||||
file: ifcopenshell.file,
|
||||
layout: entity_instance,
|
||||
hpoints: Sequence[Sequence[float]],
|
||||
radii: Sequence[Union[float, Sequence[float]]],
|
||||
cant_layout: Optional[entity_instance] = None,
|
||||
cants: Optional[Sequence[float]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Appends IfcAlignmentHorizontalSegment to a previously defined IfcAlignmentHorizontal using the PI layout method.
|
||||
The zero length segment is updated.
|
||||
|
||||
The geometry is computed by solve_horizontal_alignment_by_pi_method; see that function for the
|
||||
meaning of hpoints, radii, and cants. This function writes the resulting segment definitions to
|
||||
the layout.
|
||||
|
||||
Optionally, a cant profile can be created alongside the horizontal layout. Cant segments are
|
||||
created one-for-one with the horizontal segments: zero cant on tangent runs (CONSTANTCANT),
|
||||
linearly varying cant over spiral transitions (LINEARTRANSITION), and constant cant over
|
||||
circular curves (CONSTANTCANT). The cant is applied to the rail on the outside of the curve.
|
||||
Cant values are expressed in the project length unit. IfcAlignmentCant.RailHeadDistance is
|
||||
taken from the cant_layout. Curves with a non-zero cant require entry and exit spiral
|
||||
transition curves so the cant profile is continuous.
|
||||
|
||||
:param file: file
|
||||
:param layout: An IfcAlignmentHorizontal layout
|
||||
:param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE).
|
||||
:param radii: radius values to use for transition
|
||||
:param radii: radius values to use for transition, optionally with clothoid spiral transition lengths
|
||||
as (R, Lin, Lout)
|
||||
:param cant_layout: An IfcAlignmentCant layout to receive the cant segments. Required when cants is provided.
|
||||
:param cants: cant values, one per PI curve, applied to the outer rail. Required when cant_layout is provided.
|
||||
:return: None
|
||||
"""
|
||||
if not (len(hpoints) - 2 == len(radii)):
|
||||
raise ValueError("radii should have two fewer elements that hpoints")
|
||||
if (cant_layout is None) != (cants is None):
|
||||
raise ValueError("cant_layout and cants must be provided together")
|
||||
|
||||
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
|
||||
|
||||
xBT, yBT = hpoints[0]
|
||||
xPI, yPI = hpoints[1]
|
||||
|
||||
i = 1
|
||||
|
||||
for radius in radii:
|
||||
# back tangent
|
||||
dxBT = xPI - xBT
|
||||
dyBT = yPI - yBT
|
||||
angleBT = math.atan2(dyBT, dxBT)
|
||||
lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT)
|
||||
|
||||
# forward tangent
|
||||
i += 1
|
||||
xFT, yFT = hpoints[i]
|
||||
dxFT = xFT - xPI
|
||||
dyFT = yFT - yPI
|
||||
angleFT = math.atan2(dyFT, dxFT)
|
||||
|
||||
delta = angleFT - angleBT
|
||||
|
||||
tangent = abs(radius * math.tan(delta / 2))
|
||||
|
||||
lc = abs(radius * delta)
|
||||
|
||||
radius *= delta / abs(delta)
|
||||
|
||||
xPC = xPI - tangent * math.cos(angleBT)
|
||||
yPC = yPI - tangent * math.sin(angleBT)
|
||||
|
||||
xPT = xPI + tangent * math.cos(angleFT)
|
||||
yPT = yPI + tangent * math.sin(angleFT)
|
||||
|
||||
tangent_run = lengthBT - tangent
|
||||
|
||||
# create back tangent run
|
||||
if 1.0e-03 < tangent_run:
|
||||
pt = file.createIfcCartesianPoint(
|
||||
Coordinates=(xBT, yBT),
|
||||
)
|
||||
design_parameters = file.createIfcAlignmentHorizontalSegment(
|
||||
StartTag=None,
|
||||
EndTag=None,
|
||||
StartPoint=pt,
|
||||
StartDirection=angleBT / angle_unit_scale,
|
||||
StartRadiusOfCurvature=0.0,
|
||||
EndRadiusOfCurvature=0.0,
|
||||
SegmentLength=tangent_run,
|
||||
GravityCenterLineHeight=None,
|
||||
PredefinedType="LINE",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
|
||||
|
||||
# create circular curve
|
||||
if radius != 0.0:
|
||||
pc = file.createIfcCartesianPoint(
|
||||
Coordinates=(xPC, yPC),
|
||||
)
|
||||
design_parameters = file.createIfcAlignmentHorizontalSegment(
|
||||
StartTag=None,
|
||||
EndTag=None,
|
||||
StartPoint=pc,
|
||||
StartDirection=angleBT / angle_unit_scale,
|
||||
StartRadiusOfCurvature=float(radius),
|
||||
EndRadiusOfCurvature=float(radius),
|
||||
SegmentLength=lc,
|
||||
GravityCenterLineHeight=None,
|
||||
PredefinedType="CIRCULARARC",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
|
||||
|
||||
xBT = xPT
|
||||
yBT = yPT
|
||||
xPI = xFT
|
||||
yPI = yFT
|
||||
|
||||
# done processing radii
|
||||
# create last tangent run
|
||||
dx = xPI - xBT
|
||||
dy = yPI - yBT
|
||||
angleBT = math.atan2(dy, dx)
|
||||
tangent_run = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if 1.0e-03 < tangent_run:
|
||||
pt = file.createIfcCartesianPoint(Coordinates=(xBT, yBT))
|
||||
|
||||
for segment in solve_horizontal_alignment_by_pi_method(hpoints, radii, cants):
|
||||
if cant_layout is not None:
|
||||
_create_cant_segment(file, cant_layout, segment)
|
||||
start_point = file.createIfcCartesianPoint(
|
||||
Coordinates=segment.start_point,
|
||||
)
|
||||
design_parameters = file.createIfcAlignmentHorizontalSegment(
|
||||
StartTag=None,
|
||||
EndTag=None,
|
||||
StartPoint=pt,
|
||||
StartDirection=angleBT / angle_unit_scale,
|
||||
StartRadiusOfCurvature=0.0,
|
||||
EndRadiusOfCurvature=0.0,
|
||||
SegmentLength=tangent_run,
|
||||
StartPoint=start_point,
|
||||
StartDirection=segment.start_direction / angle_unit_scale,
|
||||
StartRadiusOfCurvature=segment.start_radius_of_curvature,
|
||||
EndRadiusOfCurvature=segment.end_radius_of_curvature,
|
||||
SegmentLength=segment.segment_length,
|
||||
GravityCenterLineHeight=None,
|
||||
PredefinedType="LINE",
|
||||
PredefinedType=segment.predefined_type,
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Ported from IfcOpenShell/IfcOpenShell#8833 by Petru Conduraru (BIMvoice), onto this
|
||||
# repository's PI-method API surface.
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import NamedTuple, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Gauss-Legendre quadrature nodes and weights used to integrate the clothoid position functions
|
||||
_gauss_legendre_points = np.polynomial.legendre.leggauss(32)
|
||||
|
||||
|
||||
class HorizontalSegmentDefinition(NamedTuple):
|
||||
"""
|
||||
Parameters of one horizontal alignment segment produced by the PI method solver.
|
||||
|
||||
The fields mirror IfcAlignmentHorizontalSegment so a definition can be written to a file
|
||||
without further computation, but the definition itself is independent of any file. Directions
|
||||
are in radians, lengths and coordinates in the caller's length unit.
|
||||
"""
|
||||
|
||||
start_point: tuple[float, float]
|
||||
"""(X, Y) of the segment start"""
|
||||
|
||||
start_direction: float
|
||||
"""direction of the tangent at the segment start, in radians"""
|
||||
|
||||
start_radius_of_curvature: float
|
||||
"""radius at the segment start; 0.0 for straight, positive curving left, negative curving right"""
|
||||
|
||||
end_radius_of_curvature: float
|
||||
"""radius at the segment end, with the same sign convention as start_radius_of_curvature"""
|
||||
|
||||
segment_length: float
|
||||
"""length of the segment along the curve"""
|
||||
|
||||
predefined_type: str
|
||||
"""IfcAlignmentHorizontalSegmentTypeEnum value: LINE, CLOTHOID, or CIRCULARARC"""
|
||||
|
||||
start_dist_along: float = 0.0
|
||||
"""distance along the alignment at the segment start"""
|
||||
|
||||
start_cant: float = 0.0
|
||||
"""cant at the segment start, applied to the rail on the outside of the curve"""
|
||||
|
||||
end_cant: float = 0.0
|
||||
"""cant at the segment end, applied to the rail on the outside of the curve"""
|
||||
|
||||
raise_left_rail: bool = False
|
||||
"""True when the outside of the curve is the left rail (a curve to the right)"""
|
||||
|
||||
|
||||
def compute_clothoid_end(length: float, start_curvature: float, end_curvature: float) -> tuple[float, float, float]:
|
||||
"""
|
||||
Computes the end point of a clothoid transition whose curvature varies linearly from
|
||||
start_curvature to end_curvature over length.
|
||||
|
||||
The result (dx, dy, dtheta) is relative to the start of the transition, with the x-axis in the
|
||||
direction of the tangent at the start. Curvatures are signed: positive curving left, negative
|
||||
curving right. The position is computed with 32 point Gauss-Legendre quadrature of the clothoid
|
||||
integrals.
|
||||
|
||||
:param length: length of the transition, measured along the curve
|
||||
:param start_curvature: curvature at the start (1/R, 0.0 for a straight)
|
||||
:param end_curvature: curvature at the end (1/R, 0.0 for a straight)
|
||||
:return: (dx, dy, dtheta) displacement and change in tangent direction over the transition
|
||||
"""
|
||||
u, w = _gauss_legendre_points
|
||||
l = 0.5 * length * (u + 1.0) # map quadrature points from (-1,1) onto (0,length)
|
||||
theta = start_curvature * l + (end_curvature - start_curvature) * l * l / (2.0 * length)
|
||||
dx = 0.5 * length * float(np.sum(w * np.cos(theta)))
|
||||
dy = 0.5 * length * float(np.sum(w * np.sin(theta)))
|
||||
dtheta = 0.5 * (start_curvature + end_curvature) * length
|
||||
return dx, dy, dtheta
|
||||
|
||||
|
||||
def compute_horizontal_segment_end(segment: HorizontalSegmentDefinition) -> tuple[float, float, float]:
|
||||
"""
|
||||
Computes the end point and end direction of a horizontal segment definition.
|
||||
|
||||
Useful for checking position and direction continuity between consecutive segments: the result
|
||||
for one segment should match the start_point and start_direction of the next.
|
||||
|
||||
:param segment: the segment definition
|
||||
:return: (x, y, direction) at the end of the segment, direction in radians
|
||||
"""
|
||||
x, y = segment.start_point
|
||||
direction = segment.start_direction
|
||||
length = segment.segment_length
|
||||
|
||||
if segment.predefined_type == "LINE":
|
||||
return (x + length * math.cos(direction), y + length * math.sin(direction), direction)
|
||||
|
||||
start_curvature = 1.0 / segment.start_radius_of_curvature if segment.start_radius_of_curvature != 0.0 else 0.0
|
||||
end_curvature = 1.0 / segment.end_radius_of_curvature if segment.end_radius_of_curvature != 0.0 else 0.0
|
||||
|
||||
if segment.predefined_type == "CIRCULARARC":
|
||||
dtheta = start_curvature * length
|
||||
dx = math.sin(dtheta) / start_curvature
|
||||
dy = (1.0 - math.cos(dtheta)) / start_curvature
|
||||
elif segment.predefined_type == "CLOTHOID":
|
||||
dx, dy, dtheta = compute_clothoid_end(length, start_curvature, end_curvature)
|
||||
else:
|
||||
raise NotImplementedError(f"unsupported predefined type '{segment.predefined_type}'")
|
||||
|
||||
return (
|
||||
x + dx * math.cos(direction) - dy * math.sin(direction),
|
||||
y + dx * math.sin(direction) + dy * math.cos(direction),
|
||||
direction + dtheta,
|
||||
)
|
||||
|
||||
|
||||
def solve_horizontal_alignment_by_pi_method(
|
||||
hpoints: Sequence[Sequence[float]],
|
||||
radii: Sequence[Union[float, Sequence[float]]],
|
||||
cants: Optional[Sequence[float]] = None,
|
||||
) -> list[HorizontalSegmentDefinition]:
|
||||
"""
|
||||
Solves a horizontal alignment defined by the PI layout method into a continuous sequence of
|
||||
segment definitions.
|
||||
|
||||
This is a pure geometric computation: no file is read or written. Use
|
||||
layout_horizontal_alignment_by_pi_method to write the solution to an IfcAlignmentHorizontal
|
||||
layout, or consume the returned definitions directly, for example to preview an alignment in
|
||||
an interactive editor before committing it to a file.
|
||||
|
||||
Each element of radii defines the transition at the corresponding PI and is either:
|
||||
|
||||
R - radius of a circular curve (tangent runs connect directly to the circular curve), or
|
||||
|
||||
(R, Lin, Lout) - radius of a circular curve with clothoid spiral transition curves of length
|
||||
Lin ahead of the curve and Lout following the curve. When spiral transitions are used the
|
||||
circular curve shifts inward relative to the tangent runs so the tangent runs, spirals, and
|
||||
circular curve are continuous in position and direction. Lin and Lout can be 0.0 for a
|
||||
spiral-less connection on that end of the curve.
|
||||
|
||||
Only clothoid spirals are supported at present (other spiral families - Bloss, cosine, sine,
|
||||
cubic, Helmert - would need their own curvature-vs-length integrand substituted into the
|
||||
displacement composition below; the tangent-distance projection itself is spiral-family
|
||||
agnostic).
|
||||
|
||||
If cants is provided, each definition also carries the cant at the segment start and end,
|
||||
applied to the rail on the outside of the curve: zero cant on tangent runs, linearly varying
|
||||
cant over spiral transitions, and constant cant over circular curves. Because every horizontal
|
||||
segment carries its own cant values, a cant layout built from the definitions is one-for-one
|
||||
with the horizontal layout. Curves with a non-zero cant require entry and exit spiral
|
||||
transition curves so the cant profile is continuous.
|
||||
|
||||
:param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE).
|
||||
:param radii: radius values to use for transition, optionally with spiral transition lengths
|
||||
:param cants: cant values, one per PI curve, applied to the outer rail
|
||||
:return: list of segment definitions, in order, continuous in position and direction
|
||||
"""
|
||||
if not (len(hpoints) - 2 == len(radii)):
|
||||
raise ValueError("radii should have two fewer elements that hpoints")
|
||||
|
||||
if cants is not None and not (len(cants) == len(radii)):
|
||||
raise ValueError("cants should have the same number of elements as radii")
|
||||
|
||||
segments: list[HorizontalSegmentDefinition] = []
|
||||
|
||||
xBT, yBT = hpoints[0]
|
||||
xPI, yPI = hpoints[1]
|
||||
|
||||
i = 1
|
||||
dist_along = 0.0 # distance along the horizontal alignment at the start of the next segment
|
||||
|
||||
for curve_index, curve in enumerate(radii):
|
||||
if isinstance(curve, (int, float)):
|
||||
radius = float(curve)
|
||||
entry_length = 0.0
|
||||
exit_length = 0.0
|
||||
else:
|
||||
if len(curve) != 3:
|
||||
raise ValueError("each radii element should be a radius R or a (R, Lin, Lout) sequence")
|
||||
radius, entry_length, exit_length = (float(v) for v in curve)
|
||||
if radius == 0.0 and (entry_length != 0.0 or exit_length != 0.0):
|
||||
raise ValueError("spiral transition lengths require a non-zero radius")
|
||||
|
||||
cant = float(cants[curve_index]) if cants is not None else 0.0
|
||||
if cant != 0.0 and (entry_length == 0.0 or exit_length == 0.0):
|
||||
raise ValueError(
|
||||
"curves with a non-zero cant require entry and exit spiral transition curves; "
|
||||
"otherwise the cant profile is discontinuous"
|
||||
)
|
||||
|
||||
# back tangent
|
||||
dxBT = xPI - xBT
|
||||
dyBT = yPI - yBT
|
||||
angleBT = math.atan2(dyBT, dxBT)
|
||||
lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT)
|
||||
|
||||
# forward tangent
|
||||
i += 1
|
||||
xFT, yFT = hpoints[i]
|
||||
dxFT = xFT - xPI
|
||||
dyFT = yFT - yPI
|
||||
angleFT = math.atan2(dyFT, dxFT)
|
||||
|
||||
delta = angleFT - angleBT
|
||||
|
||||
if entry_length == 0.0 and exit_length == 0.0:
|
||||
# tangent runs connect directly to the circular curve
|
||||
tangent = abs(radius * math.tan(delta / 2))
|
||||
|
||||
lc = abs(radius * delta)
|
||||
|
||||
radius *= delta / abs(delta)
|
||||
|
||||
xPC = xPI - tangent * math.cos(angleBT)
|
||||
yPC = yPI - tangent * math.sin(angleBT)
|
||||
|
||||
xPT = xPI + tangent * math.cos(angleFT)
|
||||
yPT = yPI + tangent * math.sin(angleFT)
|
||||
|
||||
tangent_run = lengthBT - tangent
|
||||
|
||||
# back tangent run
|
||||
if 1.0e-03 < tangent_run:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(xBT, yBT),
|
||||
start_direction=angleBT,
|
||||
start_radius_of_curvature=0.0,
|
||||
end_radius_of_curvature=0.0,
|
||||
segment_length=tangent_run,
|
||||
predefined_type="LINE",
|
||||
start_dist_along=dist_along,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += tangent_run
|
||||
|
||||
# circular curve
|
||||
if radius != 0.0:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(xPC, yPC),
|
||||
start_direction=angleBT,
|
||||
start_radius_of_curvature=float(radius),
|
||||
end_radius_of_curvature=float(radius),
|
||||
segment_length=lc,
|
||||
predefined_type="CIRCULARARC",
|
||||
start_dist_along=dist_along,
|
||||
start_cant=cant,
|
||||
end_cant=cant,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += lc
|
||||
else:
|
||||
# tangent runs connect to the circular curve with clothoid spiral transition curves.
|
||||
# normalize the deflection angle onto (-pi, pi)
|
||||
delta = math.atan2(math.sin(delta), math.cos(delta))
|
||||
if delta == 0.0:
|
||||
raise ValueError("PI deflection angle is zero; spiral transitions cannot be created")
|
||||
|
||||
R = abs(radius)
|
||||
s = 1.0 if 0.0 < delta else -1.0 # +1 curve to the left, -1 curve to the right
|
||||
theta1 = entry_length / (2.0 * R) # deflection of the entry spiral
|
||||
theta2 = exit_length / (2.0 * R) # deflection of the exit spiral
|
||||
theta_c = abs(delta) - theta1 - theta2 # deflection of the circular curve
|
||||
if theta_c < 0.0:
|
||||
raise ValueError(
|
||||
"spiral transition curves are too long; their combined deflection exceeds the PI deflection angle"
|
||||
)
|
||||
lc = R * theta_c
|
||||
|
||||
# compose the displacement from the start of the entry spiral (TS) to the end of the
|
||||
# exit spiral (ST), in a frame with the x-axis along the back tangent.
|
||||
# pieces are computed for a curve to the left and mirrored by s.
|
||||
pieces = []
|
||||
if 0.0 < entry_length:
|
||||
pieces.append(compute_clothoid_end(entry_length, 0.0, 1.0 / R))
|
||||
pieces.append((R * math.sin(theta_c), R * (1.0 - math.cos(theta_c)), theta_c))
|
||||
if 0.0 < exit_length:
|
||||
pieces.append(compute_clothoid_end(exit_length, 1.0 / R, 0.0))
|
||||
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
direction = 0.0
|
||||
for dx_, dy_, dtheta_ in pieces:
|
||||
x += dx_ * math.cos(direction) - s * dy_ * math.sin(direction)
|
||||
y += dx_ * math.sin(direction) + s * dy_ * math.cos(direction)
|
||||
direction += s * dtheta_
|
||||
|
||||
# locate TS on the back tangent and ST on the forward tangent so that the curve ends on
|
||||
# the forward tangent. this accounts for the inward shift of the circular curve.
|
||||
ts_to_pi = x - y / math.tan(delta) # distance from TS to the PI, along the back tangent
|
||||
pi_to_st = y / math.sin(delta) # distance from the PI to ST, along the forward tangent
|
||||
|
||||
tangent_run = lengthBT - ts_to_pi
|
||||
|
||||
# back tangent run
|
||||
if 1.0e-03 < tangent_run:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(xBT, yBT),
|
||||
start_direction=angleBT,
|
||||
start_radius_of_curvature=0.0,
|
||||
end_radius_of_curvature=0.0,
|
||||
segment_length=tangent_run,
|
||||
predefined_type="LINE",
|
||||
start_dist_along=dist_along,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += tangent_run
|
||||
|
||||
signed_radius = s * R
|
||||
cur_x = xPI - ts_to_pi * math.cos(angleBT)
|
||||
cur_y = yPI - ts_to_pi * math.sin(angleBT)
|
||||
cur_direction = angleBT
|
||||
|
||||
# entry spiral
|
||||
if 0.0 < entry_length:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(cur_x, cur_y),
|
||||
start_direction=cur_direction,
|
||||
start_radius_of_curvature=0.0,
|
||||
end_radius_of_curvature=signed_radius,
|
||||
segment_length=entry_length,
|
||||
predefined_type="CLOTHOID",
|
||||
start_dist_along=dist_along,
|
||||
start_cant=0.0,
|
||||
end_cant=cant,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += entry_length
|
||||
|
||||
dx_, dy_, dtheta_ = compute_clothoid_end(entry_length, 0.0, 1.0 / R)
|
||||
cur_x += dx_ * math.cos(cur_direction) - s * dy_ * math.sin(cur_direction)
|
||||
cur_y += dx_ * math.sin(cur_direction) + s * dy_ * math.cos(cur_direction)
|
||||
cur_direction += s * dtheta_
|
||||
|
||||
# circular curve
|
||||
if 1.0e-03 < lc:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(cur_x, cur_y),
|
||||
start_direction=cur_direction,
|
||||
start_radius_of_curvature=signed_radius,
|
||||
end_radius_of_curvature=signed_radius,
|
||||
segment_length=lc,
|
||||
predefined_type="CIRCULARARC",
|
||||
start_dist_along=dist_along,
|
||||
start_cant=cant,
|
||||
end_cant=cant,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += lc
|
||||
|
||||
cur_x += R * math.sin(theta_c) * math.cos(cur_direction) - s * R * (1.0 - math.cos(theta_c)) * math.sin(
|
||||
cur_direction
|
||||
)
|
||||
cur_y += R * math.sin(theta_c) * math.sin(cur_direction) + s * R * (1.0 - math.cos(theta_c)) * math.cos(
|
||||
cur_direction
|
||||
)
|
||||
cur_direction += s * theta_c
|
||||
|
||||
# exit spiral
|
||||
if 0.0 < exit_length:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(cur_x, cur_y),
|
||||
start_direction=cur_direction,
|
||||
start_radius_of_curvature=signed_radius,
|
||||
end_radius_of_curvature=0.0,
|
||||
segment_length=exit_length,
|
||||
predefined_type="CLOTHOID",
|
||||
start_dist_along=dist_along,
|
||||
start_cant=cant,
|
||||
end_cant=0.0,
|
||||
raise_left_rail=delta < 0.0,
|
||||
)
|
||||
)
|
||||
dist_along += exit_length
|
||||
|
||||
xPT = xPI + pi_to_st * math.cos(angleFT)
|
||||
yPT = yPI + pi_to_st * math.sin(angleFT)
|
||||
|
||||
xBT = xPT
|
||||
yBT = yPT
|
||||
xPI = xFT
|
||||
yPI = yFT
|
||||
|
||||
# done processing radii
|
||||
# last tangent run
|
||||
dx = xPI - xBT
|
||||
dy = yPI - yBT
|
||||
angleBT = math.atan2(dy, dx)
|
||||
tangent_run = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if 1.0e-03 < tangent_run:
|
||||
segments.append(
|
||||
HorizontalSegmentDefinition(
|
||||
start_point=(xBT, yBT),
|
||||
start_direction=angleBT,
|
||||
start_radius_of_curvature=0.0,
|
||||
end_radius_of_curvature=0.0,
|
||||
segment_length=tangent_run,
|
||||
predefined_type="LINE",
|
||||
start_dist_along=dist_along,
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.validate
|
||||
from ifcopenshell import ifcopenshell_wrapper
|
||||
|
||||
try:
|
||||
ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
IFC4X3_AVAILABLE = True
|
||||
except RuntimeError:
|
||||
IFC4X3_AVAILABLE = False
|
||||
|
||||
|
||||
def _create_file():
|
||||
file = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
return file
|
||||
|
||||
|
||||
def _reference_clothoid_end(length, start_curvature, end_curvature, steps=20000):
|
||||
"""Composite Simpson integration of the clothoid position functions, as an independent check."""
|
||||
l = np.linspace(0.0, length, 2 * steps + 1)
|
||||
theta = start_curvature * l + (end_curvature - start_curvature) * l * l / (2.0 * length)
|
||||
h = length / (2.0 * steps)
|
||||
weights = np.ones(2 * steps + 1)
|
||||
weights[1:-1:2] = 4.0
|
||||
weights[2:-1:2] = 2.0
|
||||
dx = h / 3.0 * float(np.sum(weights * np.cos(theta)))
|
||||
dy = h / 3.0 * float(np.sum(weights * np.sin(theta)))
|
||||
return dx, dy
|
||||
|
||||
|
||||
def test_compute_clothoid_end():
|
||||
for length, k1, k2 in [(200.0, 0.0, 1.0 / 1000.0), (150.0, 1.0 / 1000.0, 0.0), (120.0, -1.0 / 800.0, 1.0 / 500.0)]:
|
||||
dx, dy, dtheta = ifcopenshell.api.alignment.compute_clothoid_end(length, k1, k2)
|
||||
ref_dx, ref_dy = _reference_clothoid_end(length, k1, k2)
|
||||
assert dx == pytest.approx(ref_dx, abs=1.0e-12)
|
||||
assert dy == pytest.approx(ref_dy, abs=1.0e-12)
|
||||
assert dtheta == pytest.approx(0.5 * (k1 + k2) * length)
|
||||
|
||||
# signed curvatures mirror the unsigned result
|
||||
dx, dy, dtheta = ifcopenshell.api.alignment.compute_clothoid_end(200.0, 0.0, 1.0 / 1000.0)
|
||||
mx, my, mtheta = ifcopenshell.api.alignment.compute_clothoid_end(200.0, 0.0, -1.0 / 1000.0)
|
||||
assert mx == pytest.approx(dx)
|
||||
assert my == pytest.approx(-dy)
|
||||
assert mtheta == pytest.approx(-dtheta)
|
||||
|
||||
|
||||
def test_solve_produces_continuous_segments():
|
||||
hpoints = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
|
||||
radii = [(1000.0, 200.0, 150.0), (1250.0, 180.0, 180.0), (950.0, 0.0, 120.0)]
|
||||
|
||||
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, radii)
|
||||
|
||||
expected_types = [
|
||||
"LINE",
|
||||
"CLOTHOID",
|
||||
"CIRCULARARC",
|
||||
"CLOTHOID",
|
||||
"LINE",
|
||||
"CLOTHOID",
|
||||
"CIRCULARARC",
|
||||
"CLOTHOID",
|
||||
"LINE",
|
||||
"CIRCULARARC",
|
||||
"CLOTHOID",
|
||||
"LINE",
|
||||
]
|
||||
assert [s.predefined_type for s in segments] == expected_types
|
||||
|
||||
# the solution starts at the POB, in the direction of the first PI
|
||||
assert segments[0].start_point == pytest.approx((500.0, 2500.0))
|
||||
assert segments[0].start_direction == pytest.approx(math.atan2(660.0 - 2500.0, 3340.0 - 500.0))
|
||||
|
||||
# spirals run from zero curvature to the curve radius and vice versa
|
||||
entry_spiral = segments[1]
|
||||
assert entry_spiral.start_radius_of_curvature == 0.0
|
||||
assert entry_spiral.end_radius_of_curvature == pytest.approx(1000.0) # positive, curve to the left
|
||||
assert entry_spiral.segment_length == pytest.approx(200.0)
|
||||
exit_spiral = segments[3]
|
||||
assert exit_spiral.start_radius_of_curvature == pytest.approx(1000.0)
|
||||
assert exit_spiral.end_radius_of_curvature == 0.0
|
||||
assert exit_spiral.segment_length == pytest.approx(150.0)
|
||||
assert segments[5].end_radius_of_curvature == pytest.approx(-1250.0) # curve to the right
|
||||
|
||||
# each segment ends exactly where the next one starts, in position and direction
|
||||
dist_along = 0.0
|
||||
for segment, next_segment in zip(segments[:-1], segments[1:]):
|
||||
assert segment.start_dist_along == pytest.approx(dist_along)
|
||||
end_x, end_y, end_direction = ifcopenshell.api.alignment.compute_horizontal_segment_end(segment)
|
||||
assert end_x == pytest.approx(next_segment.start_point[0], abs=1.0e-9)
|
||||
assert end_y == pytest.approx(next_segment.start_point[1], abs=1.0e-9)
|
||||
direction_gap = end_direction - next_segment.start_direction
|
||||
assert math.atan2(math.sin(direction_gap), math.cos(direction_gap)) == pytest.approx(0.0, abs=1.0e-12)
|
||||
dist_along += segment.segment_length
|
||||
|
||||
|
||||
def test_solve_plain_radius_matches_spiral_free_tuple():
|
||||
hpoints = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (8480.0, 2010.0)]
|
||||
|
||||
segments1 = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [1000.0, 1250.0])
|
||||
segments2 = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(
|
||||
hpoints, [(1000.0, 0.0, 0.0), (1250.0, 0.0, 0.0)]
|
||||
)
|
||||
assert segments1 == segments2
|
||||
|
||||
|
||||
def test_solve_errors():
|
||||
hpoints = [(0.0, 0.0), (1000.0, 0.0), (2000.0, 1000.0)]
|
||||
|
||||
with pytest.raises(ValueError): # radii count mismatch
|
||||
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [500.0, 500.0])
|
||||
with pytest.raises(ValueError): # malformed radii element
|
||||
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(500.0, 50.0)])
|
||||
with pytest.raises(ValueError): # spiral lengths without a radius
|
||||
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(0.0, 50.0, 50.0)])
|
||||
with pytest.raises(ValueError): # spirals deflect more than the PI deflection angle
|
||||
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(500.0, 5000.0, 5000.0)])
|
||||
with pytest.raises(ValueError): # zero deflection angle
|
||||
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(
|
||||
[(0.0, 0.0), (1000.0, 0.0), (2000.0, 0.0)], [(500.0, 50.0, 50.0)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
|
||||
def test_author_transition_curve_alignment():
|
||||
"""
|
||||
End-to-end example: author a tangent -> clothoid -> circular arc -> clothoid -> tangent
|
||||
alignment, then check the written geometry for continuity with the geometry engine and
|
||||
validate the file against the schema and express rules.
|
||||
"""
|
||||
file = _create_file()
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create_by_pi_method(
|
||||
file,
|
||||
"TestAlignment",
|
||||
[(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0)],
|
||||
[(1000.0, 200.0, 150.0)],
|
||||
[(0.0, 100.0), (2000.0, 135.0), (4000.0, 105.0)],
|
||||
[1600.0],
|
||||
)
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
|
||||
expected_types = ["LINE", "CLOTHOID", "CIRCULARARC", "CLOTHOID", "LINE", "LINE"] # last is the zero length segment
|
||||
assert [s.DesignParameters.PredefinedType for s in segment_nest.RelatedObjects] == expected_types
|
||||
|
||||
# verify continuity of position and direction between consecutive segments of the
|
||||
# geometric representation
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal_layout)
|
||||
settings = ifcopenshell.geom.settings()
|
||||
for segment, next_segment in zip(curve.Segments[:-1], curve.Segments[1:]):
|
||||
fn = ifcopenshell_wrapper.map_shape(settings, segment)
|
||||
evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, fn)
|
||||
end = np.array(evaluator.evaluate(fn.end()))
|
||||
end_position = end[0:2, 3]
|
||||
end_direction = math.atan2(end[1, 0], end[0, 0])
|
||||
start_position = next_segment.Placement.Location.Coordinates
|
||||
d = next_segment.Placement.RefDirection.DirectionRatios
|
||||
start_direction = math.atan2(d[1], d[0])
|
||||
assert end_position[0] == pytest.approx(start_position[0], abs=1.0e-5)
|
||||
assert end_position[1] == pytest.approx(start_position[1], abs=1.0e-5)
|
||||
direction_gap = math.atan2(math.sin(end_direction - start_direction), math.cos(end_direction - start_direction))
|
||||
assert direction_gap == pytest.approx(0.0, abs=1.0e-9)
|
||||
|
||||
# the file is schema and express rule valid
|
||||
logger = ifcopenshell.validate.json_logger()
|
||||
ifcopenshell.validate.validate(file, logger, express_rules=True)
|
||||
assert [entry for entry in logger.statements if entry["level"] == logging.ERROR] == []
|
||||
@@ -0,0 +1,196 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Golden worked example for solve_horizontal_alignment_by_pi_method with a symmetric
|
||||
spiral-curve-spiral (clothoid / circular arc / clothoid) transition.
|
||||
|
||||
Every expected value below is derived directly from the classic route-surveying spiral
|
||||
formulas (see e.g. AASHTO "A Policy on Geometric Design of Highways and Streets", spiral
|
||||
curve tables), independently of solve_horizontal_alignment_by_pi_method's own
|
||||
implementation (which integrates the clothoid position functions with 32 point
|
||||
Gauss-Legendre quadrature). The two methods agreeing to 1e-6 or better is the point of the
|
||||
test: it is a check on solve_horizontal_alignment_by_pi_method, not a restatement of it.
|
||||
|
||||
Problem setup (units are arbitrary but consistent, e.g. feet or meters):
|
||||
|
||||
Delta = 60 deg total PI deflection angle
|
||||
R = 500 circular curve radius
|
||||
Ls = 150 spiral length, both entry and exit (equal-spiral case)
|
||||
|
||||
Derivation, step by step:
|
||||
|
||||
theta_s = Ls / (2R) spiral angle (deflection of one spiral)
|
||||
= 150 / 1000 = 0.15 rad
|
||||
|
||||
X = Ls * (1 - theta_s^2/10 + theta_s^4/216) local coordinates of the spiral's far end
|
||||
Y = Ls * (theta_s/3 - theta_s^3/42 + theta_s^5/1320) (TS at the origin, tangent along +X)
|
||||
|
||||
p = Y - R*(1 - cos(theta_s)) shift: inward offset of the circular
|
||||
curve from the tangent line
|
||||
k = X - R*sin(theta_s) abscissa of the shifted PC, measured
|
||||
from TS along the tangent
|
||||
|
||||
Ts = (R + p)*tan(Delta/2) + k total tangent distance, PI to TS (or,
|
||||
by symmetry of an equal-spiral curve,
|
||||
PI to ST)
|
||||
|
||||
Delta_c = Delta - 2*theta_s central angle left for the circular arc
|
||||
Lc = R * Delta_c circular arc length
|
||||
|
||||
Plugging in Delta = pi/3, R = 500, Ls = 150 (computed with Python's math module, which is
|
||||
just a calculator here -- no call into ifcopenshell.api.alignment is involved):
|
||||
|
||||
theta_s = 0.15
|
||||
X = 149.6628515625
|
||||
Y = 7.487955057832791
|
||||
p = 1.8734940258539128
|
||||
k = 74.9437853257004
|
||||
Ts = 364.70058220066517
|
||||
Delta_c = 0.7471975511965976 rad (=~ 42.809 deg)
|
||||
Lc = 373.5987755982988
|
||||
|
||||
Geometry of the PI-method problem: POB at (0,0), PI1 at (D,0) with D = 1200 (comfortably
|
||||
more than Ts so the back tangent run is a real segment), and POE at
|
||||
(D + L*cos(-60deg), L*sin(-60deg)) with L = 1200, so PI1 deflects 60 degrees to the right.
|
||||
Because the back tangent lies exactly on the world X axis (POB -> PI1 direction is 0 rad),
|
||||
the offset of any point on the curve from the initial tangent line is simply that point's
|
||||
world Y coordinate, and the spiral's local (X, Y) frame is the world frame translated to TS
|
||||
(no rotation) -- which is what lets this example's numbers be checked directly against the
|
||||
solver's segment start points without any extra transform.
|
||||
|
||||
TS = (D - Ts, 0) = (835.2994177993348, 0.0)
|
||||
|
||||
The circular arc begins where the entry spiral ends. In the spiral's local frame that is
|
||||
(X, Y); since the curve deflects right (s = -1 in the solver's sign convention) the spiral's
|
||||
local Y is mirrored into world coordinates:
|
||||
|
||||
circular arc start = TS + (X, -Y) = (984.9622693618348, -7.487955057832791)
|
||||
|
||||
By symmetry (equal spirals, same radius on both sides) the exit tangent run has the same
|
||||
length as the entry tangent run, D - Ts = L - Ts = 835.2994177993348.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
|
||||
R = 500.0
|
||||
Ls = 150.0
|
||||
DELTA = math.pi / 3.0 # 60 degrees
|
||||
D = 1200.0
|
||||
L = 1200.0
|
||||
|
||||
# --- independently derived expected values (see module docstring for the derivation) ---
|
||||
THETA_S = Ls / (2.0 * R)
|
||||
assert THETA_S == pytest.approx(0.15)
|
||||
|
||||
EXPECTED_X = Ls * (1.0 - THETA_S**2 / 10.0 + THETA_S**4 / 216.0)
|
||||
EXPECTED_Y = Ls * (THETA_S / 3.0 - THETA_S**3 / 42.0 + THETA_S**5 / 1320.0)
|
||||
assert EXPECTED_X == pytest.approx(149.6628515625)
|
||||
assert EXPECTED_Y == pytest.approx(7.487955057832791)
|
||||
|
||||
EXPECTED_P = EXPECTED_Y - R * (1.0 - math.cos(THETA_S))
|
||||
EXPECTED_K = EXPECTED_X - R * math.sin(THETA_S)
|
||||
assert EXPECTED_P == pytest.approx(1.8734940258539128)
|
||||
assert EXPECTED_K == pytest.approx(74.9437853257004)
|
||||
|
||||
EXPECTED_TS = (R + EXPECTED_P) * math.tan(DELTA / 2.0) + EXPECTED_K
|
||||
assert EXPECTED_TS == pytest.approx(364.70058220066517)
|
||||
|
||||
EXPECTED_DELTA_C = DELTA - 2.0 * THETA_S
|
||||
EXPECTED_ARC_LENGTH = R * EXPECTED_DELTA_C
|
||||
assert EXPECTED_DELTA_C == pytest.approx(0.7471975511965976)
|
||||
assert EXPECTED_ARC_LENGTH == pytest.approx(373.5987755982988)
|
||||
|
||||
EXPECTED_TANGENT_RUN = D - EXPECTED_TS
|
||||
assert EXPECTED_TANGENT_RUN == pytest.approx(835.2994177993348)
|
||||
|
||||
EXPECTED_TS_POINT = (D - EXPECTED_TS, 0.0)
|
||||
EXPECTED_CIRCULARARC_START = (EXPECTED_TS_POINT[0] + EXPECTED_X, EXPECTED_TS_POINT[1] - EXPECTED_Y)
|
||||
assert EXPECTED_CIRCULARARC_START == pytest.approx((984.9622693618348, -7.487955057832791))
|
||||
|
||||
|
||||
def _pi_points() -> list[tuple[float, float]]:
|
||||
pi0 = (0.0, 0.0)
|
||||
pi1 = (D, 0.0)
|
||||
pi2 = (D + L * math.cos(math.radians(-60.0)), L * math.sin(math.radians(-60.0)))
|
||||
return [pi0, pi1, pi2]
|
||||
|
||||
|
||||
def test_solve_spiral_worked_example_segment_types_and_lengths():
|
||||
hpoints = _pi_points()
|
||||
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
|
||||
|
||||
expected_types = ["LINE", "CLOTHOID", "CIRCULARARC", "CLOTHOID", "LINE"]
|
||||
assert [s.predefined_type for s in segments] == expected_types
|
||||
|
||||
back_tangent, entry_spiral, arc, exit_spiral, forward_tangent = segments
|
||||
|
||||
# first tangent run: POB to TS, length D - Ts
|
||||
assert back_tangent.segment_length == pytest.approx(EXPECTED_TANGENT_RUN, rel=1.0e-6)
|
||||
|
||||
# spiral lengths are exactly what was requested
|
||||
assert entry_spiral.segment_length == pytest.approx(Ls, rel=1.0e-6)
|
||||
assert exit_spiral.segment_length == pytest.approx(Ls, rel=1.0e-6)
|
||||
|
||||
# circular arc length == R * Delta_c
|
||||
assert arc.segment_length == pytest.approx(EXPECTED_ARC_LENGTH, rel=1.0e-6)
|
||||
|
||||
# by symmetry, the closing tangent run has the same length as the opening one
|
||||
assert forward_tangent.segment_length == pytest.approx(EXPECTED_TANGENT_RUN, rel=1.0e-6)
|
||||
|
||||
|
||||
def test_solve_spiral_worked_example_spiral_end_offset():
|
||||
hpoints = _pi_points()
|
||||
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
|
||||
_, entry_spiral, arc, _, _ = segments
|
||||
|
||||
# the back tangent (POB -> PI1) runs exactly along the world X axis, so TS is where the
|
||||
# entry spiral starts. EXPECTED_TS_POINT comes from the truncated power series formulas
|
||||
# for X and Y, while the solver integrates the same clothoid with Gauss-Legendre
|
||||
# quadrature; the two methods agree to a few parts in 1e-10 relative, well inside the
|
||||
# 1e-6 relative tolerance used throughout this test.
|
||||
assert entry_spiral.start_point == pytest.approx(EXPECTED_TS_POINT, rel=1.0e-6, abs=1.0e-9)
|
||||
|
||||
# the circular arc starts where the entry spiral ends: TS + (X, -Y) in world coordinates
|
||||
# (Y is mirrored because the curve deflects to the right). this is the spiral end offset
|
||||
# from the initial tangent, expressed directly against the classic X, Y spiral formulas.
|
||||
assert arc.start_point == pytest.approx(EXPECTED_CIRCULARARC_START, rel=1.0e-6, abs=1.0e-9)
|
||||
offset_from_tangent = -arc.start_point[1] # tangent line is world Y == 0
|
||||
assert offset_from_tangent == pytest.approx(EXPECTED_Y, rel=1.0e-6)
|
||||
|
||||
|
||||
def test_solve_spiral_worked_example_is_continuous():
|
||||
hpoints = _pi_points()
|
||||
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
|
||||
|
||||
max_position_gap = 0.0
|
||||
max_direction_gap = 0.0
|
||||
for segment, next_segment in zip(segments[:-1], segments[1:]):
|
||||
end_x, end_y, end_direction = ifcopenshell.api.alignment.compute_horizontal_segment_end(segment)
|
||||
position_gap = math.hypot(end_x - next_segment.start_point[0], end_y - next_segment.start_point[1])
|
||||
raw_direction_gap = end_direction - next_segment.start_direction
|
||||
direction_gap = abs(math.atan2(math.sin(raw_direction_gap), math.cos(raw_direction_gap)))
|
||||
max_position_gap = max(max_position_gap, position_gap)
|
||||
max_direction_gap = max(max_direction_gap, direction_gap)
|
||||
|
||||
assert max_position_gap < 1.0e-9
|
||||
assert max_direction_gap < 1.0e-9
|
||||
Reference in New Issue
Block a user