diff --git a/DIRECTIVE_OTO_3D_STUDIO_20260810.md b/DIRECTIVE_OTO_3D_STUDIO_20260810.md new file mode 100644 index 0000000..37f06eb --- /dev/null +++ b/DIRECTIVE_OTO_3D_STUDIO_20260810.md @@ -0,0 +1,22 @@ +# OTO 3D Studio · Pipeline complet (open-source, zero external dependency) +**Directive Michel · 2026-08-10** + +## Vision +Construire une expérience 3D virtuelle mieux que PlanPoint, à partir des archives existantes. +100% open-source, zéro dépendance externe (pas d'attente architectes, pas de studio 3D humain). + +## Sprints +- **A · Extract** ✅ LIVE (oto_3d_extract.py, 9 projets extraits, manifest.json généré) +- **B · Génération 3D auto Blender** ← next (convertir IFC → GLB détaillé) +- **C · Batch renders Flux via RunPod** (~48 scènes photorealistes) +- **D · Panoramas 360° Blender Cycles** (~24 panoramas) +- **E · Refonte /choisir-mon-unite-3d/ avec checkout + websocket** live + +## Data disponible après Sprint A +- 5 projets avec IFC BIM squelette (P01, P02, P03, P05×2) +- 4 projets sans IFC (P04, P06-P09) — fallback GLB placeholder + PDF plans +- 100+ renders existants dans archives (à intégrer, pas à regénérer) +- Palette + matériaux + clientèle cible codés par projet +- Coordonnées géo + secteur pour chaque projet + +Voir: /opt/oto/3d/data/manifest.json diff --git a/SPRINT_A_3D_EXTRACT_20260810.py b/SPRINT_A_3D_EXTRACT_20260810.py new file mode 100755 index 0000000..4ec8442 --- /dev/null +++ b/SPRINT_A_3D_EXTRACT_20260810.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +OTOV7 · Sprint A · Extract 3D data from data_room archives +============================================================ +Scanne /opt/oto/data_room/{Pxx}/ pour extraire toutes les données nécessaires +à la génération 3D auto: +- IFC BIM squelette (aec_generated) +- Plans PDF préliminaires +- Feasibility docs (typologies, superficies, prix, ambiance/matériaux) +- Coordonnées géo (site) +- Rendus existants (200+ dans archives) + +Output: /opt/oto/3d/data/{Pxx}_extracted.json + /opt/oto/3d/data/manifest.json (global) + +Usage: + python3 oto_3d_extract.py # tous les projets + python3 oto_3d_extract.py P01 # un seul projet + +Author: OTOV7 Core · 2026-08-10 +""" +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +try: + import ifcopenshell + HAS_IFC = True +except ImportError: + HAS_IFC = False + +try: + import fitz # PyMuPDF + HAS_PDF = True +except ImportError: + HAS_PDF = False + +DATA_ROOM = Path("/opt/oto/data_room") +OUTPUT_DIR = Path("/opt/oto/3d/data") +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +# Metadata connue par projet (à enrichir avec les vraies faisabilités) +PROJECT_META = { + "P01": { + "name": "Coralis", + "developer": "HRD", + "location": {"city": "Santo Domingo", "sector": "Bella Vista", "country": "RD"}, + "clientele": "Jeunes cadres urbains 30-45", + "palette": ["#0A0A0E", "#C9A465", "#FAF8F4", "#8B7355"], + "materials": ["marbre carrare", "chêne massif", "laiton brossé", "verre trempé"], + "typologies_typical": ["1C", "2C", "3C", "Penthouse"], + "surface_m2": {"1C": 55, "2C": 85, "3C": 120, "Penthouse": 180}, + }, + "P02": { + "name": "Coral del Sur", + "developer": "Ristitullo", + "location": {"city": "Santo Domingo", "sector": "Naco", "country": "RD"}, + "clientele": "Familles internationales", + "palette": ["#F5F0EB", "#B8860B", "#4A5568", "#E6D5B8"], + "materials": ["travertin", "bois exotique", "acier noir", "béton lissé"], + "typologies_typical": ["Studio", "1C", "2C", "3C"], + "surface_m2": {"Studio": 42, "1C": 60, "2C": 90, "3C": 130}, + }, + "P03": { + "name": "Nakua", + "developer": "HRD", + "location": {"city": "Santo Domingo", "sector": "Bella Vista", "country": "RD"}, + "clientele": "Investisseurs internationaux", + "palette": ["#1A1A2E", "#E94560", "#F5F5F5", "#0F3460"], + "materials": ["granit noir", "acajou", "cuivre patiné", "verre fumé"], + "typologies_typical": ["Studio", "1C", "2C", "3C", "Penthouse"], + "surface_m2": {"Studio": 38, "1C": 58, "2C": 88, "3C": 128, "Penthouse": 220}, + }, + "P05": { + "name": "Najayo", + "developer": "HRD", + "location": {"city": "San Cristóbal", "sector": "Najayo Arriba", "country": "RD"}, + "clientele": "Retraités + secondaire côtière", + "palette": ["#FFFFFF", "#4A90E2", "#87CEEB", "#F5DEB3"], + "materials": ["pierre naturelle", "bois clair", "aluminium blanc", "béton chaux"], + "typologies_typical": ["1C", "2C", "3C"], + "surface_m2": {"1C": 65, "2C": 95, "3C": 135}, + }, +} + + +def find_ifc(project_code): + """Trouve le fichier IFC principal du projet.""" + base = DATA_ROOM / project_code / "aec_generated" + if not base.exists(): + return None + ifcs = list(base.rglob("*_complete.ifc")) + return ifcs[0] if ifcs else None + + +def parse_ifc(ifc_path): + """Extract structure IFC (site, building, storeys, spaces).""" + if not HAS_IFC or not ifc_path or not ifc_path.exists(): + return {"error": "IFC not available"} + try: + ifc = ifcopenshell.open(str(ifc_path)) + return { + "file": str(ifc_path.relative_to(DATA_ROOM)), + "size_bytes": ifc_path.stat().st_size, + "schema": ifc.schema, + "sites": [{"id": s.GlobalId, "name": s.Name} for s in ifc.by_type("IfcSite")], + "buildings": [{"id": b.GlobalId, "name": b.Name} for b in ifc.by_type("IfcBuilding")], + "storeys": [ + {"id": s.GlobalId, "name": s.Name, "elevation": getattr(s, "Elevation", None)} + for s in ifc.by_type("IfcBuildingStorey") + ], + "spaces_count": len(ifc.by_type("IfcSpace")), + "walls_count": len(ifc.by_type("IfcWall")), + "doors_count": len(ifc.by_type("IfcDoor")), + "windows_count": len(ifc.by_type("IfcWindow")), + } + except Exception as e: + return {"error": str(e)} + + +def find_plans_pdfs(project_code): + """Trouve les plans PDF (préliminaires + architectural).""" + base = DATA_ROOM / project_code + if not base.exists(): + return [] + plans = [] + patterns = ["*planos*.pdf", "*PLANOS*.pdf", "*plans*.pdf", "*architect*.pdf", + "HRD_*.pdf", "*preliminaires*.pdf"] + for pat in patterns: + plans.extend([str(p.relative_to(DATA_ROOM)) for p in base.rglob(pat)]) + return sorted(set(plans)) + + +def find_feasibility_docs(project_code): + """Trouve les docs de faisabilité (JSON de préférence, sinon MD/PDF).""" + base = DATA_ROOM / project_code + if not base.exists(): + return {} + docs = { + "json_files": [str(p.relative_to(DATA_ROOM)) for p in base.rglob("*.json")][:30], + "md_files": [str(p.relative_to(DATA_ROOM)) for p in base.rglob("*.md")][:20], + "financier_pdfs": [str(p.relative_to(DATA_ROOM)) for p in (base / "50_financier_bancable").glob("*.pdf")] if (base / "50_financier_bancable").exists() else [], + } + return docs + + +def find_existing_renders(project_code): + """Trouve rendus existants (PNG/JPG/WebP) dans archives.""" + base = DATA_ROOM / project_code + if not base.exists(): + return [] + renders = [] + for ext in ["png", "jpg", "jpeg", "webp"]: + renders.extend([str(p.relative_to(DATA_ROOM)) for p in base.rglob(f"*.{ext}")]) + return sorted(set(renders))[:100] + + +def extract_feasibility_units(project_code): + """Parse les JSON de faisabilité pour trouver les données d'unités.""" + base = DATA_ROOM / project_code + if not base.exists(): + return {} + units_data = {} + for jf in list(base.rglob("*unites*.json")) + list(base.rglob("*units*.json")): + try: + data = json.loads(jf.read_text(encoding="utf-8")) + units_data[str(jf.relative_to(DATA_ROOM))] = data + except Exception: + pass + for jf in list(base.rglob("*typolog*.json")): + try: + data = json.loads(jf.read_text(encoding="utf-8")) + units_data.setdefault("typologies", {})[str(jf.relative_to(DATA_ROOM))] = data + except Exception: + pass + return units_data + + +def extract_project(project_code): + """Extract complet d'un projet.""" + print(f"\n=== Extract {project_code} ===") + ifc_path = find_ifc(project_code) + meta = PROJECT_META.get(project_code[:3], {}) + + data = { + "project_code": project_code, + "meta": meta, + "extracted_at": datetime.utcnow().isoformat() + "Z", + "ifc": parse_ifc(ifc_path) if ifc_path else {"error": "no IFC found"}, + "plans_pdfs": find_plans_pdfs(project_code), + "feasibility_docs": find_feasibility_docs(project_code), + "existing_renders": find_existing_renders(project_code), + "units_from_feasibility": extract_feasibility_units(project_code), + } + + # Stats + data["stats"] = { + "ifc_available": bool(ifc_path), + "ifc_storeys": len(data["ifc"].get("storeys", [])), + "plans_pdf_count": len(data["plans_pdfs"]), + "feasibility_json_count": len(data["feasibility_docs"].get("json_files", [])), + "existing_renders_count": len(data["existing_renders"]), + } + + print(f" IFC: {'✓' if data['stats']['ifc_available'] else '✗'} · Storeys: {data['stats']['ifc_storeys']}") + print(f" Plans PDF: {data['stats']['plans_pdf_count']}") + print(f" Feasibility JSON: {data['stats']['feasibility_json_count']}") + print(f" Renders existants: {data['stats']['existing_renders_count']}") + + out_file = OUTPUT_DIR / f"{project_code}_extracted.json" + out_file.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + print(f" → {out_file}") + return data + + +def build_manifest(all_extracts): + """Manifest global pour le viewer frontend.""" + manifest = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "projects": {}, + } + for code, data in all_extracts.items(): + meta = data.get("meta", {}) + manifest["projects"][code] = { + "name": meta.get("name", code), + "location": meta.get("location", {}), + "clientele": meta.get("clientele", ""), + "storeys": data["stats"]["ifc_storeys"], + "renders_available": data["stats"]["existing_renders_count"], + "ifc_ready": data["stats"]["ifc_available"], + "typologies": meta.get("typologies_typical", []), + "extract_path": f"{code}_extracted.json", + } + (OUTPUT_DIR / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(f"\n=== Manifest global → {OUTPUT_DIR}/manifest.json ===") + return manifest + + +if __name__ == "__main__": + if len(sys.argv) > 1: + project = sys.argv[1] + projects = [project] + else: + # Auto-detect all P0X folders in data_room + projects = sorted([p.name for p in DATA_ROOM.iterdir() + if p.is_dir() and re.match(r"^P\d{2}", p.name)]) + print(f"Projects trouvés: {projects}") + + all_extracts = {} + for p in projects: + try: + all_extracts[p] = extract_project(p) + except Exception as e: + print(f" ERROR {p}: {e}") + + build_manifest(all_extracts) + print(f"\n✅ Sprint A terminé · {len(all_extracts)} projets extraits")