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 +17 -0
- hullprod/_version.py +3 -0
- hullprod/api.py +142 -0
- hullprod/backends.py +195 -0
- hullprod/brep_display.py +317 -0
- hullprod/brep_geometry.py +485 -0
- hullprod/brep_metrics.py +295 -0
- hullprod/brep_quadrature.py +734 -0
- hullprod/brep_sections.py +446 -0
- hullprod/brep_validity.py +417 -0
- hullprod/cache.py +103 -0
- hullprod/cli.py +289 -0
- hullprod/curvature.py +24 -0
- hullprod/examples/__init__.py +1 -0
- hullprod/examples/simple_sphere.iges +51 -0
- hullprod/examples/simple_sphere.stl +563 -0
- hullprod/experimental_curvature.py +162 -0
- hullprod/experimental_fairness.py +165 -0
- hullprod/experimental_mass_curvature.py +157 -0
- hullprod/experimental_standard_curvature.py +737 -0
- hullprod/export.py +336 -0
- hullprod/fairness.py +5 -0
- hullprod/io.py +54 -0
- hullprod/manifest.py +308 -0
- hullprod/mesh_ops.py +161 -0
- hullprod/mesh_quality.py +341 -0
- hullprod/metrics.py +789 -0
- hullprod/plotting.py +625 -0
- hullprod/reference_length.py +144 -0
- hullprod/report.py +432 -0
- hullprod/schema.py +81 -0
- hullprod/sections.py +471 -0
- hullprod/types.py +117 -0
- hullprod/units.py +79 -0
- hullprod/validity.py +127 -0
- hullprod-1.0.0.dist-info/METADATA +230 -0
- hullprod-1.0.0.dist-info/RECORD +41 -0
- hullprod-1.0.0.dist-info/WHEEL +5 -0
- hullprod-1.0.0.dist-info/entry_points.txt +2 -0
- hullprod-1.0.0.dist-info/licenses/LICENSE +30 -0
- hullprod-1.0.0.dist-info/top_level.txt +1 -0
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
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}
|
hullprod/brep_display.py
ADDED
|
@@ -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)
|