mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
add IfcTester webapp to Bonsai UI and websocket server
This commit is contained in:
committed by
Dion Moult
parent
be72887216
commit
10dcd9a923
@@ -21,6 +21,9 @@ from . import ui, prop, operator
|
||||
|
||||
classes = (
|
||||
operator.ExecuteIfcTester,
|
||||
operator.StartIfcTesterWebapp,
|
||||
operator.StopIfcTesterWebapp,
|
||||
operator.OpenIfcTesterWebapp,
|
||||
operator.SelectRequirement,
|
||||
operator.SelectFailedEntities,
|
||||
operator.ExportBcf,
|
||||
|
||||
@@ -22,6 +22,13 @@ import time
|
||||
import tempfile
|
||||
import webbrowser
|
||||
import traceback
|
||||
import subprocess
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import asyncio
|
||||
import socketio
|
||||
from aiohttp import web
|
||||
import ifctester
|
||||
import ifctester.ids
|
||||
import ifctester.reporter
|
||||
@@ -32,6 +39,145 @@ from bpy_extras.io_utils import ExportHelper
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
# Global variables to track server processes/threads
|
||||
webapp_process = None
|
||||
websocket_server_thread = None
|
||||
websocket_app = None
|
||||
websocket_runner = None
|
||||
|
||||
|
||||
class IfcTesterWebSocketServer:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp")
|
||||
self.app = web.Application()
|
||||
self.sio.attach(self.app)
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
# Register namespace
|
||||
self.sio.register_namespace(IfcTesterNamespace("/ifctester"))
|
||||
|
||||
# Add health check route
|
||||
self.app.router.add_get("/health", self.health_check)
|
||||
|
||||
async def health_check(self, request):
|
||||
return web.Response(text="OK", content_type="text/plain")
|
||||
|
||||
async def start_server(self):
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, "127.0.0.1", self.port)
|
||||
await self.site.start()
|
||||
print(f"IfcTester Websocket server started on 127.0.0.1:{self.port}")
|
||||
|
||||
async def stop_server(self):
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
|
||||
class IfcTesterNamespace(socketio.AsyncNamespace):
|
||||
def __init__(self, namespace):
|
||||
super().__init__(namespace)
|
||||
|
||||
async def on_connect(self, sid, environ):
|
||||
print(f"IfcTester webapp client connected: {sid}")
|
||||
await self.emit("status", {"connected": True}, room=sid)
|
||||
|
||||
async def on_disconnect(self, sid):
|
||||
print(f"IfcTester webapp client disconnected: {sid}")
|
||||
|
||||
async def on_audit_ids(self, sid, data):
|
||||
request_id = None
|
||||
try:
|
||||
request_id = data.get("id")
|
||||
ids_string = data.get("ids")
|
||||
|
||||
if not request_id:
|
||||
await self.emit("error", {
|
||||
"error": "No request ID provided"
|
||||
}, room=sid)
|
||||
return
|
||||
|
||||
if not ids_string:
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": "No IDS XML string provided"
|
||||
}, room=sid)
|
||||
return
|
||||
|
||||
print(f"Processing IDS audit request {request_id}")
|
||||
|
||||
# Check if IFC is loaded in Bonsai
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc:
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": "No IFC model is currently loaded in Bonsai"
|
||||
}, room=sid)
|
||||
return
|
||||
|
||||
# Parse IDS from string
|
||||
try:
|
||||
ids = ifctester.ids.from_string(ids_string)
|
||||
except Exception as e:
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": f"Failed to parse IDS XML: {str(e)}"
|
||||
}, room=sid)
|
||||
return
|
||||
|
||||
# Validate IFC against IDS
|
||||
try:
|
||||
ids.validate(ifc)
|
||||
except Exception as e:
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": f"Validation failed: {str(e)}"
|
||||
}, room=sid)
|
||||
return
|
||||
|
||||
# Generate reports
|
||||
try:
|
||||
# JSON report
|
||||
json_reporter = ifctester.reporter.Json(ids)
|
||||
json_reporter.report()
|
||||
json_report = json_reporter.to_string()
|
||||
|
||||
# HTML report
|
||||
html_reporter = ifctester.reporter.Html(ids)
|
||||
html_reporter.report()
|
||||
html_report = html_reporter.to_string()
|
||||
|
||||
# Send results back
|
||||
await self.emit("audit_result", {
|
||||
"id": request_id,
|
||||
"json_report": json_report,
|
||||
"html_report": html_report
|
||||
}, room=sid)
|
||||
|
||||
print(f"Successfully processed IDS audit request {request_id}")
|
||||
|
||||
except Exception as e:
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": f"Failed to generate reports: {str(e)}"
|
||||
}, room=sid)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing audit request: {str(e)}")
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
await self.emit("error", {
|
||||
"id": request_id,
|
||||
"error": f"Internal server error: {str(e)}"
|
||||
}, room=sid)
|
||||
|
||||
async def on_ping(self, sid, data):
|
||||
await self.emit("pong", {"timestamp": data.get("timestamp")}, room=sid)
|
||||
|
||||
|
||||
class ExecuteIfcTester(bpy.types.Operator):
|
||||
bl_idname = "bim.execute_ifc_tester"
|
||||
@@ -124,6 +270,177 @@ class ExecuteIfcTester(bpy.types.Operator):
|
||||
new_spec.status = spec["status"]
|
||||
|
||||
|
||||
class StartIfcTesterWebapp(bpy.types.Operator):
|
||||
bl_idname = "bim.start_ifc_tester_webapp"
|
||||
bl_label = "Start IfcTester Webapp"
|
||||
bl_description = "Start the IfcTester webapp server and open it in the default browser"
|
||||
|
||||
def execute(self, context):
|
||||
global webapp_process, websocket_server_thread, websocket_app
|
||||
|
||||
props = tool.Tester.get_tester_props()
|
||||
|
||||
if webapp_process is not None or websocket_server_thread is not None:
|
||||
self.report({"WARNING"}, "IfcTester webapp is already running")
|
||||
return {"CANCELLED"}
|
||||
|
||||
try:
|
||||
import ifctester.webapp.serve
|
||||
except ImportError:
|
||||
self.report({"ERROR"}, "IfcTester webapp not available. Please ensure the latest version of ifctester is installed.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
webapp_port = self.find_free_port()
|
||||
websocket_port = self.find_free_port()
|
||||
|
||||
# Get the path to the serve.py module
|
||||
webapp_serve_path = ifctester.webapp.serve.__file__
|
||||
|
||||
try:
|
||||
# Start the websocket server in a thread
|
||||
websocket_app = IfcTesterWebSocketServer(websocket_port)
|
||||
|
||||
def run_websocket_server():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(websocket_app.start_server())
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"WebSocket server error: {e}")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
websocket_server_thread = threading.Thread(target=run_websocket_server, daemon=True)
|
||||
websocket_server_thread.start()
|
||||
|
||||
# Start the Flask server as subprocess
|
||||
webapp_process = subprocess.Popen([
|
||||
sys.executable, webapp_serve_path,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(webapp_port)
|
||||
])
|
||||
|
||||
# Update properties
|
||||
props.webapp_server_port = webapp_port
|
||||
props.websocket_server_port = websocket_port
|
||||
props.webapp_is_running = True
|
||||
|
||||
# Wait a moment for servers to start, then open browser
|
||||
def delayed_open_browser():
|
||||
import time
|
||||
time.sleep(1.5)
|
||||
webbrowser.open(f"http://127.0.0.1:{webapp_port}?bonsai_server={websocket_port}")
|
||||
|
||||
browser_thread = threading.Thread(target=delayed_open_browser, daemon=True)
|
||||
browser_thread.start()
|
||||
|
||||
self.report({"INFO"}, f"IfcTester webapp started at http://127.0.0.1:{webapp_port} (Websocket: {websocket_port})")
|
||||
return {"FINISHED"}
|
||||
|
||||
except Exception as e:
|
||||
# Clean up on error
|
||||
if webapp_process:
|
||||
webapp_process.terminate()
|
||||
webapp_process = None
|
||||
if websocket_server_thread and websocket_app:
|
||||
# The websocket server will be cleaned up when the thread ends
|
||||
websocket_server_thread = None
|
||||
websocket_app = None
|
||||
|
||||
self.report({"ERROR"}, f"Failed to start servers: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
def find_free_port(self):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
|
||||
class StopIfcTesterWebapp(bpy.types.Operator):
|
||||
bl_idname = "bim.stop_ifc_tester_webapp"
|
||||
bl_label = "Stop IfcTester Webapp"
|
||||
bl_description = "Stop the IfcTester webapp server"
|
||||
|
||||
def execute(self, context):
|
||||
global webapp_process, websocket_server_thread, websocket_app
|
||||
|
||||
props = tool.Tester.get_tester_props()
|
||||
|
||||
if webapp_process is None and websocket_server_thread is None:
|
||||
self.report({"WARNING"}, "No IfcTester servers are running")
|
||||
return {"CANCELLED"}
|
||||
|
||||
errors = []
|
||||
|
||||
# Stop webapp server
|
||||
if webapp_process:
|
||||
try:
|
||||
webapp_process.terminate()
|
||||
webapp_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
webapp_process.kill()
|
||||
except Exception as e:
|
||||
errors.append(f"Error stopping webapp server: {str(e)}")
|
||||
finally:
|
||||
webapp_process = None
|
||||
|
||||
# Stop websocket server
|
||||
if websocket_app and websocket_server_thread:
|
||||
try:
|
||||
# Create a new event loop to properly shutdown the server
|
||||
def shutdown_websocket():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(websocket_app.stop_server())
|
||||
except Exception as e:
|
||||
errors.append(f"Error stopping websocket server: {str(e)}")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Run shutdown in a separate thread to avoid blocking
|
||||
shutdown_thread = threading.Thread(target=shutdown_websocket)
|
||||
shutdown_thread.start()
|
||||
shutdown_thread.join(timeout=5) # Wait up to 5 seconds
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error during websocket shutdown: {str(e)}")
|
||||
finally:
|
||||
websocket_app = None
|
||||
websocket_server_thread = None
|
||||
|
||||
# Update properties
|
||||
props.webapp_server_port = 0
|
||||
props.websocket_server_port = 0
|
||||
props.webapp_is_running = False
|
||||
|
||||
if errors:
|
||||
self.report({"WARNING"}, f"IfcTester webapp and server stopped with errors: {'; '.join(errors)}")
|
||||
else:
|
||||
self.report({"INFO"}, "IfcTester webapp and server stopped")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class OpenIfcTesterWebapp(bpy.types.Operator):
|
||||
bl_idname = "bim.open_ifc_tester_webapp"
|
||||
bl_label = "Open IfcTester Webapp"
|
||||
bl_description = "Open the IfcTester webapp in the default browser"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Tester.get_tester_props()
|
||||
|
||||
if not props.webapp_is_running:
|
||||
self.report({"ERROR"}, "IfcTester webapp is not running. Please start it first.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
webbrowser.open(f"http://127.0.0.1:{props.webapp_server_port}?bonsai_server={props.websocket_server_port}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectRequirement(bpy.types.Operator):
|
||||
bl_idname = "bim.select_requirement"
|
||||
bl_label = "Select Specification"
|
||||
|
||||
@@ -77,6 +77,9 @@ class IfcTesterProperties(PropertyGroup):
|
||||
failed_entities: CollectionProperty(name="FailedEntities", type=FailedEntities)
|
||||
has_entities: BoolProperty(default=False, name="")
|
||||
n_entities: IntProperty(name="", default=0)
|
||||
webapp_server_port: IntProperty(name="Webapp Server Port", default=0)
|
||||
webapp_is_running: BoolProperty(default=False, name="Webapp Is Running", options=set())
|
||||
websocket_server_port: IntProperty(name="WebSocket Server Port", default=0)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
specs: MultipleFileSelect
|
||||
@@ -92,3 +95,6 @@ class IfcTesterProperties(PropertyGroup):
|
||||
failed_entities: bpy.types.bpy_prop_collection_idprop[FailedEntities]
|
||||
has_entities: bool
|
||||
n_entities: int
|
||||
webapp_server_port: int
|
||||
webapp_is_running: bool
|
||||
websocket_server_port: int
|
||||
|
||||
@@ -70,6 +70,20 @@ class BIM_PT_tester(Panel):
|
||||
|
||||
row = self.layout.row()
|
||||
row.operator("bim.execute_ifc_tester")
|
||||
|
||||
self.layout.separator()
|
||||
|
||||
# IfcTester Webapp controls
|
||||
if props.webapp_is_running:
|
||||
row = self.layout.row()
|
||||
row.label(text=f"Webapp: {props.webapp_server_port} | Server: {props.websocket_server_port}")
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.stop_ifc_tester_webapp")
|
||||
row.operator("bim.open_ifc_tester_webapp", icon="URL", text="")
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.operator("bim.start_ifc_tester_webapp")
|
||||
|
||||
if TesterData.data["has_report"]:
|
||||
self.layout.template_list(
|
||||
|
||||
@@ -50,6 +50,7 @@ webapp-prepare: webapp-build
|
||||
rm -rf $(PACKAGE_NAME)/webapp/www/*
|
||||
mkdir -p $(PACKAGE_NAME)/webapp/www
|
||||
cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_NAME)/webapp/www/
|
||||
cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_NAME)/webapp/__init__.py
|
||||
cp $(WEBAPP_DIR)/serve.py $(PACKAGE_NAME)/webapp/serve.py
|
||||
|
||||
.PHONY: test
|
||||
|
||||
@@ -15,11 +15,10 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy", "odfpy", "pystache", "bcf-client"]
|
||||
dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy", "odfpy", "pystache", "bcf-client", "flask"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
advanced = [
|
||||
"flask",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user