hullprod 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hullprod/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from ._version import __version__
2
+ from .api import assess, assess_hull
3
+ from .mesh_quality import analyze_mesh_quality
4
+ from .schema import FIELD_SCHEMA_VERSION, OUTPUT_LAYOUT_VERSION, SCHEMA_VERSION
5
+ from .types import MetricResult, ProducibilityConfig
6
+
7
+ __all__ = [
8
+ "FIELD_SCHEMA_VERSION",
9
+ "OUTPUT_LAYOUT_VERSION",
10
+ "SCHEMA_VERSION",
11
+ "MetricResult",
12
+ "ProducibilityConfig",
13
+ "__version__",
14
+ "analyze_mesh_quality",
15
+ "assess",
16
+ "assess_hull",
17
+ ]
hullprod/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Authoritative HullProd package version."""
2
+
3
+ __version__ = "1.0.0"
hullprod/api.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import replace
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from time import perf_counter
8
+
9
+ from ._version import __version__
10
+ from .backends import backend_for_path
11
+ from .brep_geometry import sha256_file
12
+ from .export import export_visualization_fields
13
+ from .plotting import save_default_plots
14
+ from .report import write_outputs
15
+ from .types import ProducibilityConfig
16
+
17
+
18
+ def assess_hull(
19
+ geometry: str | Path,
20
+ config: ProducibilityConfig | None = None,
21
+ n_stations: int | None = None,
22
+ out_dir: str | Path | None = None,
23
+ make_plots: bool = False,
24
+ largest_component: bool = False,
25
+ backend: str | None = None,
26
+ progress: Callable[[str], None] | None = None,
27
+ overwrite: bool = False,
28
+ ):
29
+ """Assess a mesh or native CAD BRep through the appropriate backend."""
30
+ if config is None:
31
+ config = ProducibilityConfig()
32
+ if n_stations is not None:
33
+ config.n_stations = n_stations
34
+
35
+ started = perf_counter()
36
+ emit = progress or (lambda _message: None)
37
+ geometry = Path(geometry).resolve()
38
+ if not geometry.exists():
39
+ raise FileNotFoundError(f"Input file not found: {geometry}")
40
+ if not geometry.is_file():
41
+ raise ValueError(f"Input path is not a file: {geometry}")
42
+ output_path = Path(out_dir).resolve() if out_dir is not None else None
43
+ if output_path is not None and output_path.is_dir() and any(output_path.iterdir()):
44
+ if not overwrite:
45
+ raise FileExistsError(
46
+ f"Output directory is not empty: {output_path}. "
47
+ "Choose --out elsewhere or pass --overwrite."
48
+ )
49
+ selected_backend = backend_for_path(geometry, override=backend)
50
+ assessment = selected_backend.assess(
51
+ geometry,
52
+ config,
53
+ largest_component=largest_component,
54
+ progress=progress,
55
+ )
56
+ result = assessment.result
57
+ result.metadata["hullprod_version"] = __version__
58
+ result.metadata["input_geometry"] = {
59
+ "path": str(geometry),
60
+ "name": geometry.name,
61
+ "extension": geometry.suffix.lower(),
62
+ "sha256": sha256_file(geometry),
63
+ }
64
+ result.metadata["run_timestamp_utc"] = datetime.now(timezone.utc).isoformat()
65
+
66
+ if output_path is not None:
67
+ out_dir = output_path
68
+ if assessment.visualization_mesh is not None:
69
+ emit("Writing visualization fields...")
70
+ fields_started = perf_counter()
71
+ field_paths = export_visualization_fields(
72
+ assessment.visualization_mesh,
73
+ result,
74
+ out_dir / "fields",
75
+ )
76
+ result.metadata.setdefault("output_paths", {}).update(field_paths)
77
+ geometry_dir = out_dir / "geometry"
78
+ geometry_dir.mkdir(parents=True, exist_ok=True)
79
+ assessment.visualization_mesh.export(geometry_dir / "display_mesh.stl")
80
+ result.metadata.setdefault("phase_timings", {})["visualization_fields_seconds"] = (
81
+ perf_counter() - fields_started
82
+ )
83
+ if make_plots:
84
+ emit("Generating plots...")
85
+ plots_started = perf_counter()
86
+ plot_paths = save_default_plots(
87
+ assessment.visualization_mesh,
88
+ result,
89
+ out_dir / "plots",
90
+ experimental=config.experimental_metrics,
91
+ )
92
+ result.metadata.setdefault("output_paths", {}).update(plot_paths)
93
+ result.metadata["phase_timings"]["plots_seconds"] = perf_counter() - plots_started
94
+ else:
95
+ raise RuntimeError(
96
+ "Distributed field export requires a visualization surface; "
97
+ "enable the BRep display tessellation."
98
+ )
99
+ emit("Writing report and manifests...")
100
+ result.metadata.setdefault("phase_timings", {})["total_elapsed_seconds"] = (
101
+ perf_counter() - started
102
+ )
103
+ output_paths = write_outputs(result, out_dir)
104
+ result.metadata.setdefault("output_paths", {}).update(output_paths)
105
+
106
+ result.metadata.setdefault("phase_timings", {})["total_elapsed_seconds"] = perf_counter() - started
107
+ return result
108
+
109
+
110
+ def assess(
111
+ geometry: str | Path,
112
+ *,
113
+ out_dir: str | Path | None = None,
114
+ lref: float | None = None,
115
+ plots: bool = False,
116
+ experimental: bool = False,
117
+ overwrite: bool = False,
118
+ config: ProducibilityConfig | None = None,
119
+ largest_component: bool = False,
120
+ progress: Callable[[str], None] | None = None,
121
+ ):
122
+ """Assess one BRep or mesh file through the public HullProd 1.0 pipeline.
123
+
124
+ The file extension selects the native BRep or triangle-mesh backend. When
125
+ ``out_dir`` is supplied, the same artifacts used by the CLI are written.
126
+ """
127
+ resolved = replace(config) if config is not None else ProducibilityConfig()
128
+ if lref is not None:
129
+ resolved.length_ref = float(lref)
130
+ if experimental:
131
+ resolved.experimental_metrics = True
132
+ if resolved.n_stations == 0:
133
+ resolved.n_stations = 80
134
+ return assess_hull(
135
+ geometry,
136
+ config=resolved,
137
+ out_dir=out_dir,
138
+ make_plots=plots,
139
+ largest_component=largest_component,
140
+ progress=progress,
141
+ overwrite=overwrite,
142
+ )
hullprod/backends.py ADDED
@@ -0,0 +1,195 @@
1
+ """Representation-specific backends behind HullProd's common assessment API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from time import perf_counter
9
+ from typing import Any, Protocol
10
+
11
+ import trimesh
12
+
13
+ from .io import clean_mesh, load_mesh
14
+ from .mesh_ops import largest_connected_component
15
+ from .metrics import compute_metrics
16
+ from .types import MetricResult, ProducibilityConfig
17
+ from .units import mesh_unit_metadata
18
+
19
+ MESH_SUFFIXES = frozenset({".stl", ".obj", ".ply"})
20
+ BREP_SUFFIXES = frozenset({".igs", ".iges", ".stp", ".step"})
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BackendAssessment:
25
+ """Common result plus the optional geometry used only for output drawing."""
26
+
27
+ result: MetricResult
28
+ visualization_mesh: trimesh.Trimesh | None
29
+
30
+
31
+ class GeometryBackend(Protocol):
32
+ """Minimal contract shared by the mesh and native-BRep implementations."""
33
+
34
+ name: str
35
+ representation: str
36
+
37
+ def assess(
38
+ self,
39
+ geometry: Path,
40
+ config: ProducibilityConfig,
41
+ *,
42
+ largest_component: bool,
43
+ progress: Callable[[str], None] | None = None,
44
+ ) -> BackendAssessment: ...
45
+
46
+
47
+ class MeshBackend:
48
+ """Stable 1.0 triangulated-surface backend."""
49
+
50
+ name = "mesh_rusinkiewicz"
51
+ representation = "mesh"
52
+
53
+ def assess(
54
+ self,
55
+ geometry: Path,
56
+ config: ProducibilityConfig,
57
+ *,
58
+ largest_component: bool,
59
+ progress: Callable[[str], None] | None = None,
60
+ ) -> BackendAssessment:
61
+ emit = progress or (lambda _message: None)
62
+ emit("Loading triangle mesh...")
63
+ mesh = clean_mesh(load_mesh(geometry))
64
+ if len(mesh.vertices) == 0 or len(mesh.faces) == 0 or mesh.area <= 0.0:
65
+ raise ValueError(f"Mesh is empty or has no positive-area surface: {geometry}")
66
+ if largest_component:
67
+ mesh = largest_connected_component(mesh)
68
+ emit(f"Loaded {len(mesh.vertices):,} vertices and {len(mesh.faces):,} triangles.")
69
+ result = compute_metrics(mesh, config=config, progress=progress)
70
+ result.metadata.setdefault("representation", self.representation)
71
+ result.metadata.setdefault("backend", self.name)
72
+ result.metadata.setdefault("source_format", geometry.suffix.lower().lstrip("."))
73
+ result.metadata.setdefault("canonical_metrics_depend_on_display_mesh", False)
74
+ result.metadata.setdefault("units", mesh_unit_metadata())
75
+ return BackendAssessment(result=result, visualization_mesh=mesh)
76
+
77
+
78
+ class BRepBackend:
79
+ """Direct parametric-surface backend; its display mesh is non-canonical."""
80
+
81
+ name = "brep_native"
82
+ representation = "brep"
83
+
84
+ def assess(
85
+ self,
86
+ geometry: Path,
87
+ config: ProducibilityConfig,
88
+ *,
89
+ largest_component: bool,
90
+ progress: Callable[[str], None] | None = None,
91
+ ) -> BackendAssessment:
92
+ if largest_component:
93
+ raise ValueError(
94
+ "--largest-component is mesh-specific; select BRep roots explicitly "
95
+ "with brep_root_indices"
96
+ )
97
+ from .brep_display import generate_brep_display
98
+ from .brep_geometry import load_brep
99
+ from .brep_metrics import compute_brep_metrics
100
+ from .cache import brep_cache_key, load_cached_result, save_cached_result
101
+ from .reference_length import reference_length_preflight
102
+ from .units import brep_unit_metadata
103
+
104
+ emit = progress or (lambda _message: None)
105
+ emit("Loading native BRep...")
106
+ load_started = perf_counter()
107
+ model = load_brep(geometry, root_indices=config.brep_root_indices)
108
+ load_elapsed = perf_counter() - load_started
109
+ emit(f"Loaded {len(model.faces)} faces in {load_elapsed:.1f} s.")
110
+ cache_key = brep_cache_key(model.metadata["source_sha256"], config)
111
+ cache_started = perf_counter()
112
+ cached = load_cached_result(cache_key) if config.brep_cache else None
113
+ cache_elapsed = perf_counter() - cache_started
114
+ if cached is not None:
115
+ preflight_messages, reference_warning = reference_length_preflight(
116
+ cached.metadata["reference_length"],
117
+ brep_unit_metadata(model.metadata),
118
+ )
119
+ for message in preflight_messages:
120
+ emit(message)
121
+ if reference_warning:
122
+ emit(f"WARNING: {reference_warning}")
123
+ emit("Using cached native BRep metric integrals and exact sections.")
124
+ display_mesh = None
125
+ cached.metadata["cache"] = {"enabled": True, "key": cache_key, "hit": True}
126
+ phase_timings = {
127
+ "native_brep_load_seconds": load_elapsed,
128
+ "canonical_cache_lookup_seconds": cache_elapsed,
129
+ }
130
+ if config.brep_display_mesh:
131
+ quality = config.brep_display_quality.upper()
132
+ emit(f"Generating {quality} display tessellation...")
133
+ display_started = perf_counter()
134
+ length_ref = float(cached.metrics["length_ref"])
135
+ display = generate_brep_display(model, config, length_ref=length_ref)
136
+ display_elapsed = perf_counter() - display_started
137
+ display_mesh = display.mesh
138
+ cached.local_fields = display.local_fields
139
+ cached.metadata["display_mesh"] = display.metadata
140
+ phase_timings["display_tessellation_seconds"] = display_elapsed
141
+ emit(
142
+ "Display tessellation completed: "
143
+ f"{len(display_mesh.faces):,} triangles in {display_elapsed:.1f} s."
144
+ )
145
+ else:
146
+ cached.metadata["display_mesh"] = {
147
+ "generated": False,
148
+ "display_quality": config.brep_display_quality,
149
+ "canonical_metrics_depend_on_display_mesh": False,
150
+ }
151
+ cached.metadata["phase_timings"] = phase_timings
152
+ return BackendAssessment(result=cached, visualization_mesh=display_mesh)
153
+ assessment = compute_brep_metrics(model, config=config, progress=progress)
154
+ assessment.result.metadata.setdefault("phase_timings", {})["native_brep_load_seconds"] = (
155
+ load_elapsed
156
+ )
157
+ assessment.result.metadata["phase_timings"]["canonical_cache_lookup_seconds"] = (
158
+ cache_elapsed
159
+ )
160
+ assessment.result.metadata["cache"] = {
161
+ "enabled": bool(config.brep_cache),
162
+ "key": cache_key,
163
+ "hit": False,
164
+ }
165
+ if config.brep_cache:
166
+ save_cached_result(cache_key, assessment.result)
167
+ return BackendAssessment(
168
+ result=assessment.result,
169
+ visualization_mesh=assessment.display_mesh,
170
+ )
171
+
172
+
173
+ def backend_for_path(path: str | Path, *, override: str | None = None) -> GeometryBackend:
174
+ """Select a validated backend from an explicit override or file extension."""
175
+ suffix = Path(path).suffix.lower()
176
+ if override is not None:
177
+ normalized = override.strip().lower()
178
+ if normalized == "mesh":
179
+ return MeshBackend()
180
+ if normalized in {"brep", "cad"}:
181
+ return BRepBackend()
182
+ raise ValueError(f"Unknown geometry backend: {override}")
183
+ if suffix in MESH_SUFFIXES:
184
+ return MeshBackend()
185
+ if suffix in BREP_SUFFIXES:
186
+ return BRepBackend()
187
+ supported = ", ".join(sorted(MESH_SUFFIXES | BREP_SUFFIXES))
188
+ raise ValueError(
189
+ f"Unsupported geometry format {suffix!r}. Supported: {supported}"
190
+ )
191
+
192
+
193
+ def backend_summary(backend: GeometryBackend) -> dict[str, Any]:
194
+ """Return stable backend identity metadata for reports and services."""
195
+ return {"representation": backend.representation, "backend": backend.name}
@@ -0,0 +1,317 @@
1
+ """Visualization-only tessellation and native BRep field sampling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import trimesh
10
+
11
+ from .brep_geometry import BRepModel, evaluate_surface_differential
12
+ from .schema import CurvatureClassID
13
+ from .types import ProducibilityConfig
14
+
15
+ DISPLAY_QUALITY_PRESETS: dict[str, dict[str, float]] = {
16
+ "draft": {
17
+ "linear_deflection_span_fraction": 2.0e-3,
18
+ "angular_deflection": 0.25,
19
+ },
20
+ "standard": {
21
+ "linear_deflection_span_fraction": 7.5e-4,
22
+ "angular_deflection": 0.12,
23
+ },
24
+ "fine": {
25
+ "linear_deflection_span_fraction": 3.5e-4,
26
+ "angular_deflection": 0.075,
27
+ },
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class BRepDisplay:
33
+ """A non-canonical drawing mesh with fields sampled from the BRep."""
34
+
35
+ mesh: trimesh.Trimesh
36
+ local_fields: dict[str, np.ndarray]
37
+ metadata: dict[str, Any]
38
+
39
+
40
+ def sample_brep_reference_points(model: BRepModel) -> tuple[np.ndarray, dict[str, Any]]:
41
+ """Return controlled BRep tessellation points used only for automatic ``L_ref``.
42
+
43
+ Deflection is scaled by the square root of exact BRep area, which is
44
+ translation/rotation invariant and scales with model length. The returned
45
+ samples do not participate in the canonical native-BRep integrations.
46
+ """
47
+ from OCP.BRep import BRep_Tool
48
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
49
+ from OCP.BRepTools import BRepTools
50
+ from OCP.TopLoc import TopLoc_Location
51
+
52
+ area = float(model.metadata.get("independent_surface_area", 0.0))
53
+ if not np.isfinite(area) or area <= 0.0:
54
+ raise ValueError("Positive BRep surface area is required for automatic reference length")
55
+ area_length_scale = float(np.sqrt(area))
56
+ area_scale_fraction = 2.0e-3
57
+ linear_deflection = max(area_scale_fraction * area_length_scale, 1.0e-12)
58
+ angular_deflection = 0.25
59
+
60
+ BRepTools.Clean_s(model.shape)
61
+ mesher = BRepMesh_IncrementalMesh(
62
+ model.shape,
63
+ linear_deflection,
64
+ False,
65
+ angular_deflection,
66
+ True,
67
+ )
68
+ mesher.Perform()
69
+ if not mesher.IsDone():
70
+ raise RuntimeError("OpenCascade automatic-reference sampling failed")
71
+
72
+ vertices: list[list[float]] = []
73
+ for face in model.faces:
74
+ location = TopLoc_Location()
75
+ triangulation = BRep_Tool.Triangulation_s(face, location)
76
+ if triangulation is None:
77
+ continue
78
+ transformation = location.Transformation()
79
+ for node_index in range(1, triangulation.NbNodes() + 1):
80
+ point = triangulation.Node(node_index).Transformed(transformation)
81
+ vertices.append([point.X(), point.Y(), point.Z()])
82
+ if len(vertices) < 2:
83
+ raise RuntimeError("OpenCascade automatic-reference sampling produced too few points")
84
+ return np.asarray(vertices, dtype=float), {
85
+ "representation": "controlled_BRep_tessellation",
86
+ "purpose": "automatic_reference_length_only",
87
+ "canonical_metrics_depend_on_sampling": False,
88
+ "linear_deflection": linear_deflection,
89
+ "linear_deflection_area_length_fraction": area_scale_fraction,
90
+ "area_length_scale": area_length_scale,
91
+ "angular_deflection": angular_deflection,
92
+ }
93
+
94
+
95
+ def resolve_display_settings(
96
+ config: ProducibilityConfig,
97
+ *,
98
+ geometric_max_span: float,
99
+ ) -> dict[str, Any]:
100
+ """Resolve visualization-only preset values and explicit overrides."""
101
+ quality = str(config.brep_display_quality).strip().lower()
102
+ if quality not in DISPLAY_QUALITY_PRESETS:
103
+ choices = ", ".join(DISPLAY_QUALITY_PRESETS)
104
+ raise ValueError(f"Unknown display quality {quality!r}; expected one of: {choices}")
105
+ if not np.isfinite(geometric_max_span) or geometric_max_span <= 0.0:
106
+ raise ValueError("Geometric maximum span must be positive for display tessellation")
107
+ preset = DISPLAY_QUALITY_PRESETS[quality]
108
+ span_fraction = float(preset["linear_deflection_span_fraction"])
109
+ linear = (
110
+ float(config.brep_display_linear_deflection)
111
+ if config.brep_display_linear_deflection is not None
112
+ else max(span_fraction * geometric_max_span, 1.0e-8)
113
+ )
114
+ angular = (
115
+ float(config.brep_display_angular_deflection)
116
+ if config.brep_display_angular_deflection is not None
117
+ else float(preset["angular_deflection"])
118
+ )
119
+ if not np.isfinite(linear) or linear <= 0.0:
120
+ raise ValueError("Display linear deflection must be positive")
121
+ if not np.isfinite(angular) or angular <= 0.0:
122
+ raise ValueError("Display angular deflection must be positive")
123
+ return {
124
+ "display_quality": quality,
125
+ "linear_deflection": linear,
126
+ "angular_deflection": angular,
127
+ "linear_deflection_span_fraction": linear / geometric_max_span,
128
+ "preset_linear_deflection_span_fraction": span_fraction,
129
+ "preset_angular_deflection": float(preset["angular_deflection"]),
130
+ "linear_deflection_source": (
131
+ "explicit_override"
132
+ if config.brep_display_linear_deflection is not None
133
+ else "display_quality_preset"
134
+ ),
135
+ "angular_deflection_source": (
136
+ "explicit_override"
137
+ if config.brep_display_angular_deflection is not None
138
+ else "display_quality_preset"
139
+ ),
140
+ "relative_deflection": False,
141
+ }
142
+
143
+
144
+ def generate_brep_display(
145
+ model: BRepModel,
146
+ config: ProducibilityConfig,
147
+ *,
148
+ length_ref: float,
149
+ ) -> BRepDisplay:
150
+ """Tessellate for drawing, then sample fields from exact face derivatives."""
151
+ from OCP.BRep import BRep_Tool
152
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
153
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
154
+ from OCP.BRepTools import BRepTools
155
+ from OCP.TopAbs import TopAbs_REVERSED
156
+ from OCP.TopLoc import TopLoc_Location
157
+
158
+ bounds = np.asarray(model.metadata["bounds"], dtype=float)
159
+ span = float(np.max(np.ptp(bounds, axis=0)))
160
+ settings = resolve_display_settings(config, geometric_max_span=span)
161
+ deflection = float(settings["linear_deflection"])
162
+ # OpenCascade caches polygonal representations on the shape. Clear only
163
+ # that non-geometric cache so a requested display resolution is honored;
164
+ # this does not alter the source BRep surfaces or topology.
165
+ BRepTools.Clean_s(model.shape)
166
+ mesher = BRepMesh_IncrementalMesh(
167
+ model.shape,
168
+ deflection,
169
+ False,
170
+ float(settings["angular_deflection"]),
171
+ True,
172
+ )
173
+ mesher.Perform()
174
+ if not mesher.IsDone():
175
+ raise RuntimeError("OpenCascade visualization tessellation failed")
176
+
177
+ vertices: list[list[float]] = []
178
+ uv_values: list[tuple[float, float]] = []
179
+ face_indices: list[int] = []
180
+ triangles: list[list[int]] = []
181
+ for face_index, face in enumerate(model.faces):
182
+ location = TopLoc_Location()
183
+ triangulation = BRep_Tool.Triangulation_s(face, location)
184
+ if triangulation is None or triangulation.NbTriangles() == 0:
185
+ continue
186
+ if not triangulation.HasUVNodes():
187
+ continue
188
+ offset = len(vertices)
189
+ transformation = location.Transformation()
190
+ for node_index in range(1, triangulation.NbNodes() + 1):
191
+ point = triangulation.Node(node_index).Transformed(transformation)
192
+ uv = triangulation.UVNode(node_index)
193
+ vertices.append([point.X(), point.Y(), point.Z()])
194
+ uv_values.append((uv.X(), uv.Y()))
195
+ face_indices.append(face_index)
196
+ reversed_face = face.Orientation() == TopAbs_REVERSED
197
+ for triangle_index in range(1, triangulation.NbTriangles() + 1):
198
+ n1, n2, n3 = triangulation.Triangle(triangle_index).Get()
199
+ values = [offset + n1 - 1, offset + n2 - 1, offset + n3 - 1]
200
+ if reversed_face:
201
+ values[1], values[2] = values[2], values[1]
202
+ triangles.append(values)
203
+ if not triangles:
204
+ raise RuntimeError("Visualization tessellation produced no UV-mapped triangles")
205
+ mesh = trimesh.Trimesh(
206
+ vertices=np.asarray(vertices, dtype=float),
207
+ faces=np.asarray(triangles, dtype=np.int64),
208
+ process=False,
209
+ )
210
+
211
+ h_threshold = (
212
+ config.h_threshold
213
+ if config.h_threshold is not None
214
+ else config.h_threshold_factor / length_ref
215
+ )
216
+ k_threshold = (
217
+ config.k_threshold
218
+ if config.k_threshold is not None
219
+ else config.k_threshold_factor / length_ref**2
220
+ )
221
+ count = len(vertices)
222
+ mean = np.full(count, np.nan)
223
+ gaussian = np.full(count, np.nan)
224
+ gradient_squared = np.full(count, np.nan)
225
+ normal = np.full((count, 3), np.nan)
226
+ valid = np.zeros(count, dtype=float)
227
+ fairness_valid = np.zeros(count, dtype=float)
228
+ classes = np.full(count, int(CurvatureClassID.INVALID), dtype=np.int8)
229
+ surfaces = [BRepAdaptor_Surface(face, True) for face in model.faces]
230
+ for index, ((u, v), face_index) in enumerate(zip(uv_values, face_indices, strict=True)):
231
+ try:
232
+ value = evaluate_surface_differential(
233
+ model.faces[face_index],
234
+ u,
235
+ v,
236
+ need_third=config.brep_compute_fairness,
237
+ surface=surfaces[face_index],
238
+ )
239
+ mean[index] = value.mean_curvature
240
+ gaussian[index] = value.gaussian_curvature
241
+ normal[index] = value.normal
242
+ valid[index] = 1.0
243
+ if config.brep_compute_fairness and value.gradient_mean_squared is not None:
244
+ gradient_squared[index] = value.gradient_mean_squared
245
+ fairness_valid[index] = 1.0
246
+ if abs(gaussian[index]) <= k_threshold and abs(mean[index]) <= h_threshold:
247
+ classes[index] = int(CurvatureClassID.FLAT)
248
+ elif abs(gaussian[index]) <= k_threshold:
249
+ classes[index] = int(CurvatureClassID.SINGLE_CURVATURE)
250
+ elif gaussian[index] > k_threshold:
251
+ classes[index] = int(CurvatureClassID.ELLIPTIC_DOUBLE_CURVATURE)
252
+ else:
253
+ classes[index] = int(CurvatureClassID.SADDLE_REVERSE_DOUBLE_CURVATURE)
254
+ except Exception:
255
+ try:
256
+ value = evaluate_surface_differential(
257
+ model.faces[face_index],
258
+ u,
259
+ v,
260
+ need_third=False,
261
+ surface=surfaces[face_index],
262
+ )
263
+ mean[index] = value.mean_curvature
264
+ gaussian[index] = value.gaussian_curvature
265
+ normal[index] = value.normal
266
+ valid[index] = 1.0
267
+ except Exception:
268
+ pass
269
+ sample_valid = valid.astype(bool)
270
+ classes[sample_valid & (np.abs(gaussian) <= k_threshold) & (np.abs(mean) <= h_threshold)] = int(
271
+ CurvatureClassID.FLAT
272
+ )
273
+ classes[sample_valid & (np.abs(gaussian) <= k_threshold) & (np.abs(mean) > h_threshold)] = int(
274
+ CurvatureClassID.SINGLE_CURVATURE
275
+ )
276
+ classes[sample_valid & (gaussian > k_threshold)] = int(
277
+ CurvatureClassID.ELLIPTIC_DOUBLE_CURVATURE
278
+ )
279
+ classes[sample_valid & (gaussian < -k_threshold)] = int(
280
+ CurvatureClassID.SADDLE_REVERSE_DOUBLE_CURVATURE
281
+ )
282
+ principal_offset = np.sqrt(np.maximum(mean**2 - gaussian, 0.0))
283
+ threshold_mask = np.full(count, -1, dtype=np.int8)
284
+ threshold_mask[sample_valid] = (np.abs(gaussian[sample_valid]) > k_threshold).astype(np.int8)
285
+ local_fields = {
286
+ "H": mean,
287
+ "K": gaussian,
288
+ "principal_curvature_k1": mean + principal_offset,
289
+ "principal_curvature_k2": mean - principal_offset,
290
+ "H_valid": valid.astype(np.uint8),
291
+ "K_valid": valid.astype(np.uint8),
292
+ "surface_normal_x": normal[:, 0],
293
+ "surface_normal_y": normal[:, 1],
294
+ "surface_normal_z": normal[:, 2],
295
+ "developability_density": np.abs(gaussian) * length_ref**2,
296
+ "developability_positive_density": np.maximum(gaussian, 0.0) * length_ref**2,
297
+ "developability_negative_density": np.maximum(-gaussian, 0.0) * length_ref**2,
298
+ "developability_threshold_mask": threshold_mask,
299
+ "curvature_class_id": classes,
300
+ "brep_face_index": np.asarray(face_indices, dtype=np.int32),
301
+ }
302
+ if config.experimental_metrics or config.n_stations > 0 or config.brep_compute_fairness:
303
+ local_fields["curvature_energy_density"] = (mean * length_ref) ** 2
304
+ if config.brep_compute_fairness:
305
+ local_fields["curvature_fairness_density"] = gradient_squared * length_ref**4
306
+ local_fields["fairness_valid"] = fairness_valid.astype(np.uint8)
307
+ metadata = {
308
+ **settings,
309
+ "generated": True,
310
+ "purpose": "visualization_export_and_mesh_vs_brep_verification_only",
311
+ "canonical_metrics_depend_on_display_mesh": False,
312
+ "vertex_count": len(mesh.vertices),
313
+ "face_count": len(mesh.faces),
314
+ "triangle_count": len(mesh.faces),
315
+ "field_source": "direct_BRep_face_UV_differential_evaluation",
316
+ }
317
+ return BRepDisplay(mesh=mesh, local_fields=local_fields, metadata=metadata)