exterior-shell 1.2.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.
- exterior_shell/__init__.py +3 -0
- exterior_shell/ai/__init__.py +1 -0
- exterior_shell/cli.py +387 -0
- exterior_shell/core/__init__.py +1 -0
- exterior_shell/core/assembler.py +137 -0
- exterior_shell/core/classifier.py +210 -0
- exterior_shell/core/models.py +190 -0
- exterior_shell/core/parser.py +211 -0
- exterior_shell/export/__init__.py +1 -0
- exterior_shell/export/footprint.py +190 -0
- exterior_shell/export/stripped_ifc.py +218 -0
- exterior_shell/utils/__init__.py +1 -0
- exterior_shell-1.2.0.dist-info/METADATA +207 -0
- exterior_shell-1.2.0.dist-info/RECORD +17 -0
- exterior_shell-1.2.0.dist-info/WHEEL +4 -0
- exterior_shell-1.2.0.dist-info/entry_points.txt +2 -0
- exterior_shell-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI modules for enhanced classification."""
|
exterior_shell/cli.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""CLI entry point for exterior-shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .core.parser import parse_ifc
|
|
15
|
+
from .core.classifier import classify_all, resolve_ambiguities
|
|
16
|
+
from .core.assembler import assemble_shell, get_shell_stats
|
|
17
|
+
from .export.stripped_ifc import export_stripped_ifc
|
|
18
|
+
from .export.footprint import export_footprint_geojson, write_extraction_report
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _setup_logging(verbose: bool) -> None:
|
|
22
|
+
"""Configure logging based on verbosity."""
|
|
23
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=level,
|
|
26
|
+
format="%(levelname)-8s %(name)s: %(message)s",
|
|
27
|
+
stream=sys.stderr,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group()
|
|
32
|
+
@click.version_option(__version__, prog_name="exterior-shell")
|
|
33
|
+
def main():
|
|
34
|
+
"""Extract lightweight exterior shells from BIM models (IFC).
|
|
35
|
+
|
|
36
|
+
Produces a stripped IFC (interior elements removed) and optionally a
|
|
37
|
+
2D building footprint with elevation attributes for GIS use.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@main.command()
|
|
43
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
44
|
+
@click.option(
|
|
45
|
+
"-o", "--output", "output_dir",
|
|
46
|
+
type=click.Path(file_okay=False),
|
|
47
|
+
default=None,
|
|
48
|
+
help="Output directory. Defaults to same directory as input file.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--ai",
|
|
52
|
+
is_flag=True,
|
|
53
|
+
default=False,
|
|
54
|
+
help="Enable AI-assisted classification for ambiguous elements",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--no-filter",
|
|
58
|
+
is_flag=True,
|
|
59
|
+
default=False,
|
|
60
|
+
help="Include all elements (skip rule-based filtering)",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--report/--no-report",
|
|
64
|
+
default=True,
|
|
65
|
+
help="Generate extraction report",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"-v", "--verbose",
|
|
69
|
+
is_flag=True,
|
|
70
|
+
default=False,
|
|
71
|
+
help="Enable debug logging",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--crs",
|
|
75
|
+
default="EPSG:4326",
|
|
76
|
+
help="Output coordinate reference system for footprint (default: EPSG:4326)",
|
|
77
|
+
show_default=True,
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--keep-interior",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
default=False,
|
|
83
|
+
help="Keep interior-facing faces in the shell",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--no-stripped-ifc",
|
|
87
|
+
is_flag=True,
|
|
88
|
+
default=False,
|
|
89
|
+
help="Skip stripped IFC export (only useful with --footprint)",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--footprint",
|
|
93
|
+
is_flag=True,
|
|
94
|
+
default=False,
|
|
95
|
+
help="Also export a 2D building footprint GeoJSON with elevation attributes",
|
|
96
|
+
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--json-stats",
|
|
99
|
+
is_flag=True,
|
|
100
|
+
default=False,
|
|
101
|
+
help="Output stats as JSON to stdout",
|
|
102
|
+
)
|
|
103
|
+
def extract(
|
|
104
|
+
input_file: str,
|
|
105
|
+
output_dir: str | None,
|
|
106
|
+
ai: bool,
|
|
107
|
+
no_filter: bool,
|
|
108
|
+
report: bool,
|
|
109
|
+
verbose: bool,
|
|
110
|
+
json_stats: bool,
|
|
111
|
+
crs: str,
|
|
112
|
+
keep_interior: bool,
|
|
113
|
+
no_stripped_ifc: bool,
|
|
114
|
+
footprint: bool,
|
|
115
|
+
):
|
|
116
|
+
"""Extract exterior shell from an IFC file.
|
|
117
|
+
|
|
118
|
+
\b
|
|
119
|
+
Examples:
|
|
120
|
+
exterior-shell extract building.ifc
|
|
121
|
+
exterior-shell extract building.ifc -o /output/
|
|
122
|
+
exterior-shell extract building.ifc --footprint
|
|
123
|
+
exterior-shell extract building.ifc --footprint --no-stripped-ifc
|
|
124
|
+
exterior-shell extract building.ifc --footprint --crs EPSG:3857
|
|
125
|
+
"""
|
|
126
|
+
_setup_logging(verbose)
|
|
127
|
+
logger = logging.getLogger("exterior_shell.cli")
|
|
128
|
+
|
|
129
|
+
input_path = Path(input_file)
|
|
130
|
+
stem = input_path.stem
|
|
131
|
+
|
|
132
|
+
# Determine output directory
|
|
133
|
+
if output_dir is not None:
|
|
134
|
+
out_dir = Path(output_dir)
|
|
135
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
else:
|
|
137
|
+
out_dir = input_path.parent
|
|
138
|
+
|
|
139
|
+
# Track timing
|
|
140
|
+
start_time = time.time()
|
|
141
|
+
|
|
142
|
+
# ── Step 1: Parse ──────────────────────────────────────────────────
|
|
143
|
+
click.echo(f"Parsing {input_path}...", err=True)
|
|
144
|
+
elements = parse_ifc(input_path)
|
|
145
|
+
click.echo(f" Found {len(elements)} elements", err=True)
|
|
146
|
+
|
|
147
|
+
# ── Step 2: Filter / Classify ──────────────────────────────────────
|
|
148
|
+
report_data = None
|
|
149
|
+
if no_filter:
|
|
150
|
+
click.echo("Skipping classification (--no-filter)", err=True)
|
|
151
|
+
from .core.models import Classification
|
|
152
|
+
for e in elements:
|
|
153
|
+
e.classification = Classification.EXTERIOR
|
|
154
|
+
else:
|
|
155
|
+
click.echo("Classifying elements...", err=True)
|
|
156
|
+
report_data = classify_all(elements)
|
|
157
|
+
|
|
158
|
+
if report_data.ambiguous_count > 0:
|
|
159
|
+
click.echo(
|
|
160
|
+
f" {report_data.ambiguous_count} ambiguous elements "
|
|
161
|
+
f"(defaulting to exterior)",
|
|
162
|
+
err=True,
|
|
163
|
+
)
|
|
164
|
+
report_data = resolve_ambiguities(report_data, use_ai=ai)
|
|
165
|
+
|
|
166
|
+
click.echo(
|
|
167
|
+
f" {report_data.exterior_count} exterior, "
|
|
168
|
+
f"{report_data.interior_count} interior",
|
|
169
|
+
err=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# ── Step 3: Assemble ──────────────────────────────────────────────
|
|
173
|
+
exterior_elements = (
|
|
174
|
+
report_data.exterior_elements if report_data else elements
|
|
175
|
+
)
|
|
176
|
+
if keep_interior:
|
|
177
|
+
click.echo("Assembling shell geometry (keeping interior faces)...", err=True)
|
|
178
|
+
else:
|
|
179
|
+
click.echo("Assembling shell geometry...", err=True)
|
|
180
|
+
shell = assemble_shell(
|
|
181
|
+
exterior_elements,
|
|
182
|
+
remove_interior_faces=not keep_interior,
|
|
183
|
+
)
|
|
184
|
+
stats = get_shell_stats(shell)
|
|
185
|
+
click.echo(
|
|
186
|
+
f" {stats['face_count']} faces, "
|
|
187
|
+
f"area: {stats['total_area']:.1f} sq units",
|
|
188
|
+
err=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# ── Step 4: Stripped IFC Export ───────────────────────────────────
|
|
192
|
+
stripped_result = None
|
|
193
|
+
if not no_stripped_ifc:
|
|
194
|
+
stripped_output = out_dir / f"{stem}_stripped.ifc"
|
|
195
|
+
click.echo(f"Exporting stripped IFC to {stripped_output}...", err=True)
|
|
196
|
+
try:
|
|
197
|
+
all_elements = elements
|
|
198
|
+
if report_data:
|
|
199
|
+
all_elements = (
|
|
200
|
+
report_data.exterior_elements
|
|
201
|
+
+ report_data.interior_elements
|
|
202
|
+
+ report_data.ambiguous_elements
|
|
203
|
+
)
|
|
204
|
+
stripped_result = export_stripped_ifc(
|
|
205
|
+
input_path=input_path,
|
|
206
|
+
output_path=stripped_output,
|
|
207
|
+
elements=all_elements,
|
|
208
|
+
)
|
|
209
|
+
click.echo(
|
|
210
|
+
f" Removed {stripped_result['removed_count']} interior elements, "
|
|
211
|
+
f"{stripped_result['kept_count']} kept",
|
|
212
|
+
err=True,
|
|
213
|
+
)
|
|
214
|
+
click.echo(
|
|
215
|
+
f" Size: {stripped_result['output_size'] / 1024:.1f} KB "
|
|
216
|
+
f"({stripped_result['size_reduction_pct']:.1f}% reduction)",
|
|
217
|
+
err=True,
|
|
218
|
+
)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
click.echo(f" Stripped IFC export failed: {exc}", err=True)
|
|
221
|
+
else:
|
|
222
|
+
click.echo("Skipping stripped IFC (--no-stripped-ifc)", err=True)
|
|
223
|
+
|
|
224
|
+
# ── Step 5: Footprint Export ──────────────────────────────────────
|
|
225
|
+
footprint_data = None
|
|
226
|
+
footprint_path = None
|
|
227
|
+
if footprint:
|
|
228
|
+
footprint_path = out_dir / f"{stem}_footprint.geojson"
|
|
229
|
+
click.echo(f"Exporting 2D footprint to {footprint_path}...", err=True)
|
|
230
|
+
try:
|
|
231
|
+
footprint_data = export_footprint_geojson(
|
|
232
|
+
shell=shell,
|
|
233
|
+
output_path=footprint_path,
|
|
234
|
+
crs=crs,
|
|
235
|
+
)
|
|
236
|
+
if footprint_data:
|
|
237
|
+
click.echo(
|
|
238
|
+
f" base_elevation: {footprint_data['base_elevation']}, "
|
|
239
|
+
f"height: {footprint_data['height']}, "
|
|
240
|
+
f"area: {footprint_data['area']:.1f} sq units",
|
|
241
|
+
err=True,
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
click.echo(" No valid footprint geometry found", err=True)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
click.echo(f" Footprint export failed: {exc}", err=True)
|
|
247
|
+
|
|
248
|
+
# ── Step 6: Report ───────────────────────────────────────────────
|
|
249
|
+
elapsed = time.time() - start_time
|
|
250
|
+
input_size = input_path.stat().st_size
|
|
251
|
+
|
|
252
|
+
# Build result for summary and report
|
|
253
|
+
from .core.models import ExtractionResult
|
|
254
|
+
placeholder_report = report_data or type('obj', (object,), {
|
|
255
|
+
'total_elements': len(elements),
|
|
256
|
+
'exterior_count': len(elements),
|
|
257
|
+
'interior_count': 0,
|
|
258
|
+
'ambiguous_count': 0,
|
|
259
|
+
'ambiguous_elements': [],
|
|
260
|
+
'exterior_elements': [],
|
|
261
|
+
'interior_elements': [],
|
|
262
|
+
'ambiguity_score': 0.0,
|
|
263
|
+
'summary': lambda self: "No classification",
|
|
264
|
+
})()
|
|
265
|
+
|
|
266
|
+
result = ExtractionResult(
|
|
267
|
+
classification_report=placeholder_report,
|
|
268
|
+
shell=shell,
|
|
269
|
+
input_file=str(input_path),
|
|
270
|
+
output_file=str(stripped_output if stripped_result else (footprint_path or "")),
|
|
271
|
+
input_element_count=len(elements),
|
|
272
|
+
output_face_count=stats['face_count'],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if report and report_data:
|
|
276
|
+
report_path = out_dir / f"{stem}.report.md"
|
|
277
|
+
write_extraction_report(result, report_path)
|
|
278
|
+
click.echo(f"Report written to {report_path}", err=True)
|
|
279
|
+
|
|
280
|
+
# ── Summary ──────────────────────────────────────────────────────
|
|
281
|
+
# File sizes
|
|
282
|
+
stripped_size = (
|
|
283
|
+
stripped_output.stat().st_size
|
|
284
|
+
if stripped_result and stripped_output.exists()
|
|
285
|
+
else 0
|
|
286
|
+
)
|
|
287
|
+
footprint_size = (
|
|
288
|
+
footprint_path.stat().st_size
|
|
289
|
+
if footprint_path and footprint_path.exists()
|
|
290
|
+
else 0
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
click.echo("", err=True)
|
|
294
|
+
click.echo(f"Done in {elapsed:.1f}s", err=True)
|
|
295
|
+
click.echo(f"Input: {input_size / 1024:.1f} KB ({input_file})", err=True)
|
|
296
|
+
if stripped_result:
|
|
297
|
+
click.echo(
|
|
298
|
+
f"Stripped IFC: {stripped_size / 1024:.1f} KB "
|
|
299
|
+
f"({stripped_output.name})",
|
|
300
|
+
err=True,
|
|
301
|
+
)
|
|
302
|
+
click.echo(
|
|
303
|
+
f" Reduction: {stripped_result['size_reduction_pct']:.1f}%",
|
|
304
|
+
err=True,
|
|
305
|
+
)
|
|
306
|
+
if footprint_data and footprint_path:
|
|
307
|
+
click.echo(
|
|
308
|
+
f"Footprint: {footprint_size / 1024:.1f} KB "
|
|
309
|
+
f"({footprint_path.name})",
|
|
310
|
+
err=True,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
click.echo("", err=True)
|
|
314
|
+
click.echo(result.summary())
|
|
315
|
+
|
|
316
|
+
# JSON stats if requested
|
|
317
|
+
if json_stats:
|
|
318
|
+
stats_output = {
|
|
319
|
+
"input_file": str(input_path),
|
|
320
|
+
"input_size_kb": input_size / 1024,
|
|
321
|
+
"elements": {
|
|
322
|
+
"total": len(elements),
|
|
323
|
+
"exterior": (
|
|
324
|
+
report_data.exterior_count if report_data else len(elements)
|
|
325
|
+
),
|
|
326
|
+
"interior": report_data.interior_count if report_data else 0,
|
|
327
|
+
"ambiguous": (
|
|
328
|
+
report_data.ambiguous_count if report_data else 0
|
|
329
|
+
),
|
|
330
|
+
},
|
|
331
|
+
"shell": stats,
|
|
332
|
+
"stripped_ifc": stripped_result,
|
|
333
|
+
"footprint": {k: v for k, v in footprint_data.items() if k != "polygon"} if footprint_data else None,
|
|
334
|
+
"elapsed_seconds": round(elapsed, 2),
|
|
335
|
+
}
|
|
336
|
+
click.echo(json.dumps(stats_output, indent=2))
|
|
337
|
+
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@main.command()
|
|
342
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
343
|
+
def info(input_file: str):
|
|
344
|
+
"""Show information about an IFC file.
|
|
345
|
+
|
|
346
|
+
Displays element counts, types, and geometry statistics.
|
|
347
|
+
"""
|
|
348
|
+
_setup_logging(False)
|
|
349
|
+
|
|
350
|
+
input_path = Path(input_file)
|
|
351
|
+
click.echo(f"IFC File: {input_path}")
|
|
352
|
+
click.echo(f"Size: {input_path.stat().st_size / 1024:.1f} KB")
|
|
353
|
+
click.echo()
|
|
354
|
+
|
|
355
|
+
elements = parse_ifc(input_path)
|
|
356
|
+
|
|
357
|
+
# Count by type
|
|
358
|
+
type_counts: dict[str, int] = {}
|
|
359
|
+
for e in elements:
|
|
360
|
+
type_counts[e.ifc_type] = type_counts.get(e.ifc_type, 0) + 1
|
|
361
|
+
|
|
362
|
+
click.echo(f"Total elements: {len(elements)}")
|
|
363
|
+
click.echo()
|
|
364
|
+
click.echo("Element Types:")
|
|
365
|
+
for ifc_type, count in sorted(type_counts.items(), key=lambda x: -x[1]):
|
|
366
|
+
click.echo(f" {ifc_type:<35} {count:>5}")
|
|
367
|
+
|
|
368
|
+
# Face counts
|
|
369
|
+
total_faces = sum(e.face_count for e in elements)
|
|
370
|
+
click.echo()
|
|
371
|
+
click.echo(f"Total faces: {total_faces}")
|
|
372
|
+
|
|
373
|
+
# Storeys
|
|
374
|
+
storeys = set()
|
|
375
|
+
for e in elements:
|
|
376
|
+
if e.storey:
|
|
377
|
+
storeys.add(e.storey)
|
|
378
|
+
if storeys:
|
|
379
|
+
click.echo()
|
|
380
|
+
click.echo("Storeys:")
|
|
381
|
+
for s in sorted(storeys):
|
|
382
|
+
count = sum(1 for e in elements if e.storey == s)
|
|
383
|
+
click.echo(f" {s:<35} {count:>5}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules for exterior shell extraction."""
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Geometry assembler — combines exterior elements into a multipatch shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .models import Classification, Element, Face, ShellGeometry
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _face_area(face: Face) -> float:
|
|
15
|
+
"""Calculate area of a triangular face."""
|
|
16
|
+
edge1 = face.vertices[1] - face.vertices[0]
|
|
17
|
+
edge2 = face.vertices[2] - face.vertices[0]
|
|
18
|
+
return 0.5 * float(np.linalg.norm(np.cross(edge1, edge2)))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _are_faces_coplanar(f1: Face, f2: Face, angle_threshold: float = 0.01) -> bool:
|
|
22
|
+
"""Check if two faces are roughly coplanar and adjacent."""
|
|
23
|
+
# Check if normals are approximately parallel
|
|
24
|
+
dot = abs(float(np.dot(f1.normal, f2.normal)))
|
|
25
|
+
if dot < (1.0 - angle_threshold):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
# Check if they share an edge (vertices within tolerance)
|
|
29
|
+
tol = 1e-6
|
|
30
|
+
for v1 in f1.vertices:
|
|
31
|
+
for v2 in f2.vertices:
|
|
32
|
+
if np.linalg.norm(v1 - v2) < tol:
|
|
33
|
+
return True
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _face_is_backward(f: Face, shell_centroid: np.ndarray) -> bool:
|
|
38
|
+
"""Check if a face is pointing inward (toward the shell centroid).
|
|
39
|
+
|
|
40
|
+
A face pointing toward the centroid is likely an interior face that
|
|
41
|
+
should be removed.
|
|
42
|
+
"""
|
|
43
|
+
# Face center
|
|
44
|
+
face_center = f.vertices.mean(axis=0)
|
|
45
|
+
# Vector from face center toward centroid
|
|
46
|
+
to_centroid = shell_centroid - face_center
|
|
47
|
+
# If the face normal points toward centroid, it's inward-facing
|
|
48
|
+
return float(np.dot(f.normal, to_centroid)) > 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def assemble_shell(
|
|
52
|
+
exterior_elements: list[Element],
|
|
53
|
+
remove_interior_faces: bool = True,
|
|
54
|
+
) -> ShellGeometry:
|
|
55
|
+
"""Assemble exterior elements into a single shell geometry.
|
|
56
|
+
|
|
57
|
+
Takes all classified-exterior elements and combines their faces into
|
|
58
|
+
a unified shell. Optionally removes interior-facing faces.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
exterior_elements: Elements classified as exterior.
|
|
62
|
+
remove_interior_faces: If True, remove faces that point inward.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Assembled ShellGeometry.
|
|
66
|
+
"""
|
|
67
|
+
shell = ShellGeometry(
|
|
68
|
+
element_count=len(exterior_elements),
|
|
69
|
+
source_elements=exterior_elements,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Collect all exterior faces
|
|
73
|
+
all_faces: list[Face] = []
|
|
74
|
+
for element in exterior_elements:
|
|
75
|
+
all_faces.extend(element.faces)
|
|
76
|
+
|
|
77
|
+
if not all_faces:
|
|
78
|
+
logger.warning("No faces to assemble — empty shell")
|
|
79
|
+
return shell
|
|
80
|
+
|
|
81
|
+
logger.info(f"Assembling {len(all_faces)} faces from {len(exterior_elements)} elements")
|
|
82
|
+
|
|
83
|
+
# Compute rough centroid for interior face detection
|
|
84
|
+
all_verts = np.vstack([f.vertices for f in all_faces])
|
|
85
|
+
centroid = all_verts.mean(axis=0)
|
|
86
|
+
|
|
87
|
+
# Optionally remove interior-facing faces
|
|
88
|
+
if remove_interior_faces:
|
|
89
|
+
original_count = len(all_faces)
|
|
90
|
+
all_faces = [f for f in all_faces if not _face_is_backward(f, centroid)]
|
|
91
|
+
removed = original_count - len(all_faces)
|
|
92
|
+
if removed > 0:
|
|
93
|
+
logger.info(f"Removed {removed} interior-facing faces")
|
|
94
|
+
|
|
95
|
+
shell.faces = all_faces
|
|
96
|
+
shell.total_face_count = len(all_faces)
|
|
97
|
+
|
|
98
|
+
logger.info(
|
|
99
|
+
f"Shell assembled: {shell.element_count} elements, "
|
|
100
|
+
f"{shell.total_face_count} faces"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return shell
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_shell_stats(shell: ShellGeometry) -> dict:
|
|
107
|
+
"""Get statistics about the assembled shell.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dictionary with shell statistics.
|
|
111
|
+
"""
|
|
112
|
+
if not shell.faces:
|
|
113
|
+
return {
|
|
114
|
+
"face_count": 0,
|
|
115
|
+
"element_count": 0,
|
|
116
|
+
"bbox": None,
|
|
117
|
+
"total_area": 0.0,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
total_area = sum(_face_area(f) for f in shell.faces)
|
|
121
|
+
bbox_min = shell.bbox_min
|
|
122
|
+
bbox_max = shell.bbox_max
|
|
123
|
+
|
|
124
|
+
bbox = None
|
|
125
|
+
if bbox_min is not None and bbox_max is not None:
|
|
126
|
+
bbox = {
|
|
127
|
+
"min": bbox_min.tolist(),
|
|
128
|
+
"max": bbox_max.tolist(),
|
|
129
|
+
"size": (bbox_max - bbox_min).tolist(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"face_count": shell.total_face_count,
|
|
134
|
+
"element_count": shell.element_count,
|
|
135
|
+
"bbox": bbox,
|
|
136
|
+
"total_area": total_area,
|
|
137
|
+
}
|