# The Clear BSD License
#
# Copyright (c) 2026 Tobias Heibges
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted (subject to the limitations in the disclaimer
# below) provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Geometry Builder
Fluent interface for constructing simulation geometries.
Supports any Surface implementation with named media for material consistency.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Self
from ..surfaces import Surface, SurfaceRole
from ..materials import MaterialField
from .geometry import Geometry
from .cell import Cell, HalfSpace
from .cell_geometry import CellGeometry
from .surface_analysis import analyze_surface_pair, SurfaceRelationship
from .validation import IntersectingSurfacesError
[docs]
class GeometryBuilder:
"""
Fluent builder for constructing simulation geometries.
Provides a simple interface for registering named media and adding
surfaces with validation for duplicate names, surface limits, and
material consistency.
Supports two modes:
1. Standard mode: Use add_surface(surface, front=, back=) for simple geometries
2. Cell mode: Use add_surface_only() + add_cell() for complex geometries
Examples
--------
Standard mode (parallel planes):
>>> from lsurf.geometry import GeometryBuilder
>>> from lsurf.materials import WATER, ExponentialAtmosphere
>>> from lsurf.surfaces import SphereSurface, PlaneSurface, SurfaceRole
>>>
>>> EARTH_RADIUS = 6.371e6
>>> atmosphere = ExponentialAtmosphere()
>>>
>>> ocean = SphereSurface(
... center=(0, 0, -EARTH_RADIUS),
... radius=EARTH_RADIUS,
... role=SurfaceRole.OPTICAL,
... name="ocean",
... )
>>> detector = PlaneSurface(
... point=(0, 0, 35000),
... normal=(0, 0, 1),
... role=SurfaceRole.DETECTOR,
... name="detector_35km",
... )
>>>
>>> geometry = (
... GeometryBuilder()
... .register_medium("atmosphere", atmosphere)
... .register_medium("ocean", WATER)
... .set_background("atmosphere")
... .add_surface(ocean, front="atmosphere", back="ocean")
... .add_detector(detector)
... .build()
... )
Cell mode (non-parallel planes with different materials):
>>> plane_x = PlaneSurface(point=(0, 0, 0), normal=(1, 0, 0), ...)
>>> plane_y = PlaneSurface(point=(0, 0, 0), normal=(0, 1, 0), ...)
>>>
>>> geometry = (
... GeometryBuilder()
... .register_medium("air", AIR)
... .register_medium("water", WATER)
... .register_medium("glass", GLASS)
... .register_medium("vacuum", VACUUM)
... .set_background("air")
... .add_surface_only(plane_x)
... .add_surface_only(plane_y)
... .add_cell("air", ("plane_x", True), ("plane_y", True)) # Q1
... .add_cell("water", ("plane_x", False), ("plane_y", True)) # Q2
... .add_cell("glass", ("plane_x", True), ("plane_y", False)) # Q3
... .add_cell("vacuum", ("plane_x", False), ("plane_y", False)) # Q4
... .build()
... )
"""
[docs]
def __init__(self) -> None:
self._media: dict[str, MaterialField] = {}
self._background_medium: str | None = None
self._surfaces: list[Surface] = []
self._detectors: list[Surface] = []
self._surface_names: dict[str, int] = {}
self._detector_names: dict[str, int] = {}
# Cell mode state
self._cells: list[Cell] = []
self._cell_mode: bool = False
self._surface_only_names: set[str] = set() # Surfaces added without materials
[docs]
def register_medium(self, name: str, material: MaterialField) -> Self:
"""
Register a named medium with its material.
A medium is a named reference to a material. Surfaces reference
media by name, ensuring material consistency when multiple
surfaces share the same medium.
If the same medium name is registered twice, validates that the
same material instance is used.
Parameters
----------
name : str
Name of the medium.
material : MaterialField
Material for this medium.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If medium already registered with different material instance.
"""
if name in self._media:
if self._media[name] is not material:
raise ValueError(
f"Medium '{name}' already registered with different material instance"
)
self._media[name] = material
return self
[docs]
def set_background(self, medium_name: str) -> Self:
"""
Set the background/propagation medium by name.
The medium must be registered before calling this method.
Parameters
----------
medium_name : str
Name of the medium to use as background.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If the medium is not registered.
"""
if medium_name not in self._media:
raise ValueError(f"Medium '{medium_name}' not registered")
self._background_medium = medium_name
return self
[docs]
def add_surface(
self,
surface: Surface,
front: str,
back: str,
) -> Self:
"""
Add a surface with materials from named media.
Parameters
----------
surface : Surface
Surface geometry (materials will be replaced).
front : str
Medium name for the front side material.
back : str
Medium name for the back side material.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If surface name is duplicate, medium names are not registered,
or cell mode is active.
"""
if self._cell_mode:
raise ValueError(
"Cannot use add_surface() in cell mode. "
"Use add_surface_only() instead, or don't call add_cell()."
)
self._validate_name(surface.name)
# Lookup materials from media
front_material = self._get_medium_material(front)
back_material = self._get_medium_material(back)
# Create new surface with correct materials using dataclasses.replace()
new_surface = replace(
surface,
material_front=front_material,
material_back=back_material,
)
self._surface_names[surface.name] = len(self._surfaces)
self._surfaces.append(new_surface)
return self
[docs]
def add_surface_only(self, surface: Surface) -> Self:
"""
Add a surface without material assignment (for cell mode).
Use this with add_cell() to define complex geometries where
surfaces intersect and the simple front/back model is insufficient.
Parameters
----------
surface : Surface
Surface geometry. Materials should be left as None.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If surface name is duplicate.
"""
self._validate_name(surface.name)
self._surface_names[surface.name] = len(self._surfaces)
self._surfaces.append(surface)
self._surface_only_names.add(surface.name)
return self
[docs]
def add_cell(
self,
medium_name: str,
*conditions: tuple[str, bool],
name: str = "",
) -> Self:
"""
Define a cell (region) by half-space intersection.
A cell is a region of space defined by being on specific sides
of multiple surfaces. Each condition specifies a surface and
which side (front=True means signed_distance > 0).
Parameters
----------
medium_name : str
Name of the medium for this cell.
*conditions : tuple[str, bool]
Each condition is (surface_name, front) where:
- surface_name: Name of a surface added with add_surface_only()
- front: True for front side (signed_distance > 0),
False for back side (signed_distance < 0)
name : str, optional
Optional human-readable name for the cell.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If medium is not registered or surface name not found.
Examples
--------
>>> builder.add_cell("air", ("plane_x", True), ("plane_y", True), name="Q1")
"""
# Enable cell mode
self._cell_mode = True
# Validate medium exists
if medium_name not in self._media:
available = ", ".join(sorted(self._media.keys()))
raise ValueError(
f"Medium '{medium_name}' not registered. Available: {available}"
)
# Validate surface names exist
for surface_name, _front in conditions:
if surface_name not in self._surface_names:
available = ", ".join(sorted(self._surface_names.keys()))
raise ValueError(
f"Surface '{surface_name}' not found. Available: {available}"
)
# Create half-spaces and cell
half_spaces = tuple(
HalfSpace(surface_name=cond[0], front=cond[1]) for cond in conditions
)
cell = Cell(half_spaces=half_spaces, medium_name=medium_name, name=name)
self._cells.append(cell)
return self
[docs]
def add_detector(self, detector: Surface) -> Self:
"""
Add a detector Surface to the geometry.
Parameters
----------
detector : Surface
Any object implementing the Surface protocol.
Must have a unique `name` attribute.
Returns
-------
Self
The builder instance for chaining.
Raises
------
ValueError
If detector name is duplicate.
"""
self._validate_name(detector.name)
self._detector_names[detector.name] = len(self._detectors)
self._detectors.append(detector)
return self
def _get_medium_material(self, medium_name: str) -> MaterialField:
"""Get material for a medium, raising if not registered."""
if medium_name not in self._media:
available = ", ".join(sorted(self._media.keys()))
raise ValueError(
f"Medium '{medium_name}' not registered. Available: {available}"
)
return self._media[medium_name]
def _validate_surface_consistency(self) -> None:
"""
Validate that surface material assignments are consistent.
For non-parallel surfaces with different front/back materials,
raises IntersectingSurfacesError.
"""
optical_surfaces = [s for s in self._surfaces if s.role == SurfaceRole.OPTICAL]
# Check all pairs
for i, surf1 in enumerate(optical_surfaces):
for surf2 in optical_surfaces[i + 1 :]:
result = analyze_surface_pair(surf1, surf2)
if (
result.relationship == SurfaceRelationship.INTERSECTING
and not result.materials_consistent
):
raise IntersectingSurfacesError(
surface1_name=surf1.name,
surface2_name=surf2.name,
details=result.details,
)
[docs]
def build(self, validate: bool = True) -> Geometry | CellGeometry:
"""
Build the immutable Geometry object.
Parameters
----------
validate : bool, optional
If True (default), validates that surface material assignments
are consistent. Set to False to skip validation.
Returns
-------
Geometry or CellGeometry
Standard Geometry if using add_surface(), or CellGeometry if
using add_surface_only() + add_cell().
Raises
------
ValueError
If background medium not set or OPTICAL surfaces missing materials.
IntersectingSurfacesError
If non-parallel surfaces have conflicting material assignments
(only when validate=True).
"""
if self._background_medium is None:
raise ValueError("Background medium not set. Call set_background() first.")
# Cell mode: return CellGeometry
if self._cell_mode:
if not self._cells:
raise ValueError(
"Cell mode enabled but no cells defined. "
"Call add_cell() at least once."
)
return CellGeometry(
surfaces=tuple(self._surfaces),
detectors=tuple(self._detectors),
background_material=self._media[self._background_medium],
media=dict(self._media),
surface_names=dict(self._surface_names),
detector_names=dict(self._detector_names),
cells=tuple(self._cells),
)
# Standard mode: validate and return Geometry
# Validate all OPTICAL surfaces have materials
for surface in self._surfaces:
if surface.role == SurfaceRole.OPTICAL:
if surface.material_front is None or surface.material_back is None:
raise ValueError(
f"OPTICAL surface '{surface.name}' missing materials. "
"Use add_surface(surface, front='medium', back='medium')."
)
# Validate surface consistency
if validate:
self._validate_surface_consistency()
return Geometry(
surfaces=tuple(self._surfaces),
detectors=tuple(self._detectors),
background_material=self._media[self._background_medium],
media=dict(self._media),
surface_names=dict(self._surface_names),
detector_names=dict(self._detector_names),
)
def _validate_name(self, name: str) -> None:
"""Validate that a name is unique across surfaces and detectors."""
if name in self._surface_names:
raise ValueError(f"Duplicate surface name: '{name}'")
if name in self._detector_names:
raise ValueError(f"Duplicate detector name: '{name}'")