"""Projected silhouette area measurement."""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy as np
from unyt import unyt_quantity
from flow360.exceptions import Flow360ValueError
from flow360.log import log
from .geometry import (
SurfaceSelection,
collect_surface_face_ids,
load_transformed_surface_triangles,
require_geometry_root,
require_project_length_unit,
resolve_surface_entities,
)
if TYPE_CHECKING:
from flow360.component.simulation.draft_context.context import DraftContext
ProjectionDirection = Literal["X", "Y", "Z"]
RenderQuality = Literal["low", "medium", "high", "ultra"]
# Half space to keep, as a sign and an axis, bounded by that axis' zero plane. Internal:
# the only caller is submission-time trimming against the global Y=0 symmetry plane.
HalfSpace = Literal["+X", "-X", "+Y", "-Y", "+Z", "-Z"]
_PROJECTION_AXES = {"X": (1, 2), "Y": (0, 2), "Z": (0, 1)}
_RENDER_RESOLUTIONS = {"low": 512, "medium": 1024, "high": 2048, "ultra": 4096}
_MINIMUM_RASTER_DIMENSION = 256
# Sample points per rasterization call. Bounds the broadcast temporaries so neither a huge
# mesh nor a raster-spanning triangle turns one call into a multi-gigabyte allocation.
_MAX_BATCH_SAMPLES = 1 << 20
# Footprint size, in pixels, up to which batching wins. Batching reaches the raster through
# gathers and scatters, which cost several times more per pixel than a plain slice; it pays
# only while the per-triangle Python overhead it removes outweighs that. Measured crossover
# is a few thousand pixels, and the curve is shallow either side of it.
_MAX_BATCHED_FOOTPRINT = 2048
[docs]
def projected_area(
draft: DraftContext,
*,
surfaces: SurfaceSelection,
direction: ProjectionDirection = "X",
render_quality: RenderQuality = "medium",
) -> unyt_quantity:
"""Compute the rasterized projected silhouette area of selected surfaces.
Parameters
----------
draft :
Draft created from a Geometry resource; supplies the tessellation and any
coordinate-system transforms applied to the selected surfaces.
surfaces :
Surfaces to project. Accepts a single ``Surface``, a single
``SurfaceSelector``, a list mixing the two, or an ``EntityList[Surface]``
(for example ``my_wall.entities``, to reuse a boundary condition's
assignment). Duplicates are removed automatically -- there is no need to
deduplicate before calling.
direction :
Global axis to project along.
render_quality :
Raster resolution preset, and therefore how accurate the result is. The area
is measured by counting whole pixels of a rasterized silhouette, so the error
is proportional to pixel size: each step up halves it, at roughly four times
the computation. Raise this when a small discrepancy matters.
Returns
-------
unyt_quantity
Projected area expressed in the geometry's project length unit squared.
Overlapping surfaces are counted once -- the result is the silhouette
union, not a sum of per-surface areas.
Example
-------
>>> import flow360 as fl
>>> geometry = fl.Geometry.from_cloud(id="...")
>>> with fl.create_draft(new_run_from=geometry, face_grouping="faceId") as draft:
... wheels = fl.SurfaceSelector(name="wheels").match("*rim*")
... body = fl.SurfaceSelector(name="body").match("*body*")
... area = fl.measure.projected_area(draft, surfaces=[body, wheels], direction="X")
====
"""
return _projected_area(
draft,
surfaces=surfaces,
direction=direction,
render_quality=render_quality,
clip=None,
)
def _projected_area(
draft: DraftContext,
*,
surfaces: SurfaceSelection,
direction: ProjectionDirection,
render_quality: RenderQuality,
clip: HalfSpace | None,
) -> unyt_quantity:
"""Measure a projected area, optionally restricted to one half space.
The half-space restriction stays private because it is derived from finalized
simulation settings at submission time rather than chosen by hand: a user cannot know
whether their geometry needs trimming without knowing whether the mesher will trim it.
"""
operation = "projected_area()"
if direction not in _PROJECTION_AXES:
raise Flow360ValueError(
f"{operation} direction must be one of X, Y, or Z; got {direction!r}."
)
if render_quality not in _RENDER_RESOLUTIONS:
raise Flow360ValueError(
f"{operation} render_quality must be one of low, medium, high, or ultra; "
f"got {render_quality!r}."
)
geometry_root = require_geometry_root(draft, operation=operation)
length_unit = require_project_length_unit(geometry_root, operation=operation)
resolved_surfaces = resolve_surface_entities(draft, surfaces, operation=operation)
surface_face_ids = collect_surface_face_ids(resolved_surfaces, operation=operation)
triangles = load_transformed_surface_triangles(draft, geometry_root, surface_face_ids)
if clip is not None:
selected_count = len(triangles)
triangles = _clip_to_half_space(triangles, clip)
# Always report the effect, not just that a restriction was requested: equal counts
# mean the geometry was already inside the half space and nothing was removed.
log.info(
f"Trimmed the selected tessellation at {clip[1]}=0, keeping the {clip} side: "
f"triangle count {selected_count} -> {len(triangles)}."
)
if len(triangles) == 0:
raise Flow360ValueError(
f"None of the selected surfaces lie on the {clip} side of the {clip[1]}=0 "
"plane, so the projected area would be zero. Check that the selected "
"surfaces belong to the half of the model being meshed."
)
area = _compute_projected_area(
triangles,
direction=direction,
base_resolution=_RENDER_RESOLUTIONS[render_quality],
)
return area * length_unit**2
def _compute_projected_area( # pylint: disable=too-many-locals
triangles: np.ndarray,
*,
direction: ProjectionDirection,
base_resolution: int,
) -> float:
"""Project triangles and estimate their silhouette union with a boolean raster.
Whether a pixel centre falls inside a triangle is independent per (triangle, pixel)
pair and the results are combined with OR, so triangles are rasterized in vectorized
batches instead of one per Python iteration. Batches are grouped by pixel-footprint
size, which is what lets one broadcast cover many triangles at once.
Measures exactly the triangles it is given. To restrict a measurement to part of the
geometry, pass it through :func:`_clip_to_half_space` first; the raster is then sized
to what survives.
"""
horizontal_axis, vertical_axis = _PROJECTION_AXES[direction]
# Select the two projected axes straight into their final layout: going through
# `asarray(..., float64)[..., axes]` would materialize a full-size upcast of all three
# axes first, which on automotive meshes is hundreds of megabytes of pure waste.
projected = np.empty((len(triangles), 3, 2), dtype=np.float64)
projected[..., 0] = triangles[..., horizontal_axis]
projected[..., 1] = triangles[..., vertical_axis]
bounds_minimum = projected.min(axis=(0, 1))
bounds_maximum = projected.max(axis=(0, 1))
projected -= bounds_minimum
projected_width, projected_height = bounds_maximum - bounds_minimum
if projected_width <= 0 or projected_height <= 0:
return 0.0
raster_width, raster_height = _raster_dimensions(
projected_width, projected_height, base_resolution
)
pixel_width = projected_width / raster_width
pixel_height = projected_height / raster_height
occupancy = np.zeros((raster_height, raster_width), dtype=bool)
edge_tolerance = (
np.finfo(np.float64).eps * max(projected_width, projected_height, 1.0) ** 2 * 32
)
corners = [(projected[:, index, 0], projected[:, index, 1]) for index in range(3)]
twice_area = _edge_function(corners[0], corners[1], corners[2][0], corners[2][1])
column_start, column_stop = _pixel_intervals(
projected[:, :, 0].min(axis=1), projected[:, :, 0].max(axis=1), pixel_width, raster_width
)
row_start, row_stop = _pixel_intervals(
projected[:, :, 1].min(axis=1), projected[:, :, 1].max(axis=1), pixel_height, raster_height
)
footprint_widths = column_stop - column_start
footprint_heights = row_stop - row_start
contributing = np.flatnonzero(
(np.abs(twice_area) > edge_tolerance) & (footprint_widths > 0) & (footprint_heights > 0)
)
orientation = np.copysign(1.0, twice_area)
for group in _footprint_groups(contributing, footprint_heights, footprint_widths, raster_width):
footprint_height = int(footprint_heights[group[0]])
footprint_width = int(footprint_widths[group[0]])
# A footprint too tall to fit the sample cap on its own is split into row bands, so
# no single evaluation allocates more than the cap regardless of triangle size.
band_height = max(1, min(footprint_height, _MAX_BATCH_SAMPLES // footprint_width))
if footprint_height * footprint_width <= _MAX_BATCHED_FOOTPRINT:
mark = _mark_covered_batch
batch_size = max(1, _MAX_BATCH_SAMPLES // (band_height * footprint_width))
else:
mark = _mark_covered_window
batch_size = 1
for batch_start in range(0, len(group), batch_size):
batch = group[batch_start : batch_start + batch_size]
vertices = projected[batch]
batch_columns = column_start[batch]
batch_orientation = orientation[batch]
for band_start in range(0, footprint_height, band_height):
mark(
occupancy,
vertices,
row_start[batch] + band_start,
batch_columns,
batch_orientation,
footprint=(
min(band_height, footprint_height - band_start),
footprint_width,
),
pixel_width=pixel_width,
pixel_height=pixel_height,
edge_tolerance=edge_tolerance,
)
return float(np.count_nonzero(occupancy) * pixel_width * pixel_height)
def _plane_crossing(
inside_point: np.ndarray,
inside_depth: np.ndarray,
outside_point: np.ndarray,
outside_depth: np.ndarray,
) -> np.ndarray:
"""Return where each inside-to-outside segment crosses the plane.
``inside_depth`` is non-negative and ``outside_depth`` negative, so the denominator is
always positive and the fraction lands in [0, 1).
"""
fraction = inside_depth / (inside_depth - outside_depth)
return inside_point + fraction[:, None] * (outside_point - inside_point)
def _clip_to_half_space(triangles: np.ndarray, half_space: HalfSpace) -> np.ndarray:
"""Return the part of `triangles` on the kept side of the half space's zero plane.
Triangles crossing the plane are cut exactly, not kept or dropped whole. The intended
plane is a symmetry plane, and a surface mesh crossing one carries a full line of
triangles there; deciding those whole either way would bias the area by roughly a mesh
cell along the entire cut.
Only fragments with area survive. A triangle merely touching the plane from the
discarded side cuts to a zero-area sliver, so keeping those would make the returned
count claim retained geometry where there is none.
"""
axis = "XYZ".index(half_space[1])
sign = 1.0 if half_space[0] == "+" else -1.0
# Non-negative depth means the vertex is in the half space being kept.
depth = sign * triangles[:, :, axis].astype(np.float64)
inside = depth >= 0.0
inside_count = inside.sum(axis=1)
kept = [triangles[inside_count == 3]]
crossing_single = np.flatnonzero(inside_count == 1)
if len(crossing_single) > 0:
# One vertex survives, so the cut leaves a single smaller triangle.
corner, corner_depth = _cyclic_corners(
triangles, depth, crossing_single, np.argmax(inside[crossing_single], axis=1)
)
kept.append(
np.stack(
[
corner[0],
_plane_crossing(corner[0], corner_depth[0], corner[1], corner_depth[1]),
_plane_crossing(corner[0], corner_depth[0], corner[2], corner_depth[2]),
],
axis=1,
)
)
crossing_double = np.flatnonzero(inside_count == 2)
if len(crossing_double) > 0:
# One vertex is cut away, leaving a quadrilateral; fan it from the first survivor.
# Rotating the outside vertex to the front puts the two survivors at 1 and 2, so
# the quadrilateral in cyclic order is (1, 2, crossing on 2->0, crossing on 0->1).
corner, corner_depth = _cyclic_corners(
triangles, depth, crossing_double, np.argmin(inside[crossing_double], axis=1)
)
beyond_second = _plane_crossing(corner[2], corner_depth[2], corner[0], corner_depth[0])
before_first = _plane_crossing(corner[1], corner_depth[1], corner[0], corner_depth[0])
kept.append(np.stack([corner[1], corner[2], beyond_second], axis=1))
kept.append(np.stack([corner[1], beyond_second, before_first], axis=1))
return _with_area(np.concatenate(kept))
def _with_area(triangles: np.ndarray) -> np.ndarray:
"""Drop triangles whose three vertices are collinear, so contribute no area."""
first_edge = triangles[:, 1] - triangles[:, 0]
second_edge = triangles[:, 2] - triangles[:, 0]
return triangles[np.any(np.cross(first_edge, second_edge) != 0, axis=1)]
def _cyclic_corners(
triangles: np.ndarray, depth: np.ndarray, selected: np.ndarray, first: np.ndarray
) -> tuple[list[np.ndarray], list[np.ndarray]]:
"""Rotate each selected triangle so `first` leads, preserving winding order.
Returns the three corners and their depths, so callers can name vertices by their
position relative to the one that decides how the triangle is cut.
"""
rotated = triangles[selected]
rotated_depth = depth[selected]
rows = np.arange(len(selected))
corners = [rotated[rows, (first + offset) % 3] for offset in range(3)]
depths = [rotated_depth[rows, (first + offset) % 3] for offset in range(3)]
return corners, depths
def _footprint_groups(
contributing: np.ndarray,
footprint_heights: np.ndarray,
footprint_widths: np.ndarray,
raster_width: int,
) -> list[np.ndarray]:
"""Group triangle indices by identical pixel footprint, largest footprint first.
Grouping is on the exact footprint rather than a rounded one so a batch needs no
padding: every triangle in a group covers the same window shape, already clamped
inside the raster. Largest first because those fill the raster fastest, which lets
``_mark_covered_pixels`` discard the many small triangles that land on ground already
covered -- an automotive silhouette stacks body, underbody, suspension and hidden
wheels along the same view direction.
Every returned group is non-empty, so callers can read a group's footprint off its
first member: ``np.split`` yields one empty group for empty input, which is dropped.
"""
heights = footprint_heights[contributing]
widths = footprint_widths[contributing]
footprint_key = heights * (raster_width + 1) + widths
order = np.lexsort((footprint_key, -(heights * widths)))
grouped = contributing[order]
sorted_key = footprint_key[order]
groups = np.split(grouped, np.flatnonzero(np.diff(sorted_key)) + 1)
return [group for group in groups if len(group) > 0]
def _covered_mask(
corners: list[tuple[np.ndarray, np.ndarray]],
sample_x: np.ndarray,
sample_y: np.ndarray,
orientation: np.ndarray,
edge_tolerance: float,
) -> np.ndarray:
"""Return which sample points lie inside the triangles described by `corners`.
Shapes are the caller's business: pass scalars per corner for one triangle, or
leading-axis arrays to test a whole batch at once. Both broadcast against the sample
grids the same way, which keeps one copy of the containment arithmetic.
"""
covered = (
orientation * _edge_function(corners[0], corners[1], sample_x, sample_y) >= -edge_tolerance
)
covered &= (
orientation * _edge_function(corners[1], corners[2], sample_x, sample_y) >= -edge_tolerance
)
covered &= (
orientation * _edge_function(corners[2], corners[0], sample_x, sample_y) >= -edge_tolerance
)
return covered
def _mark_covered_window( # pylint: disable=too-many-arguments,too-many-locals
occupancy: np.ndarray,
vertices: np.ndarray,
row_start: np.ndarray,
column_start: np.ndarray,
orientation: np.ndarray,
*,
footprint: tuple[int, int],
pixel_width: float,
pixel_height: float,
edge_tolerance: float,
) -> None:
"""Rasterize a single triangle into a contiguous raster window.
Used for footprints too large to batch, where slicing beats the gather and scatter
that make batching worthwhile for small ones.
"""
footprint_height, footprint_width = footprint
first_row, first_column = int(row_start[0]), int(column_start[0])
window = occupancy[
first_row : first_row + footprint_height,
first_column : first_column + footprint_width,
]
if window.all():
return
sample_x = ((first_column + np.arange(footprint_width) + 0.5) * pixel_width)[None, :]
sample_y = ((first_row + np.arange(footprint_height) + 0.5) * pixel_height)[:, None]
corners = [(vertices[0, index, 0], vertices[0, index, 1]) for index in range(3)]
window |= _covered_mask(corners, sample_x, sample_y, orientation[0], edge_tolerance)
def _mark_covered_batch( # pylint: disable=too-many-arguments,too-many-locals
occupancy: np.ndarray,
vertices: np.ndarray,
row_start: np.ndarray,
column_start: np.ndarray,
orientation: np.ndarray,
*,
footprint: tuple[int, int],
pixel_width: float,
pixel_height: float,
edge_tolerance: float,
) -> None:
"""Set every pixel whose centre lies inside one of a batch of same-footprint triangles.
``vertices`` is ``(batch, 3, 2)`` in raster coordinates. Writes only ever set True, so
the scatter tolerates the duplicate indices that overlapping triangles produce.
"""
footprint_height, footprint_width = footprint
rows = row_start[:, None] + np.arange(footprint_height)
columns = column_start[:, None] + np.arange(footprint_width)
# A triangle whose whole footprint is already covered can only set pixels that are
# set, so drop it before evaluating any edge function.
pending = ~occupancy[rows[:, :, None], columns[:, None, :]].all(axis=(1, 2))
if not pending.any():
return
if not pending.all():
vertices, rows, columns = vertices[pending], rows[pending], columns[pending]
orientation = orientation[pending]
sample_x = ((columns + 0.5) * pixel_width)[:, None, :]
sample_y = ((rows + 0.5) * pixel_height)[:, :, None]
corners = [
(vertices[:, index, 0][:, None, None], vertices[:, index, 1][:, None, None])
for index in range(3)
]
covered = _covered_mask(corners, sample_x, sample_y, orientation[:, None, None], edge_tolerance)
batch_index, row_index, column_index = np.nonzero(covered)
occupancy[rows[batch_index, row_index], columns[batch_index, column_index]] = True
def _raster_dimensions(width: float, height: float, base_resolution: int) -> tuple[int, int]:
"""Match the Web user interface's quality and aspect-ratio raster sizing."""
if width >= height:
raster_width = base_resolution
raster_height = int(np.floor(base_resolution * height / width + 0.5))
else:
raster_height = base_resolution
raster_width = int(np.floor(base_resolution * width / height + 0.5))
return (
max(_MINIMUM_RASTER_DIMENSION, raster_width),
max(_MINIMUM_RASTER_DIMENSION, raster_height),
)
def _pixel_intervals(
minimum: np.ndarray, maximum: np.ndarray, pixel_size: float, pixel_count: int
) -> tuple[np.ndarray, np.ndarray]:
"""Return per-triangle half-open pixel-centre intervals intersecting each bound."""
start = np.maximum(np.ceil(minimum / pixel_size - 0.5).astype(np.int64), 0)
stop = np.minimum(np.floor(maximum / pixel_size - 0.5).astype(np.int64) + 1, pixel_count)
return start, stop
def _edge_function(
start: np.ndarray, end: np.ndarray, sample_x: np.ndarray | float, sample_y: np.ndarray | float
) -> np.ndarray | float:
"""Return the signed two-dimensional edge function at sample positions."""
return (end[0] - start[0]) * (sample_y - start[1]) - (end[1] - start[1]) * (sample_x - start[0])