diff --git a/CHANGELOG.md b/CHANGELOG.md index a117b2ea54..bbccab1d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `orientation` parameter to `PlateGeometry.from_global_outlines`, `Panel.from_outlines`, `Panel.from_outline_thickness`, `Panel.from_face_thickness`, `Panel.from_brep`, `Plate.from_outlines`, `Plate.from_outline_thickness`, `Plate.from_face_thickness`, and `Plate.from_brep`. When provided, the vector is projected onto the element's plane and used to control the direction of the local coordinate frame, overriding the frame determined automatically from the input outlines.* Added `SimpleScarf` BTLx processing class to `compas_timber.fabrication` for generating simple scarf joint machining operations, including optional drill holes (0, 1, or 2). * Added `ISimpleScarf` joint class to `compas_timber.connections` for joining two parallel beams (Topology I) with a simple scarf joint. * Added unit tests for `ISimpleScarf`, `SimpleScarf`, `LButtJoint`, `LMiterJoint`, `TButtJoint`, `Panel`, and `Plate`. +* Added `PlateNester` main implementation with improved skyline-based placement flow, deterministic ordering support, and seeded variant generation. ### Changed * Fixed a bug that prevented `FrenchRidgeLapJoint` from adding extensions to beams. @@ -90,11 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.1.1-rc0] 2026-03-25 ### Added -* Added new multi-face brep support via the `Plate.from_brep()` class method, which automatically creates plates from multi-face breps by detecting parallel faces. -* Added `Plate.from_face_thickness()` class method for creating plates from single-face breps (replacing the previous single-face `Plate.from_brep()` behavior). -* Added `Panel.from_brep()` class method to create panels from multi-face breps, parallel to `Plate.from_brep()`. -* Added `Panel.from_face_thickness()` class method to create panels from single-face breps with an explicit thickness, parallel to `Plate.from_face_thickness()`. -* Added `get_plate_geometry_outlines_from_brep` to `compas_timber.utils` — a shared utility used by both `Plate.from_brep()` and `Panel.from_brep()` to extract the two main outlines and openings from a multi-face brep using mesh-based face identification. + * Added new `compas_timber.btlx` package with `BTLxReader` class for reading BTLx XML files into a `TimberModel`. * Added `BTLxParsingError` to `compas_timber.errors` — a non-fatal exception with `part_id` and `processing_type` fields, collected during BTLx parsing without aborting the process. * Added `BTLxProcessing.HEADER_ATTRIBUTE_MAP` class attribute mapping BTLx XML header attributes (e.g. `ReferencePlaneID`, `ProcessID`, `CounterSink`) to Python parameter names with type converters, used by the reader. @@ -106,7 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Changed minimum required `compas` version to `2.15.1` due to bugfix. +* Improved `PlateStock`/`NestingResult` reporting integration for plate nesting, including robust unplaced-element handling and per-stock utilization/report payload consistency. * Fixed `Plate.geometry` is `None`. * Fixed `FreeContour` BTLx file creation failing with assertion `processident != 0`; `process_id` now defaults to `1` instead of the base class default of `0`. * Fixed circular import between `compas_timber.connections.analyzers` and `compas_timber.model` by moving `analyzers` module to `compas_timber.analyzers`. @@ -132,7 +129,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.1.0-dev0] 2026-03-16 ### Added - +* Added new multi-face brep support via the `Plate.from_brep()` class method, which automatically creates plates from multi-face breps by detecting parallel faces. +* Added `Plate.from_face_thickness()` class method for creating plates from single-face breps (replacing the previous single-face `Plate.from_brep()` behavior). +* Added `Panel.from_brep()` class method to create panels from multi-face breps, parallel to `Plate.from_brep()`. +* Added `Panel.from_face_thickness()` class method to create panels from single-face breps with an explicit thickness, parallel to `Plate.from_face_thickness()`. +* Added `get_plate_geometry_outlines_from_brep` to `compas_timber.utils` — a shared utility used by both `Plate.from_brep()` and `Panel.from_brep()` to extract the two main outlines and openings from a multi-face brep using mesh-based face identification. * Added `InteractionType` enum to `compas_timber.structural` for controlling which interaction types (`AUTO`, `JOINTS`, `CANDIDATES`) are used when creating structural segments. * Added `get_joints_for_element()` method to `TimberModel` to retrieve only joints for a given element. * Added `get_candidates_for_element()` method to `TimberModel` to retrieve only joint candidates for a given element. @@ -146,6 +147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `get_clusters_from_joint_candidates` function to `compas_timber.connections`. ### Changed +* Changed minimum required `compas` version to `2.15.1` due to bugfix. * Breaking change: the previous single-face `Plate.from_brep()` constructor behavior has been replaced, and `Plate.from_brep()` is now used exclusively to construct plates from multi-face breps. Existing code that called `Plate.from_brep()` with a single-face brep should be updated to call `Plate.from_face_thickness()` for plates, or `Panel.from_face_thickness()` for panels, instead. * `Plate.from_brep()` now delegates all brep parsing logic to `get_plate_geometry_outlines_from_brep`, which uses `mesh_from_brep_simple` to convert the brep into a `Mesh` datastructure. Face identification and vertex correspondence are resolved through mesh topology instead of directly iterating the brep API. * Rewrote `polyline_from_brep_loop` to use `join_polyline_segments` internally; curved edges are no longer sampled (curved breps must be pre-tessellated before use). diff --git a/src/compas_timber/planning/__init__.py b/src/compas_timber/planning/__init__.py index 96dc18cc87..5a8196e01c 100644 --- a/src/compas_timber/planning/__init__.py +++ b/src/compas_timber/planning/__init__.py @@ -8,6 +8,7 @@ from .sequencer import LinearDimension from .sequencer import BuildingPlanParser from .nesting import BeamNester +from .nesting import PlateNester from .nesting import NestingResult from .nesting import Stock from .nesting import BeamStock @@ -25,6 +26,7 @@ "SimpleSequenceGenerator", "Text3d", "BeamNester", + "PlateNester", "Stock", "BeamStock", "PlateStock", diff --git a/src/compas_timber/planning/nesting.py b/src/compas_timber/planning/nesting.py index 7a70f2cc9a..d9e4b28498 100644 --- a/src/compas_timber/planning/nesting.py +++ b/src/compas_timber/planning/nesting.py @@ -1,7 +1,19 @@ +import math +import random +from abc import ABC +from abc import abstractmethod from warnings import warn from compas.data import Data from compas.geometry import Frame +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyline +from compas.geometry import Rotation +from compas.geometry import Transformation +from compas.geometry import Translation +from compas.geometry import Vector +from compas.geometry import is_polygon_in_polygon_xy from compas.tolerance import TOL @@ -43,7 +55,7 @@ def __data__(self): } -class Stock(Data): +class Stock(Data, ABC): """ A base class to represent a stock piece for nesting. @@ -90,6 +102,7 @@ def __data__(self): "element_data": self.element_data, } + @abstractmethod def add_element(self, element): """ Add an element to this stock assignment. @@ -104,8 +117,9 @@ def add_element(self, element): ValueError If element doesn't fit in remaining space. """ - raise NotImplementedError("This method should be implemented in subclasses.") + pass + @abstractmethod def can_fit_element(self, element): """ Check if an element can fit in the remaining space. @@ -120,8 +134,9 @@ def can_fit_element(self, element): bool True if element fits in remaining space, False otherwise """ - raise NotImplementedError("This method should be implemented in subclasses.") + pass + @abstractmethod def is_compatible_with(self, element): """ Check if this stock can accommodate the element type and dimensions. @@ -136,7 +151,7 @@ def is_compatible_with(self, element): bool True if element is compatible with this stock, False otherwise """ - raise NotImplementedError("This method should be implemented in subclasses.") + pass class BeamStock(Stock): @@ -309,6 +324,14 @@ def __init__(self, dimensions, thickness, spacing=0.0, element_data=None): self.dimensions = tuple(dimensions) self.thickness = thickness + # Initialize remaining boundary as full stock rectangle + self._remaining_boundary = Polygon([Point(0, 0, 0), Point(dimensions[0], 0, 0), Point(dimensions[0], dimensions[1], 0), Point(0, dimensions[1], 0)]) + self._skyline = Polyline([[0, 0, 0], [self.dimensions[0], 0, 0]]) # setup skyline for nesting algorithms + self.placement_data = {} # {guid: Frame} raw XY placement frames for visualization/debug + self._used_area = 0.0 # explicit area accounting for robust utilization reporting + self._used_max_x = 0.0 # tracked used envelope extent along X for skyline scoring + self._used_max_y = 0.0 # tracked used envelope extent along Y for skyline scoring + @property def __data__(self): data = super(PlateStock, self).__data__ @@ -316,6 +339,150 @@ def __data__(self): data["thickness"] = self.thickness return data + @property + def _remaining_area(self): + # Get remaining available area in the stock. + if isinstance(self._remaining_boundary, Polygon): + return self._remaining_boundary.area + # Defensive fallback if boundary got corrupted by a failed boolean op. + return self.dimensions[0] * self.dimensions[1] + + def is_compatible_with(self, plate): + """ + Check if this stock can accommodate the plate type. + + For 2D nesting, plates must have matching thickness. Dimension/fit checks are handled + during candidate placement and geometry validation in the nesting algorithms. + + Parameters + ---------- + plate : :class:`~compas_timber.elements.Plate` + The plate to check + + Returns + ------- + bool + True if plate is compatible with this stock, False otherwise + """ + return TOL.is_close(plate.thickness, self.thickness) + + def can_fit_element(self, plate_outline): + """ + Check if a plate can fit in the remaining space. + + Two-step optimization check: + 1. Area check - fast rejection based on area comparison + 2. Shape check - validate if plate's polygon can fit within remaining boundary + + Parameters + ---------- + plate_outline : :class:`compas.geometry.Polygon` + The polygon to check + + Returns + ------- + bool + True if plate could fit somewhere in remaining space, False otherwise + """ + # TODO (acknowledged): API mismatch with abstract Stock.can_fit_element(element). + # Plate nesting operates on pre-transformed footprint polygons for performance. + # Keep this behavior for backward compatibility; revisit in a future API revision. + + # Step 1: Quick area rejection + if plate_outline.area > self._remaining_area: + return False + # Step 2: Shape check. + # NOTE: `is_polygon_in_polygon_xy` can be strict for boundary-touching cases. + # In nesting we generally allow touching stock boundaries. + if isinstance(self._remaining_boundary, Polygon) and is_polygon_in_polygon_xy(plate_outline, self._remaining_boundary): + return True + + # Fallback: allow boundary-touching placements using a tolerant bbox test. + # This keeps placements robust in Rhino/COMPAS numerical edge cases. + if isinstance(self._remaining_boundary, Polygon): + boundary_points = self._remaining_boundary.points + else: + # Defensive fallback to stock extents if boundary got corrupted. + boundary_points = [ + Point(0, 0, 0), + Point(self.dimensions[0], 0, 0), + Point(self.dimensions[0], self.dimensions[1], 0), + Point(0, self.dimensions[1], 0), + ] + min_x = min(p.x for p in boundary_points) - TOL.absolute + max_x = max(p.x for p in boundary_points) + TOL.absolute + min_y = min(p.y for p in boundary_points) - TOL.absolute + max_y = max(p.y for p in boundary_points) + TOL.absolute + + return all(min_x <= p.x <= max_x and min_y <= p.y <= max_y for p in plate_outline.points) + + def add_element(self, plate, transformation=Transformation()): + """ + Add a plate to this stock assignment at a specific position and rotation. + + This updates the remaining boundary by subtracting the placed plate polygon. + + Parameters + ---------- + plate : :class:`~compas_timber.elements.Plate` + The plate to add + transformation : :class:`compas.geometry.Transformation`, optional + Transformation defining position and rotation of the plate within the stock. + + Raises + ------ + ValueError + If plate doesn't fit in remaining space + """ + # Raw placement frame in XY nesting coordinates. + placement_frame = Frame.from_transformation(transformation) + + # Transform current local outline to placement position. + plate_outline = plate.plate_geometry.outline_a.transformed(transformation) + + # Convert Polyline -> Polygon for robust 2D containment/boolean operations. + outline_points = plate_outline.points + if len(outline_points) > 2 and outline_points[0] == outline_points[-1]: + outline_points = outline_points[:-1] + plate_outline = Polygon(outline_points) + + # NOTE: spacing is handled by the nesting algorithms when choosing positions. + # Keep `add_element` as a pure geometric containment/write operation. + if not self.can_fit_element(plate_outline): + raise ValueError("Plate doesn't fit in remaining space") + + # Update remaining boundary using boolean difference + difference_result = self._remaining_boundary.boolean_difference(plate_outline) + if difference_result: + # Keep polygon boundaries only; boolean ops can return mixed geometry types. + polygons = [item for item in difference_result if isinstance(item, Polygon)] + if polygons: + # Keep the largest remainder as current boundary. + self._remaining_boundary = max(polygons, key=lambda p: p.area) + + # Inline conversion from nesting XY placement to BTLx rawpart frame convention. + btlx_point = Point(placement_frame.point.x, 0.0, placement_frame.point.y) + xaxis_xy = placement_frame.xaxis + yaxis_xy = placement_frame.yaxis + btlx_xaxis = Vector(xaxis_xy.x, 0.0, xaxis_xy.y) + btlx_zaxis = Vector(yaxis_xy.x, 0.0, yaxis_xy.y) + if btlx_xaxis.length < TOL.absolute: + btlx_xaxis = Vector(1, 0, 0) + if btlx_zaxis.length < TOL.absolute: + btlx_zaxis = Vector(-btlx_xaxis.z, 0.0, btlx_xaxis.x) + + btlx_point += btlx_zaxis * plate.blank.ysize + + btlx_frame = Frame(btlx_point, btlx_xaxis, Vector(0, -1, 0)) + + # Store BTLx-oriented frame for export. + self.element_data[str(plate.guid)] = NestedElementData( + frame=btlx_frame, + key=plate.name + "-" + str(plate.guid)[:4], + ) + self.placement_data[str(plate.guid)] = placement_frame + self._used_area += plate.blank.xsize * plate.blank.ysize + class NestingResult(Data): """ @@ -333,18 +500,32 @@ class NestingResult(Data): List of stock pieces with assigned beams tolerance : :class:`~compas.tolerance.Tolerance` The tolerance configuration used for this model. TOL if none provided. + unplaced_elements : list[str] + GUIDs of elements that could not be nested + unplaced_reasons : dict[str, str] + Optional reason labels keyed by unplaced GUID + seed : int | None + Seed used for seeded nesting variants (if provided) + effective_spacing : float | None + Effective spacing used by nesting (may be epsilon-adjusted) total_material_volume : float Total material volume across all stocks in cubic millimeters total_stock_pieces : dict Detailed report of stock pieces needed with their dimensions + stock_utilization : list[dict] + Per-stock utilization metrics and capacity usage summary : str Human-readable summary of the nesting result """ - def __init__(self, stocks, tolerance=None): + def __init__(self, stocks, tolerance=None, unplaced_elements=None, seed=None, unplaced_reasons=None, effective_spacing=None): super(NestingResult, self).__init__() self.stocks = stocks if isinstance(stocks, list) else [stocks] self._tolerance = tolerance or TOL + self.unplaced_elements = list(unplaced_elements or []) + self.seed = seed + self.unplaced_reasons = dict(unplaced_reasons or {}) + self.effective_spacing = effective_spacing @property def tolerance(self): @@ -352,7 +533,19 @@ def tolerance(self): @property def __data__(self): - return {"stocks": self.stocks, "tolerance": self.tolerance} + return { + "stocks": self.stocks, + "tolerance": self.tolerance, + "unplaced_elements": self.unplaced_elements, + "seed": self.seed, + "unplaced_reasons": self.unplaced_reasons, + "effective_spacing": self.effective_spacing, + } + + @property + def unplaced_count(self): + """Number of elements that could not be nested.""" + return len(self.unplaced_elements) @property def total_material_volume(self): @@ -418,12 +611,127 @@ def summary(self): lines.append(f"BeamKeys: {beam_keys}") lines.append("BeamLengths({}): [{}]".format(self.tolerance.unit, ", ".join(formatted_lengths))) lines.append("Waste({}): {:.{prec}f}".format(self.tolerance.unit, waste, prec=self.tolerance.precision)) + lines.append("Spacing({}): {:.{prec}f}".format(self.tolerance.unit, float(stock.spacing), prec=self.tolerance.precision)) + lines.append("--------") + elif isinstance(stock, PlateStock): + lines.append( + "Dimensions({}): {:.{prec}f}x{:.{prec}f}x{:.{prec}f}".format( + self.tolerance.unit, + float(stock.dimensions[0]), + float(stock.dimensions[1]), + float(stock.thickness), + prec=self.tolerance.precision, + ) + ) + plate_keys = [data.key for data in stock.element_data.values()] + lines.append(f"PlateKeys: {plate_keys}") + lines.append("PlateCount: {}".format(len(plate_keys))) + + capacity = float(stock.dimensions[0] * stock.dimensions[1]) + used_area = getattr(stock, "_used_area", None) + if used_area is not None: + used_area = max(0.0, min(capacity, float(used_area))) + waste_area = max(0.0, capacity - used_area) + lines.append("UsedArea({}2): {:.{prec}f}".format(self.tolerance.unit, used_area, prec=self.tolerance.precision)) + lines.append("WasteArea({}2): {:.{prec}f}".format(self.tolerance.unit, waste_area, prec=self.tolerance.precision)) + else: + lines.append("UsedArea({}2): n/a".format(self.tolerance.unit)) + lines.append("WasteArea({}2): n/a".format(self.tolerance.unit)) + lines.append("Spacing({}): {:.{prec}f}".format(self.tolerance.unit, float(stock.spacing), prec=self.tolerance.precision)) lines.append("--------") else: raise NotImplementedError("Formatted summary not implemented for this stock type yet.") return "\n".join(lines) + @property + def stock_utilization(self): + """Generate per-stock utilization metrics. + + Returns + ------- + list[dict] + A list of dictionaries with per-stock metrics: + ``stock_index``, ``stock_type``, ``utilization_percent``, + ``capacity``, ``used``, ``remaining``, and ``element_count``. + """ + metrics = [] + + for i, stock in enumerate(self.stocks): + if isinstance(stock, BeamStock): + capacity = float(stock.length) + # BeamStock tracks next start position and includes spacing increment + # after each inserted beam. Remove one spacing for utilization. + if stock.element_data: + used = max(0.0, min(capacity, stock._current_x_position - stock.spacing)) + else: + used = 0.0 + remaining = max(0.0, capacity - used) + utilization = 100.0 * used / capacity if capacity > TOL.absolute else 0.0 + metrics.append( + { + "stock_index": i, + "stock_type": "BeamStock", + "utilization_percent": utilization, + "capacity": capacity, + "used": used, + "remaining": remaining, + "element_count": len(stock.element_data), + } + ) + continue + + if isinstance(stock, PlateStock): + capacity = float(stock.dimensions[0] * stock.dimensions[1]) + if hasattr(stock, "_used_area"): + used = max(0.0, min(capacity, float(stock._used_area))) + else: + used = max(0.0, min(capacity, capacity - stock._remaining_area)) + remaining = max(0.0, capacity - used) + utilization = 100.0 * used / capacity if capacity > TOL.absolute else 0.0 + metrics.append( + { + "stock_index": i, + "stock_type": "PlateStock", + "utilization_percent": utilization, + "capacity": capacity, + "used": used, + "remaining": remaining, + "element_count": len(stock.element_data), + } + ) + continue + + # Generic fallback for unknown stock implementations. + capacity = float(stock.length * stock.width * stock.height) + metrics.append( + { + "stock_index": i, + "stock_type": type(stock).__name__, + "utilization_percent": None, + "capacity": capacity, + "used": None, + "remaining": None, + "element_count": len(stock.element_data), + } + ) + + return metrics + + @property + def report_data(self): + """Return a compact nesting report payload.""" + return { + "total_stock_pieces": self.total_stock_pieces, + "total_material_volume": self.total_material_volume, + "stock_utilization": self.stock_utilization, + "unplaced_elements": self.unplaced_elements, + "unplaced_reasons": self.unplaced_reasons, + "unplaced_count": self.unplaced_count, + "seed": self.seed, + "effective_spacing": self.effective_spacing, + } + class BeamNester(object): """ @@ -612,3 +920,575 @@ def _best_fit_decreasing(beams, stock, spacing=0.0): stocks.append(new_stock) return stocks + + +class PlateNester(object): + """ + A class for optimizing 2D nesting of plates into stock pieces. + + This class implements algorithms to efficiently nest plates from a TimberModel + into available stock pieces, minimizing waste and cost. + + Parameters + ---------- + model : :class:`~compas_timber.model.TimberModel` + The timber model containing plates to nest + stock_catalog : list[:class:`PlateStock`] + Available PlateStock pieces for nesting. + spacing : float, optional + Spacing tolerance for cutting operations (kerf width, etc.) + per_group : bool, optional + Whether to nest plates per group or all together. Default is False (all together). + seed : int, optional + Seed for reproducible variant generation. Different seeds can produce + different placement orders for the same pieces. + + Attributes + ---------- + model : :class:`~compas_timber.model.TimberModel` + The timber model + stock_catalog : list[:class:`PlateStock`] + Available PlateStock pieces for nesting + spacing : float + Spacing tolerance for cutting operations (kerf width, etc.) + per_group : bool + Whether to nest plates per group or all together. Default is False (all together). + seed : int | None + Seed for reproducible variant generation. + """ + + def __init__(self, model, stock_catalog, spacing=0.0, per_group=False, seed=None): + self.model = model + self.spacing = spacing + self.per_group = per_group + self.seed = seed + self.stock_catalog = stock_catalog if isinstance(stock_catalog, list) else [stock_catalog] + + @property + def stock_catalog(self): + """Get the stock catalog.""" + return self._stock_catalog + + @stock_catalog.setter + def stock_catalog(self, value): + """Set the stock catalog with validation.""" + # Validate that all items are PlateStock instances + for i, stock in enumerate(value): + if not isinstance(stock, PlateStock): + raise TypeError(f"All items in stock_catalog must be PlateStock instances. Item at index {i} is {type(stock).__name__}") + self._stock_catalog = value + + def nest(self, fast=True): + """ + Perform 2D nesting of all plates in the model. + + Parameters + ---------- + fast : bool, optional + Whether to use a fast nesting algorithm (Skyline with bounding boxes) or a more + accurate one (Bottom-Left with polygon geometry). Default is True (fast). + + Returns + ------- + :class:`NestingResult` + Nesting result containing stocks with assigned plates and metadata + """ + nesting_stocks = [] + unplaced_plate_guids = [] + unplaced_reasons = {} + effective_spacing = self.spacing if self.spacing > TOL.absolute else TOL.absolute + if self.per_group: + # Collect plate groups + plate_groups = [] # list of lists of plates per group + standalone_plates = [] + for element in self.model.elements(): + if element.is_group_element: + group_children = list(self.model.get_elements_in_group(element, filter_=lambda e: e.is_plate)) + if group_children: + plate_groups.append(group_children) + + elif element.is_plate and element.parent is None: + # Handle standalone plates not in a group + standalone_plates.append(element) + if standalone_plates: + plate_groups.append(standalone_plates) + + # Nest each group separately + for group_index, plates in enumerate(plate_groups): + group_seed = None if self.seed is None else self.seed + group_index + stocks, unplaced, reasons = self._nest_plate_collection(plates, fast, seed=group_seed) + nesting_stocks.extend(stocks) + unplaced_plate_guids.extend(unplaced) + unplaced_reasons.update(reasons) + else: + # Nest ALL plates together + stocks, unplaced, reasons = self._nest_plate_collection(self.model.plates, fast, seed=self.seed) + nesting_stocks.extend(stocks) + unplaced_plate_guids.extend(unplaced) + unplaced_reasons.update(reasons) + + return NestingResult( + nesting_stocks, + tolerance=self.model.tolerance, + unplaced_elements=unplaced_plate_guids, + seed=self.seed, + unplaced_reasons=unplaced_reasons, + effective_spacing=effective_spacing, + ) + + def _nest_plate_collection(self, plates, fast=True, seed=None): + # Nest a collection of plates into stock pieces. + stocks = [] + unplaced = [] + unplaced_reasons = {} + stock_plate_map, incompatible_plates = self._sort_plates_by_stock(plates) + for plate in incompatible_plates: + guid = str(plate.guid) + unplaced.append(guid) + unplaced_reasons[guid] = "incompatible_stock" + + # Keep exact user spacing semantics for positive spacing, and use a tiny + # numerical clearance to avoid degenerate boolean/same-edge failures at 0. + spacing = self.spacing if self.spacing > TOL.absolute else TOL.absolute + + rng = random.Random(seed) if seed is not None else None + + for stock_type, compatible_plates in stock_plate_map.items(): + if not compatible_plates: + continue + # Apply selected algorithm + if fast: + result_stocks, unplaced_plates = self._fast_skyline_nest(compatible_plates, stock_type, spacing, rng=rng, return_unplaced=True) + else: + result_stocks, unplaced_plates = self._optimized_bottomleft_nest(compatible_plates, stock_type, spacing, rng=rng, return_unplaced=True) + + stocks.extend(result_stocks) + for plate, reason in unplaced_plates: + guid = str(plate.guid) + unplaced.append(guid) + unplaced_reasons[guid] = reason + return stocks, unplaced, unplaced_reasons + + def _sort_plates_by_stock(self, plates): + # Sort plates into compatible stock types based on their dimensions. + unnested_plates = [] + stock_plate_map = {stock: [] for stock in self.stock_catalog} + for plate in plates: + plate_matched = False + for stock in self.stock_catalog: + if stock.is_compatible_with(plate): + stock_plate_map[stock].append(plate) + plate_matched = True + break # Assign plate to first compatible stock type + + if not plate_matched: + unnested_plates.append(plate) + + if unnested_plates: + # Collect unique thicknesses from unnested plates + plate_details = set(plate.thickness for plate in unnested_plates) + # Format each thickness as a string + formatted_thicknesses = ["{}mm".format(int(thickness)) for thickness in plate_details] + + warn( + "Found {} plate(s) incompatible with available stock catalog. Plates with the following thicknesses will be skipped during nesting: {}".format( + len(unnested_plates), ", ".join(formatted_thicknesses) + ) + ) + return stock_plate_map, unnested_plates + + @classmethod + def _plate_orientations(cls, plate): + """Return candidate dimensions in both orientations. + + Returns + ------- + list[tuple[float, float, bool]] + Tuples of (width, height, rotated). + """ + return [ + (plate.blank.xsize, plate.blank.ysize, False), + (plate.blank.ysize, plate.blank.xsize, True), + ] + + @classmethod + def _plate_sort_key(cls, plate): + """Deterministic descending sort key for plate placement order.""" + width = plate.blank.xsize + height = plate.blank.ysize + area = width * height + perimeter = width + height + return (-area, -perimeter, -max(width, height), str(plate.guid)) + + @classmethod + def _ordered_plates(cls, plates, rng=None): + """Return plate order for placement. + + Without a seed, placement order is deterministic and area-driven. + With a seed, order is reproducibly shuffled to explore alternatives. + """ + ordered = list(plates) + if rng is None: + return sorted(ordered, key=cls._plate_sort_key) + rng.shuffle(ordered) + return ordered + + @classmethod + def _placement_transformation(cls, position, plate_width, rotated, spacing): + """Build placement transformation from skyline position.""" + half_spacing = spacing * 0.5 + if rotated: + rotation = Rotation.from_axis_and_angle(Vector(0, 0, 1), math.pi / 2) + translation = Translation.from_vector(Vector(position.x + half_spacing + plate_width, position.y + half_spacing, 0)) + return translation * rotation + return Translation.from_vector(Vector(position.x + half_spacing, position.y + half_spacing, 0)) + + @classmethod + def _best_skyline_candidate(cls, stock_piece, orientations, spacing, prioritize_bottomleft=False): + """Find the best skyline candidate across orientations for a stock piece.""" + best = None + segments = cls._skyline_segments(stock_piece) + + # lightweight state tracking for fast fit scoring. + current_max_x = getattr(stock_piece, "_used_max_x", 0.0) + current_max_y = getattr(stock_piece, "_used_max_y", 0.0) + + for plate_width, plate_height, rotated in orientations: + footprint_w = plate_width + spacing + footprint_h = plate_height + spacing + + position, waste, envelope_area = cls._find_skyline_position( + stock_piece, + footprint_w, + footprint_h, + segments=segments, + current_max_x=current_max_x, + current_max_y=current_max_y, + prioritize_bottomleft=prioritize_bottomleft, + ) + + if position is None: + continue + + if prioritize_bottomleft: + key = (position.y, position.x, envelope_area, waste) + else: + key = (envelope_area, waste, position.y, position.x) + + if best is None or key < best[0]: + best = (key, position, plate_width, plate_height, footprint_w, footprint_h, rotated) + return best + + @classmethod + def _warn_unplaceable_plate(cls, plate, stock, spacing): + """Emit a consistent warning when a plate cannot be placed on stock.""" + warn( + "Plate {}x{}mm cannot fit in stock {}x{}mm (with spacing {}).".format( + plate.blank.xsize, + plate.blank.ysize, + stock.dimensions[0], + stock.dimensions[1], + spacing, + ) + ) + + @classmethod + def _fast_skyline_nest(cls, plates, stock, spacing=0.0, rng=None, return_unplaced=False): + """Fast skyline packing using plate bounding boxes. + + Plates are processed by decreasing area and placed in the best skyline + candidate across both orientations. + """ + + sorted_plates = cls._ordered_plates(plates, rng=rng) + + stocks = [] + unplaced = [] + for plate in sorted_plates: + placed = False + orientations = cls._plate_orientations(plate) + + # Try to fit in existing stocks + for stock_piece in stocks: + best = cls._best_skyline_candidate(stock_piece, orientations, spacing, prioritize_bottomleft=False) + + if best is None: + continue + + _, position, plate_width, plate_height, footprint_w, footprint_h, rotated = best + transformation = cls._placement_transformation(position, plate_width, rotated, spacing) + + try: + stock_piece.add_element(plate, transformation) + cls._update_skyline(stock_piece, position, footprint_w, footprint_h) + placed = True + break + except ValueError: + # Candidate may be invalid with exact polygon geometry; try next candidate/stock. + continue + + # Create new stock if not placed + if not placed: + new_stock = PlateStock(stock.dimensions, stock.thickness, spacing=spacing) + best = cls._best_skyline_candidate(new_stock, orientations, spacing, prioritize_bottomleft=False) + + if best is not None: + _, position, plate_width, plate_height, footprint_w, footprint_h, rotated = best + transformation = cls._placement_transformation(position, plate_width, rotated, spacing) + + try: + new_stock.add_element(plate, transformation) + cls._update_skyline(new_stock, position, footprint_w, footprint_h) + stocks.append(new_stock) + except ValueError: + warn("Plate {}x{}mm could not be placed on a new stock despite skyline candidate.".format(plate.blank.xsize, plate.blank.ysize)) + unplaced.append((plate, "geometry_fit_failed")) + else: + cls._warn_unplaceable_plate(plate, stock, spacing) + unplaced.append((plate, "no_skyline_candidate")) + + if return_unplaced: + return stocks, unplaced + return stocks + + @classmethod + def _skyline_segments(cls, stock): + """Convert skyline polyline to horizontal segments (start_x, end_x, y).""" + segments = [] + points = stock._skyline.points + for i in range(len(points) - 1): + a = points[i] + b = points[i + 1] + if TOL.is_close(a.y, b.y) and b.x > a.x: + segments.append((a.x, b.x, a.y)) + return segments + + @classmethod + def _candidate_x_positions(cls, segments, plate_width, stock_width): + """Generate deterministic skyline x-candidates for a footprint width.""" + max_x = stock_width - plate_width + if max_x < -TOL.absolute: + return [] + + candidates = {0.0} + for sx, ex, _ in segments: + candidates.add(sx) + candidates.add(ex - plate_width) + + valid = [] + for x in candidates: + if x < -TOL.absolute: + continue + if x > max_x + TOL.absolute: + continue + clamped_x = max(0.0, min(x, max_x)) + valid.append(clamped_x) + + return sorted(set(valid)) + + @classmethod + def _find_skyline_position( + cls, + stock, + plate_width, + plate_height, + segments=None, + current_max_x=0.0, + current_max_y=0.0, + prioritize_bottomleft=False, + ): + """Find the best skyline position for a rectangular footprint. + + Returns + ------- + tuple[:class:`compas.geometry.Point` | None, float, float] + Candidate position, skyline waste metric, and resulting envelope area. + Returns ``(None, inf, inf)`` when no valid position exists. + """ + + best_position = None + best_waste = float("inf") + best_area = float("inf") + best_key = None + + if segments is None: + segments = cls._skyline_segments(stock) + + # Try deterministic skyline candidates derived from segment starts/ends. + for x in cls._candidate_x_positions(segments, plate_width, stock.dimensions[0]): + # Check if plate fits at this position + if x + plate_width > stock.dimensions[0]: + continue # Doesn't fit horizontally + + # Base Y is the max skyline height over the plate footprint span. + y = 0.0 + for sx, ex, sy in segments: + if ex <= x or sx >= x + plate_width: + continue + y = max(y, sy) + + if y + plate_height > stock.dimensions[1]: + continue # Doesn't fit vertically + + # Calculate waste (height difference to next skyline segment) + waste = cls._calculate_skyline_waste(segments, x, plate_width, y) + + # Prefer candidates that keep the global used envelope compact. + envelope_area = max(current_max_x, x + plate_width) * max(current_max_y, y + plate_height) + + # Create test position + test_position = Point(x, y, 0) + + # Create test rectangle polygon for validation + test_rect = Polygon([Point(x, y, 0), Point(x + plate_width, y, 0), Point(x + plate_width, y + plate_height, 0), Point(x, y + plate_height, 0)]) + + # Check if this candidate fits using stock fit logic + if stock.can_fit_element(test_rect): + if prioritize_bottomleft: + candidate_key = (y, x, envelope_area, waste) + else: + candidate_key = (envelope_area, waste, y, x) + + if best_key is None or candidate_key < best_key: + best_key = candidate_key + best_position = test_position + best_waste = waste + best_area = envelope_area + + return best_position, best_waste, best_area + + @classmethod + def _calculate_skyline_waste(cls, segments, x, width, base_y): + """Calculate skyline waste over a covered horizontal interval.""" + + waste = 0 + + for segment_start_x, segment_end_x, segment_y in segments: + # Check if this segment is in the covered region + if segment_start_x >= x + width: + break # Past the covered region + + if segment_end_x <= x: + continue # Before the covered region + + # This segment overlaps with the placement region + overlap_start = max(segment_start_x, x) + overlap_end = min(segment_end_x, x + width) + overlap_width = overlap_end - overlap_start + + # Add height difference as waste + height_diff = abs(segment_y - base_y) + waste += height_diff * overlap_width + + return waste + + @classmethod + def _update_skyline(cls, stock, position, plate_width, plate_height): + """Update skyline profile after reserving a rectangular footprint.""" + x = position.x + x2 = x + plate_width + new_y = position.y + plate_height + + # Keep compact used-envelope state for candidate scoring. + stock._used_max_x = max(getattr(stock, "_used_max_x", 0.0), x2) + stock._used_max_y = max(getattr(stock, "_used_max_y", 0.0), new_y) + + old_segments = cls._skyline_segments(stock) + new_segments = [] + + # Clip existing skyline by removing the covered interval [x, x2] + for sx, ex, sy in old_segments: + if ex <= x or sx >= x2: + new_segments.append([sx, ex, sy]) + continue + if sx < x: + new_segments.append([sx, x, sy]) + if ex > x2: + new_segments.append([x2, ex, sy]) + + # Add raised segment for the placed plate + new_segments.append([x, x2, new_y]) + new_segments.sort(key=lambda seg: seg[0]) + + # Merge adjacent segments with same height + merged = [] + for sx, ex, sy in new_segments: + if merged and TOL.is_close(merged[-1][1], sx) and TOL.is_close(merged[-1][2], sy): + merged[-1][1] = ex + else: + merged.append([sx, ex, sy]) + + # Rebuild skyline polyline with vertical steps between horizontal segments + if not merged: + stock._skyline = Polyline([[0, 0, 0], [stock.dimensions[0], 0, 0]]) + return + + points = [[merged[0][0], merged[0][2], 0], [merged[0][1], merged[0][2], 0]] + prev_ex = merged[0][1] + prev_y = merged[0][2] + + for sx, ex, sy in merged[1:]: + if sx > prev_ex: + points.append([sx, prev_y, 0]) + if not TOL.is_close(sy, prev_y): + points.append([sx, sy, 0]) + points.append([ex, sy, 0]) + prev_ex = ex + prev_y = sy + + # Remove duplicate consecutive points + cleaned = [] + for pt in points: + if not cleaned or cleaned[-1] != pt: + cleaned.append(pt) + + stock._skyline = Polyline(cleaned) + + @classmethod + def _optimized_bottomleft_nest(cls, plates, stock, spacing=0.0, rng=None, return_unplaced=False): + """Bottom-left skyline strategy prioritizing lower-left placements. + + Uses the same candidate generator as the fast method, but ranks solutions + by minimal ``y`` first and ``x`` second. + """ + sorted_plates = cls._ordered_plates(plates, rng=rng) + + stocks = [] + unplaced = [] + + for plate in sorted_plates: + best = None # tuple(rank_key, stock_piece, position, rotated, plate_width, plate_height, footprint_w, footprint_h) + orientations = cls._plate_orientations(plate) + + # Try existing stock pieces first + for stock_piece in stocks: + candidate = cls._best_skyline_candidate(stock_piece, orientations, spacing, prioritize_bottomleft=True) + if candidate is None: + continue + rank_key, position, plate_width, plate_height, footprint_w, footprint_h, rotated = candidate + if best is None or rank_key < best[0]: + best = (rank_key, stock_piece, position, rotated, plate_width, plate_height, footprint_w, footprint_h) + + if best is not None: + _, stock_piece, position, rotated, plate_width, plate_height, footprint_w, footprint_h = best + transformation = cls._placement_transformation(position, plate_width, rotated, spacing) + stock_piece.add_element(plate, transformation) + cls._update_skyline(stock_piece, position, footprint_w, footprint_h) + continue + + # If not placed, create a new stock and try from origin using skyline search + new_stock = PlateStock(stock.dimensions, stock.thickness, spacing=spacing) + candidate = cls._best_skyline_candidate(new_stock, orientations, spacing, prioritize_bottomleft=True) + if candidate is not None: + _, position, plate_width, plate_height, footprint_w, footprint_h, rotated = candidate + transformation = cls._placement_transformation(position, plate_width, rotated, spacing) + new_stock.add_element(plate, transformation) + cls._update_skyline(new_stock, position, footprint_w, footprint_h) + stocks.append(new_stock) + else: + cls._warn_unplaceable_plate(plate, stock, spacing) + unplaced.append((plate, "no_skyline_candidate")) + + if return_unplaced: + return stocks, unplaced + return stocks diff --git a/tests/compas_timber/test_nesting.py b/tests/compas_timber/test_nesting.py index 006e9e2fc5..558f56dc0c 100644 --- a/tests/compas_timber/test_nesting.py +++ b/tests/compas_timber/test_nesting.py @@ -1,18 +1,26 @@ import pytest +import random import warnings from compas.data import json_dumps from compas.data import json_loads from compas.geometry import Frame +from compas.geometry import Point from compas.geometry import Polyline +from compas.geometry import Transformation +from compas.geometry import Vector +from compas.tolerance import TOL from compas_timber.elements import Beam +from compas_timber.elements import Plate from compas_timber.elements import Panel from compas_timber.model import TimberModel from compas_timber.planning import BeamStock from compas_timber.planning import BeamNester from compas_timber.planning import NestedElementData from compas_timber.planning import NestingResult +from compas_timber.planning import PlateNester +from compas_timber.planning import PlateStock # ============================================================================ # BeamStock Tests @@ -602,6 +610,254 @@ def test_nest_per_group_multiple_sections(): assert nested_guids == all_beams +# ============================================================================ +# PlateNester Tests +# ============================================================================ + + +def _placed_rect_xy(stock, plate): + """Return axis-aligned rectangle as (min_x, min_y, max_x, max_y) in stock XY.""" + frame = stock.placement_data[str(plate.guid)] + xaxis = frame.xaxis + + # Axis-aligned 90-degree placement only. + if abs(xaxis.x) >= abs(xaxis.y): + min_x = frame.point.x + min_y = frame.point.y + width = plate.blank.xsize + height = plate.blank.ysize + else: + # In rotated placements, origin is at top-right corner of the footprint along X. + width = plate.blank.ysize + height = plate.blank.xsize + min_x = frame.point.x - width + min_y = frame.point.y + + return min_x, min_y, min_x + width, min_y + height + + +def _rect_gap(rect_a, rect_b): + """Return Euclidean gap between rectangles (0.0 if touching/overlapping).""" + ax0, ay0, ax1, ay1 = rect_a + bx0, by0, bx1, by1 = rect_b + dx = max(bx0 - ax1, ax0 - bx1, 0.0) + dy = max(by0 - ay1, ay0 - by1, 0.0) + return (dx**2 + dy**2) ** 0.5 + + +def _build_plate_nesting_model(): + model = TimberModel() + plates = [ + Plate(Frame.worldXY(), 1200, 600, 18), + Plate(Frame.worldXY(), 1000, 500, 18), + Plate(Frame.worldXY(), 700, 700, 18), + Plate(Frame.worldXY(), 500, 400, 18), + Plate(Frame.worldXY(), 300, 900, 18), + ] + for plate in plates: + model.add_element(plate) + return model, plates + + +def test_plate_stock_add_element_stores_btlx_partref_frame_mapping(): + """PartRef mapping should match the expected XY->BTLx conversion used in PlateStock.""" + plate = Plate(Frame.worldXY(), 1000, 500, 18) + stock = PlateStock((5000, 1250), 18) + + target = Frame(Point(750, 250, 0), Vector(0, 1, 0), Vector(-1, 0, 0)) + transformation = Transformation.from_frame_to_frame(Frame.worldXY(), target) + + stock.add_element(plate, transformation) + + placement_frame = stock.placement_data[str(plate.guid)] + partref_frame = stock.element_data[str(plate.guid)].frame + + btlx_xaxis = Vector(placement_frame.xaxis.x, 0.0, placement_frame.xaxis.y) + if btlx_xaxis.length < TOL.absolute: + btlx_xaxis = Vector(1, 0, 0) + + btlx_zaxis = Vector(placement_frame.yaxis.x, 0.0, placement_frame.yaxis.y) + if btlx_zaxis.length < TOL.absolute: + btlx_zaxis = Vector(-btlx_xaxis.z, 0.0, btlx_xaxis.x) + + expected_point = Point(placement_frame.point.x, 0.0, placement_frame.point.y) + btlx_zaxis * plate.blank.ysize + + assert TOL.is_close(partref_frame.point.x, expected_point.x) + assert TOL.is_close(partref_frame.point.y, expected_point.y) + assert TOL.is_close(partref_frame.point.z, expected_point.z) + + assert TOL.is_close(partref_frame.xaxis.x, btlx_xaxis.x) + assert TOL.is_close(partref_frame.xaxis.y, btlx_xaxis.y) + assert TOL.is_close(partref_frame.xaxis.z, btlx_xaxis.z) + + assert TOL.is_close(partref_frame.yaxis.x, 0.0) + assert TOL.is_close(partref_frame.yaxis.y, -1.0) + assert TOL.is_close(partref_frame.yaxis.z, 0.0) + + +def test_plate_nest_fast_assigns_all_and_inside_sheet(): + """Fast skyline nesting should place all plates inside sheet bounds.""" + model, plates = _build_plate_nesting_model() + stock_catalog = PlateStock((2400, 1200), 18) + result = PlateNester(model, stock_catalog, spacing=0.0).nest(fast=True) + + assert isinstance(result, NestingResult) + assert len(result.stocks) >= 1 + + nested_guids = set() + for stock in result.stocks: + for plate in plates: + guid = str(plate.guid) + if guid not in stock.placement_data: + continue + nested_guids.add(guid) + x0, y0, x1, y1 = _placed_rect_xy(stock, plate) + assert x0 >= -TOL.absolute + assert y0 >= -TOL.absolute + assert x1 <= stock.dimensions[0] + TOL.absolute + assert y1 <= stock.dimensions[1] + TOL.absolute + + assert nested_guids == {str(p.guid) for p in plates} + + +def test_plate_nest_fast_has_no_overlaps_per_stock(): + """Fast skyline nesting should not overlap placed plate footprints.""" + model, plates = _build_plate_nesting_model() + stock_catalog = PlateStock((2400, 1200), 18) + result = PlateNester(model, stock_catalog, spacing=0.0).nest(fast=True) + + for stock in result.stocks: + placed = [plate for plate in plates if str(plate.guid) in stock.placement_data] + rects = {str(plate.guid): _placed_rect_xy(stock, plate) for plate in placed} + for i, plate_a in enumerate(placed): + for plate_b in placed[i + 1 :]: + ax0, ay0, ax1, ay1 = rects[str(plate_a.guid)] + bx0, by0, bx1, by1 = rects[str(plate_b.guid)] + overlap_x = min(ax1, bx1) - max(ax0, bx0) + overlap_y = min(ay1, by1) - max(ay0, by0) + assert overlap_x <= TOL.absolute or overlap_y <= TOL.absolute + + +def test_plate_nest_spacing_respected_between_footprints(): + """Configured spacing should be preserved between nested plate rectangles.""" + model, plates = _build_plate_nesting_model() + spacing = 10.0 + stock_catalog = PlateStock((2600, 1300), 18) + result = PlateNester(model, stock_catalog, spacing=spacing).nest(fast=True) + + for stock in result.stocks: + placed = [plate for plate in plates if str(plate.guid) in stock.placement_data] + rects = [_placed_rect_xy(stock, plate) for plate in placed] + for i, rect_a in enumerate(rects): + for rect_b in rects[i + 1 :]: + assert _rect_gap(rect_a, rect_b) + TOL.absolute >= spacing + + +def test_plate_nest_is_deterministic_for_same_input(): + """Two runs with identical inputs should produce identical placements.""" + model, plates = _build_plate_nesting_model() + stock_catalog = PlateStock((2400, 1200), 18) + nester = PlateNester(model, stock_catalog, spacing=5.0) + + result_a = nester.nest(fast=True) + result_b = nester.nest(fast=True) + + positions_a = {} + positions_b = {} + + for stock_index, stock in enumerate(result_a.stocks): + for plate in plates: + guid = str(plate.guid) + if guid in stock.placement_data: + frame = stock.placement_data[guid] + positions_a[guid] = (stock_index, frame.point.x, frame.point.y, frame.xaxis.x, frame.xaxis.y) + + for stock_index, stock in enumerate(result_b.stocks): + for plate in plates: + guid = str(plate.guid) + if guid in stock.placement_data: + frame = stock.placement_data[guid] + positions_b[guid] = (stock_index, frame.point.x, frame.point.y, frame.xaxis.x, frame.xaxis.y) + + assert positions_a == positions_b + + +def test_plate_nest_fast_and_bottomleft_place_same_set_of_parts(): + """Fast and bottom-left modes should both nest the same part set when feasible.""" + model, plates = _build_plate_nesting_model() + stock_catalog = PlateStock((2400, 1200), 18) + + result_fast = PlateNester(model, stock_catalog, spacing=0.0).nest(fast=True) + result_slow = PlateNester(model, stock_catalog, spacing=0.0).nest(fast=False) + + fast_guids = set() + slow_guids = set() + + for stock in result_fast.stocks: + fast_guids.update(stock.placement_data.keys()) + + for stock in result_slow.stocks: + slow_guids.update(stock.placement_data.keys()) + + expected = {str(plate.guid) for plate in plates} + assert fast_guids == expected + assert slow_guids == expected + + +def test_plate_nest_spacing_zero_does_not_fail(): + """Zero spacing should remain a valid input and still place all feasible parts.""" + model, plates = _build_plate_nesting_model() + stock_catalog = PlateStock((2400, 1200), 18) + + result = PlateNester(model, stock_catalog, spacing=0.0).nest(fast=True) + + nested_guids = set() + for stock in result.stocks: + nested_guids.update(stock.placement_data.keys()) + + assert nested_guids == {str(plate.guid) for plate in plates} + + +def test_plate_nest_reports_unplaced_and_seed(): + """Nesting result should expose seed and unplaced part GUIDs.""" + model = TimberModel() + small = Plate(Frame.worldXY(), 800, 400, 18) + too_large = Plate(Frame.worldXY(), 2600, 1300, 18) + model.add_element(small) + model.add_element(too_large) + + with pytest.warns(UserWarning, match="Plate 2600.0x1300.0mm cannot fit in stock 2400x1200mm"): + result = PlateNester(model, PlateStock((2400, 1200), 18), spacing=0.0, seed=42).nest(fast=True) + + assert result.seed == 42 + assert str(too_large.guid) in result.unplaced_elements + assert str(small.guid) not in result.unplaced_elements + assert result.unplaced_count == 1 + assert result.unplaced_reasons[str(too_large.guid)] == "no_skyline_candidate" + assert result.effective_spacing > 0.0 + + report = result.report_data + assert report["seed"] == 42 + assert report["unplaced_count"] == 1 + assert report["unplaced_reasons"][str(too_large.guid)] == "no_skyline_candidate" + assert report["effective_spacing"] == result.effective_spacing + + +def test_plate_nest_seed_produces_reproducible_order_variants(): + """Seeded ordering should be reproducible and provide different variants.""" + _, plates = _build_plate_nesting_model() + rng_a1 = random.Random(7) + rng_a2 = random.Random(7) + rng_b = random.Random(19) + + order_a1 = [str(plate.guid) for plate in PlateNester._ordered_plates(plates, rng=rng_a1)] + order_a2 = [str(plate.guid) for plate in PlateNester._ordered_plates(plates, rng=rng_a2)] + order_b = [str(plate.guid) for plate in PlateNester._ordered_plates(plates, rng=rng_b)] + + assert order_a1 == order_a2 + assert order_a1 != order_b + + # ============================================================================ # NestingResult Tests # ============================================================================ @@ -650,6 +906,10 @@ def test_nesting_result_serialization(): # Test serialization data = result.__data__ assert len(data["stocks"]) == 1 + assert data["unplaced_elements"] == [] + assert data["seed"] is None + assert data["unplaced_reasons"] == {} + assert data["effective_spacing"] is None # Test deserialization restored_result = NestingResult.__from_data__(data) @@ -683,6 +943,25 @@ def test_nesting_result_summary_output(): assert "Spacing(MM): 5.000" in summary +def test_nesting_result_plate_summary_output(): + """Test the output format of the NestingResult summary property for plates.""" + stock = PlateStock((1000, 1000), 18, spacing=2.0) + plate = Plate(Frame.worldXY(), 500, 500, 18) + plate.name = "P1" + stock.add_element(plate) + + result = NestingResult([stock]) + summary = result.summary + + assert "PlateStock_0:" in summary + assert "Dimensions(MM): 1000.000x1000.000x18.000" in summary + assert "PlateKeys: ['P1-{}']".format(str(plate.guid)[:4]) in summary + assert "PlateCount: 1" in summary + assert "UsedArea(MM2): 250000.000" in summary + assert "WasteArea(MM2): 750000.000" in summary + assert "Spacing(MM): 2.000" in summary + + def test_nesting_result_properties(): """Test NestingResult summary properties.""" stock1 = BeamStock(6000, (120.000, 60.000)) @@ -707,3 +986,18 @@ def test_nesting_result_properties(): stock_pieces = result.total_stock_pieces assert stock_pieces["BeamStock"]["Dimensions(MM): 120.000x60.000x6000.000"] == 2 assert stock_pieces["BeamStock"]["Dimensions(MM): 80.000x40.000x5000.000"] == 1 + + +def test_nesting_result_plate_utilization(): + """Test plate stock utilization report metrics.""" + stock = PlateStock((1000, 1000), 18) + plate = Plate(Frame.worldXY(), 500, 500, 18) + stock.add_element(plate) + + result = NestingResult([stock]) + utilization = result.stock_utilization + + assert len(utilization) == 1 + assert utilization[0]["stock_type"] == "PlateStock" + assert utilization[0]["element_count"] == 1 + assert utilization[0]["utilization_percent"] == pytest.approx(25.0, rel=1e-6)