Source code for lsurf.geometry.builder

# 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}'")