from __future__ import annotations
from contextlib import contextmanager
from typing import Any, Generator, Optional, Union
from .._base import _BaseEditor
from ..enums import LabelType, ObjLocalXDirType, ThicknessType
import numpy as np
def _set_local_x_dir(
attribs: Any,
dir_x: Union[tuple, list, np.ndarray],
rbt: Any,
) -> None:
"""Apply a Cartesian local-X direction vector to a panel's attribute object.
Parameters
----------
attribs : IRobotObjAttributes
The COM attributes object returned by ``panel.Main.Attribs``.
dir_x : tuple | list | np.ndarray of shape (3,)
Direction vector in global Cartesian coordinates. Normalised
internally before passing to the COM API.
rbt : module
The ``RobotOM`` module returned by ``get_robotom()``.
Raises
------
ValueError
If ``dir_x`` does not have exactly 3 elements or is a zero vector.
"""
vec = np.asarray(dir_x, dtype=float).ravel()
if vec.shape != (3,):
raise ValueError(
f"dir_x must be a 3-element vector, got shape {vec.shape}."
)
norm = float(np.linalg.norm(vec))
if norm < 1e-10:
raise ValueError("dir_x must not be a zero vector.")
vec = vec / norm
attribs.SetDirX(ObjLocalXDirType.CARTESIAN, float(vec[0]), float(vec[1]), float(vec[2]))
[docs]
class GeometryEditor(_BaseEditor):
"""Editor for structural geometry: nodes, bars, shells, and cladding.
Accessed via ``app.model.geometry``. Provides methods to add nodes,
bar members, shell panels, and cladding surfaces to the Robot model.
Use :meth:`begin_edit` to batch bulk operations into a single COM
multi-operation flush for large performance gains.
"""
def __init__(self, raw_app: Any) -> None:
super().__init__(raw_app)
self._project = self._raw.Project
self._structure = self._project.Structure
self._labels = self._structure.Labels
[docs]
@contextmanager
def begin_edit(self) -> Generator[None, Any, None]:
"""Context manager that batches all structure modifications into a
single multi-operation flush, dramatically reducing COM overhead for
large bulk operations.
Usage
-----
with app.model.geometry.begin_edit():
app.model.geometry.add_node([[1, 0, 0, 0], [2, 1, 0, 0]])
app.model.geometry.add_member([[1, 1, 2]], section_name="IPE 100")
"""
self._structure.BeginMultiOperation()
try:
yield
finally:
self._structure.EndMultiOperation()
[docs]
def add_node(
self,
number_or_data: Union[int, list, np.ndarray],
x: float | None = None,
y: float | None = None,
z: float | None = None,
) -> None:
"""
Add one or more nodes to the model.
Parameters
----------
number_or_data : int | list | np.ndarray
- Single node: pass number (int) alongside x, y, z.
- Multiple nodes: pass a 2D list or numpy array of shape (N, 4)
where each row is [number, x, y, z].
x, y, z : float, optional
Coordinates in meters. Only used when adding a single node.
Examples
--------
Single node:
>>> model.add_node(1, 0.0, 1.0, 2.0)
Multiple nodes as a 2-D list:
>>> model.add_node([[1, 0.0, 1.0, 2.0], [2, 3.0, 4.0, 5.0]])
NumPy array:
>>> import numpy as np
>>> nodes = np.array([[1, 0.0, 1.0, 2.0], [2, 3.0, 4.0, 5.0]])
>>> model.add_node(nodes)
"""
nodes = self._structure.Nodes
# --- bulk input (list or numpy array) ---
if isinstance(number_or_data, (list, np.ndarray)):
data = np.asarray(number_or_data)
if data.ndim != 2 or data.shape[1] != 4:
raise ValueError(
"Bulk input must have shape (N, 4) — columns: [number, x, y, z]. "
f"Got shape {data.shape}."
)
self._structure.BeginMultiOperation()
try:
for row in data:
nodes.Create(
int(row[0]), float(row[1]), float(row[2]), float(row[3])
)
finally:
self._structure.EndMultiOperation()
# --- single node ---
elif isinstance(number_or_data, (int, np.integer)):
if any(v is None for v in (x, y, z)):
raise ValueError(
"x, y, and z must be provided when adding a single node."
)
nodes.Create(int(number_or_data), float(x), float(y), float(z))
else:
raise TypeError(
f"Expected int, list, or np.ndarray for first argument, got {type(number_or_data).__name__}."
)
[docs]
def add_member(
self,
number_or_data: Union[int, list, np.ndarray],
start_node: int | None = None,
end_node: int | None = None,
section_name: Any | str = None,
material_name: Any | str = None,
release_name: Any | str = None,
tension_comp: Any | str = None,
truss: bool = False,
) -> None:
"""
Add one or more bars to the model.
Parameters
----------
number_or_data : int | list | np.ndarray
- Single bar: pass bar number (int) alongside start_node and end_node.
- Multiple bars: pass a 2-D list or numpy array of shape (N, 3)
where each row is [member_number, start_node, end_node].
start_node : int, optional
Start node number. Only used when adding a single bar.
end_node : int, optional
End node number. Only used when adding a single bar.
section_name : str, optional
Section name — must already exist in the model.
material_name : str, optional
Material name — must already exist in the model.
release_name : str, optional
Release name — must already exist in the model.
tension_comp : str, optional
'tension' for tension-only, 'compression' for compression-only.
truss : bool, optional
If True, sets the bar as a truss element (axial forces only).
Examples
--------
Single member:
>>> model.add_member(1, 1, 2, section_name="HEA200")
Multiple members as a list:
>>> model.add_member([[1, 1, 2], [2, 2, 3], [3, 3, 4]], section_name="HEA200")
Multiple truss members as a NumPy array:
>>> import numpy as np
>>> model.add_member(np.array([[1, 1, 2], [2, 2, 3]]), truss=True)
"""
# --- normalise input into a list of (number, start, end) tuples ---
if isinstance(number_or_data, (int, np.integer)):
if start_node is None or end_node is None:
raise ValueError(
"start_node and end_node must be provided when adding a single member."
)
members = [(int(number_or_data), int(start_node), int(end_node))]
elif isinstance(number_or_data, (list, np.ndarray)):
data = np.asarray(number_or_data)
if data.ndim != 2 or data.shape[1] != 3:
raise ValueError(
"Bulk input must have shape (N, 3) — columns: "
f"[member_number, start_node, end_node]. Got shape {data.shape}."
)
members = [(int(row[0]), int(row[1]), int(row[2])) for row in data]
else:
raise TypeError(
f"Expected int, list, or np.ndarray for first argument, "
f"got {type(number_or_data).__name__}."
)
# --- create all bars, then apply shared labels ---
self._structure.BeginMultiOperation()
try:
for number, start, end in members:
self._structure.Bars.Create(number, start, end)
bar = self._structure.Bars.Get(number)
if section_name is not None:
bar.SetLabel(LabelType.BAR_SECTION, section_name)
if material_name is not None:
bar.SetLabel(LabelType.MATERIAL, material_name)
if release_name is not None:
bar.SetLabel(LabelType.BAR_RELEASE, release_name)
if tension_comp is not None:
tc_value = 1 if tension_comp == "tension" else 2
bar.TensionCompression = self._raw.IRbotBarTensionCompression(
tc_value
)
if truss:
bar.TrussBar = True
finally:
self._structure.EndMultiOperation()
[docs]
def add_contour(
self,
points: list[list[float, float, float]],
number: Any | int = None,
) -> int:
"""
Adds a contour object to the model.
Parameters
----------
points: list[list[float, float, float]]
List of points, each point to be list of coordinates x, y, z
number: Any|int, optional
Number of the contour object, optional, if you specify number make sure it is free.
Returns
-------
int
Number of the created contour.
"""
obj_server = self._structure.Objects
# User can specify their number or free number will be used
# TODO: Add a error check if specified number already exist in the model and raise error or use freenumber
if number is not None:
num = number
else:
num = obj_server.FreeNumber
# Add points to the array
points_array = self._rbt.IRobotPointsArray(
self._raw.CmpntFactory.Create(
self._rbt.IRobotComponentType.I_CT_POINTS_ARRAY
)
)
points_array.SetSize(len(points))
for point in points:
points_array.Set(point[0], point[1], point[2], point[3])
# Create contour
obj_server.CreateContour(num, points_array)
return num
[docs]
def add_cladding(
self,
points: list[list[float, float, float]],
number: Any | int = None,
load_distribution: str = "Two-way",
dir_x: Optional[Union[tuple, list, np.ndarray]] = None,
flip_z: bool = False,
) -> None:
"""Add a cladding object to the model.
Parameters
----------
points : list[list[float, float, float]]
Contour vertices as a list of ``[index, x, y, z]`` rows.
number : int, optional
Object number to assign. If omitted, the next free number is used.
The caller is responsible for ensuring the number is not already taken.
load_distribution : str, optional
Load distribution mode. One of:
* ``"Two-way"`` — isotropic distribution (default)
* ``"One-way X"`` — load carried in local X direction only
* ``"One-way Y"`` — load carried in local Y direction only
dir_x : tuple | list | np.ndarray of shape (3,), optional
Cartesian direction vector ``(x, y, z)`` for the cladding's local X
axis, expressed in global coordinates. The vector is normalised
internally, so its magnitude does not matter.
When omitted, Robot auto-computes the local X axis from the panel
geometry (usually along the first edge of the contour).
This setting is relevant for one-way cladding (``"One-way X"`` /
``"One-way Y"``), where the local X axis determines the span
direction.
flip_z : bool, optional
If ``True``, reverses the panel's local Z axis (i.e. flips the
outward normal to the opposite face). Defaults to ``False``.
Raises
------
ValueError
If ``load_distribution`` is not one of the accepted strings.
ValueError
If ``dir_x`` is provided but is not a 3-element array or is a
zero vector.
Examples
--------
Isotropic cladding with Robot's default local axes:
>>> app.model.geometry.add_cladding(points, load_distribution="Two-way")
One-way cladding spanning in the global Y direction:
>>> app.model.geometry.add_cladding(
... points,
... load_distribution="One-way X",
... dir_x=(0.0, 1.0, 0.0),
... )
Cladding with flipped normal (load applied from below):
>>> app.model.geometry.add_cladding(points, flip_z=True)
"""
_VALID_DISTRIBUTIONS = {"Two-way", "One-way X", "One-way Y"}
if load_distribution not in _VALID_DISTRIBUTIONS:
raise ValueError(
f"Invalid load_distribution '{load_distribution}'. "
f"Must be one of: {sorted(_VALID_DISTRIBUTIONS)}"
)
num = self.add_contour(points=points, number=number)
obj_server = self._structure.Objects
contour = self._rbt.IRobotObjObject(obj_server.Get(num))
attribs = contour.Main.Attribs
attribs.Meshed = False
contour.SetLabel(LabelType.CLADDING, load_distribution)
if dir_x is not None:
_set_local_x_dir(attribs, dir_x, self._rbt)
if flip_z:
attribs.DirZ = True
contour.Initialize()
contour.Update()
[docs]
def add_shell_by_contour(
self,
points: list[list[float, float, float]],
number: Any | int = None,
material_name: Any | str = None,
thickness: Any | float = None,
thickness_name: Any | str = None,
) -> None:
"""
Adds a shell panel object to the model.
Parameters
----------
points: list[list[float, float, float]]
List of points, each point to be list of coordinates x, y, z
number: Any|int, optional
Number of the contour object, optional, if you specify number make sure it is free.
matarial_name: str
Material name
thickness: float
Thickness, curently only constant thickness is supported. Currently only homogeneous thickness supported.
TODO: support variable thickness eg. thicknessData.Type = I_THT_VARIABLE_ALONG_LINE, thicknessData.Thick1,
thicknessData.Thick2
"""
if number is not None:
contour_number = number
else:
contour_number = self._structure.Objects.FreeNumber
num = self.add_contour(points=points, number=contour_number)
# shell.Update
if material_name is not None:
if material_name is None:
print("Thickness must be assigned!")
raise ValueError
if thickness is not None:
# TODO: must check if thickness already exist if yes then skip definition part
th_label = self._rbt.IRobotLabel(
self._structure.Labels.Create(
LabelType.PANEL_THICKNESS, thickness_name
)
)
thickness_ = self._rbt.IRobotThicknessData(th_label.Data)
thickness_.MaterialName = material_name
thickness_.ThicknessType = ThicknessType.HOMOGENEOUS
th_data = self._rbt.IRobotThicknessHomoData(thickness_.Data)
th_data.ThickConst = thickness
self._labels.Store(th_label)
# TODO: make below code working, if user specifies variable thickness along line
# thicknessData.Type = I_THT_VARIABLE_ALONG_LINE
# thicknessData.Thick1 = 0.1
# thicknessData.Thick2 = 0.2
# thicknessData.SetP1 = x,y,z
# thicknessData.SetP2 = x,y,z
else:
print("Material name must be assigned!")
raise ValueError
shell = self._rbt.IRobotObjObject(self._structure.Objects.Get(num))
shell.Main.Attribs.Meshed = True
shell.SetLabel(LabelType.PANEL_THICKNESS, thickness_name)
shell.Initialize()