morphosx 0.4.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.
File without changes
File without changes
@@ -0,0 +1,346 @@
1
+ from pathlib import Path
2
+ import uuid
3
+ from mimetypes import guess_extension
4
+ from typing import Optional
5
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
6
+
7
+ from morphosx.app.core.security import verify_signature, generate_signature
8
+ from morphosx.app.core.auth import get_current_user
9
+ from morphosx.app.engine.processor import ImageProcessor, ProcessingOptions, ImageFormat
10
+ from morphosx.app.engine.video import VideoProcessor
11
+ from morphosx.app.engine.audio import AudioProcessor
12
+ from morphosx.app.engine.document import DocumentProcessor
13
+ from morphosx.app.engine.raw import RawProcessor
14
+ from morphosx.app.engine.vips import VipsProcessor
15
+ from morphosx.app.engine.text import TextProcessor
16
+ from morphosx.app.engine.office import OfficeProcessor
17
+ from morphosx.app.engine.font import FontProcessor
18
+ from morphosx.app.engine.model3d import Model3DProcessor
19
+ from morphosx.app.engine.archive import ArchiveProcessor
20
+ from morphosx.app.engine.bim import BIMProcessor
21
+ from morphosx.app.storage.local import LocalStorage
22
+ from morphosx.app.storage.s3 import S3Storage
23
+ from morphosx.app.settings import settings
24
+
25
+
26
+ router = APIRouter(prefix="/assets", tags=["Assets"])
27
+
28
+ # Singleton instances factory
29
+ def get_storage():
30
+ if settings.storage_type == "s3":
31
+ if not settings.s3_bucket:
32
+ raise ValueError("S3_BUCKET is required for s3 storage_type")
33
+ return S3Storage(
34
+ bucket_name=settings.s3_bucket,
35
+ region_name=settings.s3_region,
36
+ endpoint_url=settings.s3_endpoint,
37
+ access_key_id=settings.s3_access_key,
38
+ secret_access_key=settings.s3_secret_key
39
+ )
40
+ return LocalStorage(base_directory=settings.storage_root)
41
+
42
+ def get_processor():
43
+ if settings.engine_type == "vips":
44
+ return VipsProcessor()
45
+ return ImageProcessor()
46
+
47
+ storage = get_storage()
48
+ processor = get_processor()
49
+ video_processor = VideoProcessor()
50
+ audio_processor = AudioProcessor()
51
+ document_processor = DocumentProcessor()
52
+ raw_processor = RawProcessor()
53
+ text_processor = TextProcessor()
54
+ office_processor = OfficeProcessor()
55
+ font_processor = FontProcessor()
56
+ model3d_processor = Model3DProcessor()
57
+ archive_processor = ArchiveProcessor()
58
+ bim_processor = BIMProcessor()
59
+
60
+ VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi"}
61
+ AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac"}
62
+ DOCUMENT_EXTENSIONS = {".pdf"}
63
+ RAW_EXTENSIONS = {".cr2", ".nef", ".dng", ".arw"}
64
+ TEXT_EXTENSIONS = {".json", ".xml", ".md"}
65
+ OFFICE_EXTENSIONS = {".docx", ".pptx", ".xlsx"}
66
+ FONT_EXTENSIONS = {".ttf", ".otf"}
67
+ MODEL3D_EXTENSIONS = {".stl", ".obj", ".glb", ".gltf"}
68
+ ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz"}
69
+ BIM_EXTENSIONS = {".ifc"}
70
+
71
+
72
+
73
+ @router.post("/upload")
74
+ async def upload_asset(
75
+ file: UploadFile = File(...),
76
+ private: bool = Query(False),
77
+ folder: Optional[str] = Query(None),
78
+ current_user: Optional[str] = Depends(get_current_user)
79
+ ):
80
+ """
81
+ Upload a new asset.
82
+ - If 'private=True' and user is logged in: saved to 'users/{user_id}/{folder}/'
83
+ - Otherwise: saved to 'originals/{folder}/'
84
+ """
85
+ asset_uuid = str(uuid.uuid4())
86
+ ext = guess_extension(file.content_type) or ".bin"
87
+
88
+ # Determine base prefix
89
+ if private:
90
+ if not current_user:
91
+ raise HTTPException(status_code=401, detail="Authentication required for private uploads")
92
+ base_prefix = f"users/{current_user}"
93
+ else:
94
+ base_prefix = "originals"
95
+
96
+ # Sanitize and add custom folder if provided
97
+ path_parts = [base_prefix]
98
+ if folder:
99
+ sanitized_folder = folder.strip("/")
100
+ if sanitized_folder:
101
+ path_parts.append(sanitized_folder)
102
+
103
+ asset_id = f"{'/'.join(path_parts)}/{asset_uuid}{ext}"
104
+
105
+ try:
106
+ content = await file.read()
107
+ saved_id = await storage.save_asset(asset_id, content)
108
+
109
+ # Clean ID for the response (for private assets we keep the user prefix)
110
+ clean_id = saved_id if private else Path(saved_id).name
111
+
112
+ # Determine if it's a video to suggest thumbnail params
113
+ is_video = ext.lower() in VIDEO_EXTENSIONS
114
+
115
+ # Generate a sample signed URL
116
+ sample_fmt = ImageFormat.WEBP
117
+ sample_q = settings.default_quality
118
+ sig = generate_signature(
119
+ asset_id=clean_id,
120
+ width=None,
121
+ height=None,
122
+ format=sample_fmt.value.lower(),
123
+ quality=sample_q,
124
+ secret_key=settings.secret_key,
125
+ user_id=current_user if private else None
126
+ )
127
+
128
+ url = f"{settings.api_prefix}/assets/{clean_id}?s={sig}"
129
+ if is_video:
130
+ url += "&t=1"
131
+
132
+ return {
133
+ "asset_id": clean_id,
134
+ "url": url,
135
+ "is_private": private,
136
+ "owner": current_user if private else "public",
137
+ "mime_type": file.content_type,
138
+ "size": len(content)
139
+ }
140
+ except Exception as e:
141
+ raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
142
+
143
+
144
+ @router.get("/{asset_id:path}")
145
+ async def get_processed_asset(
146
+ asset_id: str,
147
+ w: Optional[int] = Query(None, alias="width", ge=1, le=settings.max_image_dimension),
148
+ h: Optional[int] = Query(None, alias="height", ge=1, le=settings.max_image_dimension),
149
+ fmt: Optional[ImageFormat] = Query(None, alias="format"),
150
+ q: Optional[int] = Query(None, alias="quality", ge=1, le=100),
151
+ preset: Optional[str] = Query(None, alias="preset"),
152
+ t: float = Query(0.0, alias="time", ge=0.0),
153
+ p: int = Query(1, alias="page", ge=1),
154
+ s: str = Query(..., alias="signature", description="HMAC-SHA256 signature"),
155
+ current_user: Optional[str] = Depends(get_current_user),
156
+ ):
157
+ """
158
+ Retrieve and process an asset.
159
+ Supports Smart Presets and User-bound protected assets.
160
+ """
161
+ # 0. Apply Preset Logic
162
+ target_w = w
163
+ target_h = h
164
+ target_fmt = fmt
165
+ target_q = q if q else settings.default_quality
166
+
167
+ if preset:
168
+ if preset not in settings.presets:
169
+ raise HTTPException(status_code=400, detail=f"Invalid preset: {preset}")
170
+
171
+ config = settings.presets[preset]
172
+ # Preset values act as defaults, explicit query params override them
173
+ target_w = w if w else config.get("width")
174
+ target_h = h if h else config.get("height")
175
+ target_fmt = fmt if fmt else ImageFormat(config.get("format").upper())
176
+ target_q = q if q else config.get("quality", settings.default_quality)
177
+
178
+ # 1. Signature Verification (SECURITY FIRST)
179
+ # We enforce that if an asset is in the "users/" directory, it MUST have a matching current_user
180
+ is_private = asset_id.startswith("users/")
181
+ if is_private:
182
+ # Extract owner ID from path: "users/{user_id}/file.jpg"
183
+ parts = asset_id.split("/")
184
+ if len(parts) < 3:
185
+ raise HTTPException(status_code=400, detail="Invalid private asset path")
186
+
187
+ owner_id = parts[1]
188
+ if current_user != owner_id:
189
+ raise HTTPException(status_code=403, detail="Not authorized to access this private asset")
190
+
191
+ is_valid = verify_signature(
192
+ asset_id=asset_id,
193
+ width=w,
194
+ height=h,
195
+ format=fmt.value.lower() if fmt else "",
196
+ quality=q if q else 0,
197
+ signature_to_verify=s,
198
+ secret_key=settings.secret_key,
199
+ preset=preset,
200
+ user_id=current_user
201
+ )
202
+
203
+ if not is_valid:
204
+ raise HTTPException(status_code=403, detail="Invalid signature")
205
+
206
+ options = ProcessingOptions(
207
+ width=target_w,
208
+ height=target_h,
209
+ format=fmt_val,
210
+ quality=target_q
211
+ )
212
+
213
+ # 2. Define Cache Paths (Include timestamp or page for media derivatives)
214
+ cache_key = options.get_cache_key()
215
+ is_video = Path(asset_id).suffix.lower() in VIDEO_EXTENSIONS
216
+ is_document = Path(asset_id).suffix.lower() in DOCUMENT_EXTENSIONS
217
+
218
+ if is_video:
219
+ cache_key = f"t{t}_{cache_key}"
220
+ elif is_document:
221
+ cache_key = f"p{p}_{cache_key}"
222
+
223
+ derivative_id = f"cache/{asset_id}/{cache_key}"
224
+
225
+ try:
226
+ # 3. Cache Check (HIT)
227
+ try:
228
+ derivative_bytes = await storage.get_asset(derivative_id)
229
+ return Response(
230
+ content=derivative_bytes,
231
+ media_type=f"image/{options.format.value.lower()}",
232
+ headers={
233
+ "Cache-Control": "public, max-age=31536000, immutable",
234
+ "X-MorphosX-Cache": "HIT"
235
+ }
236
+ )
237
+ except FileNotFoundError:
238
+ # 4. Cache Miss (MISS)
239
+ pass
240
+
241
+ # 5. Fetch Original (Always from originals/ folder)
242
+ original_id = f"originals/{asset_id}"
243
+ source_bytes = await storage.get_asset(original_id)
244
+
245
+ # 6. Transform Pipeline
246
+ is_audio = Path(asset_id).suffix.lower() in AUDIO_EXTENSIONS
247
+ is_raw = Path(asset_id).suffix.lower() in RAW_EXTENSIONS
248
+ is_text = Path(asset_id).suffix.lower() in TEXT_EXTENSIONS
249
+ is_office = Path(asset_id).suffix.lower() in OFFICE_EXTENSIONS
250
+ is_font = Path(asset_id).suffix.lower() in FONT_EXTENSIONS
251
+ is_model3d = Path(asset_id).suffix.lower() in MODEL3D_EXTENSIONS
252
+ is_archive = Path(asset_id).suffix.lower() in ARCHIVE_EXTENSIONS
253
+ is_bim = Path(asset_id).suffix.lower() in BIM_EXTENSIONS
254
+
255
+ if is_video:
256
+ # Video: Extract Frame -> Process as Image
257
+ frame_bytes = video_processor.extract_thumbnail(source_bytes, t)
258
+ processed_data, mime_type = processor.process(frame_bytes, options)
259
+ elif is_audio:
260
+ # Audio: Generate Waveform -> Process as Image
261
+ waveform_bytes = audio_processor.generate_waveform(source_bytes, w or 800, h or 200)
262
+ processed_data, mime_type = processor.process(waveform_bytes, options)
263
+ elif is_document:
264
+ # Document: Extract Page -> Process as Image
265
+ # We extract at 150 DPI to ensure crisp text before potential downscaling
266
+ page_bytes = document_processor.extract_page_as_image(source_bytes, p, dpi=150)
267
+ processed_data, mime_type = processor.process(page_bytes, options)
268
+ elif is_raw:
269
+ # RAW: Extract Preview -> Process as Image
270
+ preview_bytes = raw_processor.extract_preview(source_bytes)
271
+ processed_data, mime_type = processor.process(preview_bytes, options)
272
+ elif is_text:
273
+ # Text: Render to Image -> Process as Image
274
+ rendered_bytes = text_processor.render_to_image(source_bytes, asset_id, options)
275
+ processed_data, mime_type = processor.process(rendered_bytes, options)
276
+ elif is_office:
277
+ # Office: Generate Summary Card -> Process as Image
278
+ office_card_bytes = office_processor.render_thumbnail(source_bytes, asset_id)
279
+ processed_data, mime_type = processor.process(office_card_bytes, options)
280
+ elif is_font:
281
+ # Font: Render Specimen -> Process as Image
282
+ specimen_bytes = font_processor.render_specimen(source_bytes, {})
283
+ processed_data, mime_type = processor.process(specimen_bytes, options)
284
+ elif is_model3d:
285
+ # 3D: Generate Blueprint -> Process as Image
286
+ model_bytes = model3d_processor.render_thumbnail(source_bytes, asset_id)
287
+ processed_data, mime_type = processor.process(model_bytes, options)
288
+ elif is_archive:
289
+ # Archive: Generate Content List -> Process as Image
290
+ archive_bytes = archive_processor.render_thumbnail(source_bytes, asset_id)
291
+ processed_data, mime_type = processor.process(archive_bytes, options)
292
+ elif is_bim:
293
+ # BIM: Generate Building Data Card -> Process as Image
294
+ bim_bytes = bim_processor.render_summary(source_bytes, asset_id)
295
+ processed_data, mime_type = processor.process(bim_bytes, options)
296
+ else:
297
+ # Image: Process directly
298
+ processed_data, mime_type = processor.process(source_bytes, options)
299
+
300
+ # 7. Store derivative for future requests
301
+ await storage.save_asset(derivative_id, processed_data)
302
+
303
+ return Response(
304
+ content=processed_data,
305
+ media_type=mime_type,
306
+ headers={
307
+ "Cache-Control": "public, max-age=31536000, immutable",
308
+ "X-MorphosX-Cache": "MISS"
309
+ }
310
+ )
311
+
312
+ except FileNotFoundError:
313
+ raise HTTPException(status_code=404, detail="Asset not found")
314
+ except Exception as e:
315
+ raise HTTPException(status_code=500, detail=f"Processing error: {str(e)}")
316
+
317
+
318
+ @router.get("/list/{path:path}")
319
+ async def list_assets(
320
+ path: str = "",
321
+ current_user: Optional[str] = Depends(get_current_user)
322
+ ):
323
+ """
324
+ List files and folders in a given path.
325
+ """
326
+ # Security: If path starts with users/, verify ownership
327
+ if path.startswith("users/"):
328
+ parts = path.split("/")
329
+ if len(parts) >= 2:
330
+ owner_id = parts[1]
331
+ if current_user != owner_id:
332
+ raise HTTPException(status_code=403, detail="Not authorized to browse this folder")
333
+
334
+ # If path is empty, default to listing 'originals/' (public root)
335
+ if not path:
336
+ path = "originals"
337
+
338
+ try:
339
+ items = await storage.list_assets(path)
340
+ return {
341
+ "path": path,
342
+ "items": items
343
+ }
344
+ except Exception as e:
345
+ raise HTTPException(status_code=500, detail=f"Listing failed: {str(e)}")
346
+
morphosx/app/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ import uvicorn
2
+ import argparse
3
+
4
+ def main():
5
+ parser = argparse.ArgumentParser(description="MorphosX Media Engine CLI")
6
+ subparsers = parser.add_subparsers(dest="command")
7
+
8
+ # Command: start
9
+ start_parser = subparsers.add_parser("start", help="Start the MorphosX server")
10
+ start_parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
11
+ start_parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
12
+ start_parser.add_argument("--reload", action="store_true", help="Enable auto-reload")
13
+
14
+ args = parser.parse_args()
15
+
16
+ if args.command == "start":
17
+ uvicorn.run("morphosx.app.main:app", host=args.host, port=args.port, reload=args.reload)
18
+ else:
19
+ parser.print_help()
20
+
21
+ if __name__ == "__main__":
22
+ main()
File without changes
@@ -0,0 +1,43 @@
1
+ from typing import Optional
2
+ from datetime import datetime, timedelta
3
+ from jose import JWTError, jwt
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import OAuth2PasswordBearer
6
+ from morphosx.app.settings import settings
7
+
8
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
9
+
10
+ def get_current_user(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[str]:
11
+ """
12
+ Decodes the JWT token and returns the user_id (subject).
13
+ If no token is provided, returns None (for public/unauthenticated access).
14
+ """
15
+ if not token:
16
+ return None
17
+
18
+ try:
19
+ payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
20
+ user_id: str = payload.get("sub")
21
+ if user_id is None:
22
+ return None
23
+ return user_id
24
+ except JWTError:
25
+ # Invalid token
26
+ raise HTTPException(
27
+ status_code=status.HTTP_401_UNAUTHORIZED,
28
+ detail="Could not validate credentials",
29
+ headers={"WWW-Authenticate": "Bearer"},
30
+ )
31
+
32
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
33
+ """
34
+ Helper to generate a JWT for testing or initial setup.
35
+ """
36
+ to_encode = data.copy()
37
+ if expires_delta:
38
+ expire = datetime.utcnow() + expires_delta
39
+ else:
40
+ expire = datetime.utcnow() + timedelta(minutes=15)
41
+ to_encode.update({"exp": expire})
42
+ encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
43
+ return encoded_jwt
@@ -0,0 +1,60 @@
1
+ import hmac
2
+ import hashlib
3
+ from typing import Optional
4
+
5
+
6
+ def generate_signature(
7
+ asset_id: str,
8
+ width: Optional[int],
9
+ height: Optional[int],
10
+ format: str,
11
+ quality: int,
12
+ secret_key: str,
13
+ preset: Optional[str] = None,
14
+ user_id: Optional[str] = None
15
+ ) -> str:
16
+ """
17
+ Generate an HMAC-SHA256 signature for image transformation parameters.
18
+
19
+ :param asset_id: The unique ID of the original asset.
20
+ :param width: Target width (or None).
21
+ :param height: Target height (or None).
22
+ :param format: Output format (e.g. 'webp').
23
+ :param quality: Output quality (e.g. 80).
24
+ :param secret_key: The server-side secret key.
25
+ :param preset: Optional preset name (e.g. 'thumb').
26
+ :param user_id: Optional user identifier for protected assets.
27
+ :return: Hexadecimal signature string (first 16 chars for brevity).
28
+ """
29
+ # Create a canonical representation of the transformation parameters
30
+ # This ensures consistency: order matters!
31
+ payload = f"{asset_id}|w{width}|h{height}|f{format}|q{quality}|p{preset}|u{user_id}"
32
+
33
+ signature = hmac.new(
34
+ secret_key.encode('utf-8'),
35
+ payload.encode('utf-8'),
36
+ hashlib.sha256
37
+ ).hexdigest()
38
+
39
+ # We take only the first 16 chars for a cleaner URL, still 2^64 variations
40
+ return signature[:16]
41
+
42
+
43
+ def verify_signature(
44
+ asset_id: str,
45
+ width: Optional[int],
46
+ height: Optional[int],
47
+ format: str,
48
+ quality: int,
49
+ signature_to_verify: str,
50
+ secret_key: str,
51
+ preset: Optional[str] = None,
52
+ user_id: Optional[str] = None
53
+ ) -> bool:
54
+ """
55
+ Check if a provided signature matches the expected signature for those parameters.
56
+ """
57
+ expected = generate_signature(asset_id, width, height, format, quality, secret_key, preset, user_id)
58
+
59
+ # Use hmac.compare_digest to prevent timing attacks
60
+ return hmac.compare_digest(expected, signature_to_verify)
File without changes
@@ -0,0 +1,58 @@
1
+ import io
2
+ import zipfile
3
+ import tarfile
4
+ from PIL import Image, ImageDraw
5
+
6
+
7
+ class ArchiveProcessor:
8
+ """
9
+ Engine for generating content-list previews for ZIP and TAR archives.
10
+ """
11
+
12
+ def render_thumbnail(self, archive_data: bytes, filename: str) -> bytes:
13
+ """
14
+ Create a preview of the archive contents.
15
+ """
16
+ ext = filename.split(".")[-1].lower()
17
+ file_list = []
18
+ try:
19
+ if ext == "zip":
20
+ with zipfile.ZipFile(io.BytesIO(archive_data)) as z:
21
+ file_list = [f.filename for f in z.infolist()[:15]]
22
+ total = len(z.infolist())
23
+ elif ext == "tar" or filename.endswith(".tar.gz"):
24
+ with tarfile.open(fileobj=io.BytesIO(archive_data)) as t:
25
+ file_list = [f.name for f in t.getmembers()[:15]]
26
+ total = len(t.getmembers())
27
+
28
+ summary = "
29
+ ".join(file_list)
30
+ if total > 15:
31
+ summary += f"
32
+ ... and {total - 15} more files."
33
+
34
+ title = f"Archive: {filename} ({total} files)"
35
+ return self._create_folder_card(title, summary)
36
+
37
+ except Exception as e:
38
+ return self._create_folder_card("Archive Error", f"Could not read archive: {str(e)}")
39
+
40
+ def _create_folder_card(self, title: str, text: str) -> bytes:
41
+ """Render a folder-style card image."""
42
+ width, height = 800, 600
43
+ img = Image.new("RGB", (width, height), color=(255, 250, 230)) # Manila folder yellow
44
+ draw = ImageDraw.Draw(img)
45
+
46
+ # Draw a folder 'tab'
47
+ draw.rectangle([20, 10, 200, 40], fill=(210, 180, 100))
48
+
49
+ # Draw main folder body
50
+ draw.rectangle([20, 40, 780, 580], outline=(180, 150, 80), width=3)
51
+
52
+ # Draw text
53
+ draw.text((40, 60), title, fill=(100, 80, 20))
54
+ draw.text((40, 110), text, fill=(60, 50, 30))
55
+
56
+ output = io.BytesIO()
57
+ img.save(output, format="JPEG")
58
+ return output.getvalue()
@@ -0,0 +1,40 @@
1
+ import ffmpeg
2
+ import os
3
+ import tempfile
4
+
5
+
6
+ class AudioProcessor:
7
+ """
8
+ Core engine for audio manipulation and waveform generation.
9
+ """
10
+
11
+ def generate_waveform(self, audio_data: bytes, width: int = 800, height: int = 200, color: str = 'cyan') -> bytes:
12
+ """
13
+ Generate a waveform image (PNG) from an audio file.
14
+
15
+ :param audio_data: Raw audio bytes.
16
+ :param width: Target width of the waveform image.
17
+ :param height: Target height of the waveform image.
18
+ :param color: The color of the waveform.
19
+ :return: PNG image bytes.
20
+ """
21
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".audio") as tmp:
22
+ tmp.write(audio_data)
23
+ tmp_path = tmp.name
24
+
25
+ try:
26
+ # Command: ffmpeg -i input -filter_complex "showwavespic=s=800x200:colors=cyan" -frames:v 1 output.png
27
+ # This generates a visual representation of the audio amplitudes.
28
+ out, _ = (
29
+ ffmpeg
30
+ .input(tmp_path)
31
+ .filter('showwavespic', s=f"{width}x{height}", colors=color)
32
+ .output('pipe:', vframes=1, format='image2', vcodec='png')
33
+ .run(capture_stdout=True, quiet=True)
34
+ )
35
+ return out
36
+ except ffmpeg.Error as e:
37
+ raise RuntimeError(f"FFmpeg waveform generation failed: {e.stderr.decode()}")
38
+ finally:
39
+ if os.path.exists(tmp_path):
40
+ os.remove(tmp_path)
@@ -0,0 +1,91 @@
1
+ import io
2
+ from PIL import Image, ImageDraw
3
+
4
+
5
+ class BIMProcessor:
6
+ """
7
+ Engine for generating technical summaries for BIM (IFC) files.
8
+ """
9
+
10
+ def render_summary(self, ifc_data: bytes, filename: str) -> bytes:
11
+ """
12
+ Create a technical data card for an IFC file.
13
+ """
14
+ try:
15
+ import ifcopenshell
16
+ import ifcopenshell.util.element
17
+ except ImportError:
18
+ raise RuntimeError("ifcopenshell is not installed. Run 'pip install morphosx[bim]' to enable this feature.")
19
+
20
+ try:
21
+ # Load IFC from memory
22
+ # IfcOpenShell usually expects a file path, but we can use a temp file
23
+ # or try the direct string loader if the version supports it.
24
+ # For robustness in memory-only environments, we use a temporary named file.
25
+ import tempfile
26
+ import os
27
+
28
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".ifc") as tmp:
29
+ tmp.write(ifc_data)
30
+ tmp_path = tmp.name
31
+
32
+ try:
33
+ model = ifcopenshell.open(tmp_path)
34
+
35
+ # Metadata extraction
36
+ project = model.by_type("IfcProject")[0] if model.by_type("IfcProject") else None
37
+ site = model.by_type("IfcSite")[0] if model.by_type("IfcSite") else None
38
+
39
+ walls = len(model.by_type("IfcWall"))
40
+ windows = len(model.by_type("IfcWindow"))
41
+ doors = len(model.by_type("IfcDoor"))
42
+ stories = len(model.by_type("IfcBuildingStorey"))
43
+
44
+ title = f"BIM Project: {project.Name if project else 'Unnamed'}"
45
+ summary = (
46
+ f"Site: {site.Name if site else 'Unknown'}
47
+ "
48
+ f"Building Stories: {stories}
49
+
50
+ "
51
+ f"Element Count:
52
+ "
53
+ f"- Walls: {walls}
54
+ "
55
+ f"- Windows: {windows}
56
+ "
57
+ f"- Doors: {doors}
58
+
59
+ "
60
+ f"Schema: {model.schema}"
61
+ )
62
+ finally:
63
+ if os.path.exists(tmp_path):
64
+ os.remove(tmp_path)
65
+
66
+ return self._create_bim_card(title, summary)
67
+
68
+ except Exception as e:
69
+ return self._create_bim_card("BIM Parsing Error", f"Could not parse IFC: {str(e)}")
70
+
71
+ def _create_bim_card(self, title: str, text: str) -> bytes:
72
+ """Render a technical architecture-style card."""
73
+ width, height = 800, 600
74
+ img = Image.new("RGB", (width, height), color=(30, 30, 35)) # Dark gray
75
+ draw = ImageDraw.Draw(img)
76
+
77
+ # Draw some 'architectural' lines
78
+ draw.line([0, 100, width, 100], fill=(100, 200, 100), width=2)
79
+ draw.line([100, 0, 100, height], fill=(100, 200, 100), width=1)
80
+
81
+ # Text
82
+ draw.text((120, 40), title, fill=(255, 255, 255))
83
+ draw.text((120, 130), text, fill=(180, 200, 180))
84
+
85
+ # Simple house icon silhouette
86
+ draw.polygon([(600, 200), (750, 200), (675, 100)], outline=(100, 255, 100), width=2)
87
+ draw.rectangle([620, 200, 730, 300], outline=(100, 255, 100), width=2)
88
+
89
+ output = io.BytesIO()
90
+ img.save(output, format="JPEG")
91
+ return output.getvalue()