add IfcTester webapp to Bonsai UI and websocket server

This commit is contained in:
Sayan Jyoti Das
2025-08-28 14:33:16 +05:30
committed by Dion Moult
parent be72887216
commit 10dcd9a923
6 changed files with 342 additions and 2 deletions
@@ -21,6 +21,9 @@ from . import ui, prop, operator
classes = ( classes = (
operator.ExecuteIfcTester, operator.ExecuteIfcTester,
operator.StartIfcTesterWebapp,
operator.StopIfcTesterWebapp,
operator.OpenIfcTesterWebapp,
operator.SelectRequirement, operator.SelectRequirement,
operator.SelectFailedEntities, operator.SelectFailedEntities,
operator.ExportBcf, operator.ExportBcf,
@@ -22,6 +22,13 @@ import time
import tempfile import tempfile
import webbrowser import webbrowser
import traceback import traceback
import subprocess
import socket
import sys
import threading
import asyncio
import socketio
from aiohttp import web
import ifctester import ifctester
import ifctester.ids import ifctester.ids
import ifctester.reporter import ifctester.reporter
@@ -32,6 +39,145 @@ from bpy_extras.io_utils import ExportHelper
from pathlib import Path from pathlib import Path
from typing import Union 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): class ExecuteIfcTester(bpy.types.Operator):
bl_idname = "bim.execute_ifc_tester" bl_idname = "bim.execute_ifc_tester"
@@ -124,6 +270,177 @@ class ExecuteIfcTester(bpy.types.Operator):
new_spec.status = spec["status"] 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): class SelectRequirement(bpy.types.Operator):
bl_idname = "bim.select_requirement" bl_idname = "bim.select_requirement"
bl_label = "Select Specification" bl_label = "Select Specification"
@@ -77,6 +77,9 @@ class IfcTesterProperties(PropertyGroup):
failed_entities: CollectionProperty(name="FailedEntities", type=FailedEntities) failed_entities: CollectionProperty(name="FailedEntities", type=FailedEntities)
has_entities: BoolProperty(default=False, name="") has_entities: BoolProperty(default=False, name="")
n_entities: IntProperty(name="", default=0) 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: if TYPE_CHECKING:
specs: MultipleFileSelect specs: MultipleFileSelect
@@ -92,3 +95,6 @@ class IfcTesterProperties(PropertyGroup):
failed_entities: bpy.types.bpy_prop_collection_idprop[FailedEntities] failed_entities: bpy.types.bpy_prop_collection_idprop[FailedEntities]
has_entities: bool has_entities: bool
n_entities: int n_entities: int
webapp_server_port: int
webapp_is_running: bool
websocket_server_port: int
+14
View File
@@ -70,6 +70,20 @@ class BIM_PT_tester(Panel):
row = self.layout.row() row = self.layout.row()
row.operator("bim.execute_ifc_tester") 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"]: if TesterData.data["has_report"]:
self.layout.template_list( self.layout.template_list(
+1
View File
@@ -50,6 +50,7 @@ webapp-prepare: webapp-build
rm -rf $(PACKAGE_NAME)/webapp/www/* rm -rf $(PACKAGE_NAME)/webapp/www/*
mkdir -p $(PACKAGE_NAME)/webapp/www mkdir -p $(PACKAGE_NAME)/webapp/www
cp -r $(WEBAPP_BUILD_DIR)/* $(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 cp $(WEBAPP_DIR)/serve.py $(PACKAGE_NAME)/webapp/serve.py
.PHONY: test .PHONY: test
+1 -2
View File
@@ -15,11 +15,10 @@ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "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] [project.optional-dependencies]
advanced = [ advanced = [
"flask",
] ]
[project.urls] [project.urls]