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.
@@ -0,0 +1,3 @@
1
+ """Exterior Shell Extractor — extract lightweight exterior shells from BIM models."""
2
+
3
+ __version__ = "1.2.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
+ }