Skip to content

Commit

Permalink
allow popout of node editor in a separate window
Browse files Browse the repository at this point in the history
  • Loading branch information
Krande committed Oct 10, 2024
1 parent 2fbc576 commit 99999c3
Show file tree
Hide file tree
Showing 27 changed files with 323 additions and 72 deletions.
5 changes: 4 additions & 1 deletion examples/procedure_example/ada_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ use_ifc_convert = false

[websockets]
server_temp_dir = "./server_temp"
auto_load_temp_files = true
auto_load_temp_files = true

[ifc]
import_shape_geom = true
18 changes: 18 additions & 0 deletions examples/procedure_example/components/basic_shapes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import pathlib

import ada
from ada.comms.fb_model_gen import FileTypeDC
from ada.geom.surfaces import Plane
from ada.procedural_modelling.components_base import app, component_decorator


@component_decorator(export_file_type=FileTypeDC.IFC)
def make_basic_shapes(output_file: pathlib.Path = None):
box = ada.PrimBox("box", (0, 0, 0), (1, 1, 1))
# plane =
a = ada.Assembly("BasicShapes") / (box,)
a.to_ifc(output_file)


if __name__ == "__main__":
app()
3 changes: 3 additions & 0 deletions examples/procedure_example/serve_node_editor_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ada.comms.web_ui import start_serving

start_serving(web_port=5174, node_editor_only=True, target_instance=1383442296)
2 changes: 1 addition & 1 deletion examples/procedure_example/start_ws_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


async def start_async_server():
server = WebSocketAsyncServer("localhost", 8765)
server = WebSocketAsyncServer("localhost", 8765, debug=True)
await server.start_async()


Expand Down
3 changes: 2 additions & 1 deletion src/ada/api/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@ def solid_geom(self) -> Geometry:
if self.geom is None:
raise NotImplementedError(f"solid_geom() not implemented for {self.__class__.__name__}")

import ada.geom.solids as geo_so
import ada.geom.surfaces as geo_su

if isinstance(self.geom.geometry, (geo_su.AdvancedFace, geo_su.ClosedShell)):
if isinstance(self.geom.geometry, (geo_su.AdvancedFace, geo_su.ClosedShell, geo_so.Box)):

self.geom.bool_operations = [BooleanOperation(x.primitive.solid_geom(), x.bool_op) for x in self.booleans]
return self.geom
Expand Down
22 changes: 22 additions & 0 deletions src/ada/comms/cli_node_editor_startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import argparse
import pathlib

from ada.comms.web_ui import start_serving

NODE_EDITOR_CLI_PY = pathlib.Path(__file__)


def start_node_editor_app():
parser = argparse.ArgumentParser()
parser.add_argument("--web-port", type=int, default=5174)
parser.add_argument("--target-instance", type=int)
parser.add_argument("--auto-open", action="store_true")
args = parser.parse_args()

start_serving(
web_port=args.web_port, node_editor_only=True, target_instance=args.target_instance, auto_open=args.auto_open
)


if __name__ == "__main__":
start_node_editor_app()
1 change: 1 addition & 0 deletions src/ada/comms/fb_model_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class CommandTypeDC(Enum):
SERVER_REPLY = 11
VIEW_FILE_OBJECT = 12
DELETE_FILE_OBJECT = 13
START_NEW_NODE_EDITOR = 14


class TargetTypeDC(Enum):
Expand Down
3 changes: 3 additions & 0 deletions src/ada/comms/msg_handling/default_on_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ada.comms.msg_handling.mesh_info_callback import mesh_info_callback
from ada.comms.msg_handling.on_error_reply import on_error_reply
from ada.comms.msg_handling.run_procedure import run_procedure
from ada.comms.msg_handling.start_separate_node_editor import start_separate_node_editor
from ada.comms.msg_handling.update_scene import update_scene
from ada.comms.msg_handling.update_server import update_server
from ada.comms.msg_handling.view_file_object import view_file_object
Expand Down Expand Up @@ -39,6 +40,8 @@ def default_on_message(server: WebSocketAsyncServer, client: ConnectedClient, me
view_file_object(server, client, message.server.get_file_object_by_name)
elif message.command_type == CommandTypeDC.DELETE_FILE_OBJECT:
delete_file_object(server, client, message)
elif message.command_type == CommandTypeDC.START_NEW_NODE_EDITOR:
start_separate_node_editor(server, client, message)
else:
logger.error(f"Unknown command type: {message.command_type}")
on_error_reply(server, client, error_message=f"Unknown command type: {message.command_type}")
Expand Down
16 changes: 13 additions & 3 deletions src/ada/comms/msg_handling/run_procedure.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ def run_procedure(server: WebSocketAsyncServer, client: ConnectedClient, message
start_procedure = message.procedure_store.start_procedure

procedure: Procedure = server.procedure_store.get(start_procedure.procedure_name)
params = {p.name: p for p in start_procedure.parameters}
if start_procedure.parameters is None:
params = {}
else:
params = {p.name: p for p in start_procedure.parameters}

if "output_file" not in params.keys():
# add output_file if not exist
Expand Down Expand Up @@ -98,12 +101,19 @@ def update_server_on_successful_procedure_run(
)

update_server(server, client, new_file_object)
if message.instance_id != client.instance_id:
# send the new file object to the target client instead of client triggering the procedure
target_client = server.get_client_by_instance_id(message.instance_id)
if target_client is None:
raise ValueError(f"Client with instance id {message.instance_id} not found")
else:
target_client = client

reply_message = MessageDC(
instance_id=server.instance_id,
command_type=CommandTypeDC.SERVER_REPLY,
server=ServerDC(all_file_objects=server.scene.file_objects),
target_id=client.instance_id,
target_id=target_client.instance_id,
target_group=client.group_type,
server_reply=ServerReplyDC(file_object=new_file_object, reply_to=message.command_type),
)
Expand All @@ -112,6 +122,6 @@ def update_server_on_successful_procedure_run(

asyncio.run(client.websocket.send(fb_message))

view_file_object(server, client, new_file_object.name)
view_file_object(server, target_client, new_file_object.name)

logger.info(f"Completed Procedure '{procedure.name}' and added the File Object '{output_file}' to the server")
27 changes: 27 additions & 0 deletions src/ada/comms/msg_handling/start_separate_node_editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import subprocess
import sys
from typing import TYPE_CHECKING

from ada.comms.cli_node_editor_startup import NODE_EDITOR_CLI_PY
from ada.comms.fb_model_gen import MessageDC
from ada.config import logger

if TYPE_CHECKING:
from ada.comms.wsock_server import ConnectedClient, WebSocketAsyncServer


def start_separate_node_editor(server: WebSocketAsyncServer, client: ConnectedClient, message: MessageDC) -> None:
logger.info("Starting separate node editor")
python_executable = sys.executable
args = [
python_executable,
NODE_EDITOR_CLI_PY.as_posix(),
"--target-instance",
str(message.instance_id),
"--auto-open",
]
args_str = " ".join(args)
command = f'start cmd.exe /K "{args_str}"'
subprocess.run(command, shell=True, creationflags=subprocess.CREATE_NEW_CONSOLE)
98 changes: 98 additions & 0 deletions src/ada/comms/web_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import functools
import http.server
import os
import socketserver
import threading
import webbrowser

from ada.visit.rendering.renderer_react import RendererReact


# Define the custom request handler
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(
self, *args, unique_id, ws_port, node_editor_only=False, target_instance=None, directory=None, **kwargs
):
self.unique_id = unique_id
self.ws_port = ws_port # Use the actual WebSocket port
self.node_editor_only = node_editor_only
self.target_instance = target_instance
super().__init__(*args, directory=directory, **kwargs)

def do_GET(self):
if self.path == "/" or self.path == "/index.html":
# Serve the index.html file with replacements
index_file_path = os.path.join(self.directory, "index.html")
try:
with open(index_file_path, "r", encoding="utf-8") as f:
html_content = f.read()

replacement_str = ""
if self.unique_id is not None:
replacement_str += f'<script>window.WEBSOCKET_ID = "{self.unique_id}";</script>'
if self.ws_port is not None:
replacement_str += f"\n<script>window.WEBSOCKET_PORT = {self.ws_port};</script>"
if self.node_editor_only:
replacement_str += "\n<script>window.NODE_EDITOR_ONLY = true;</script>"
if self.target_instance is not None:
replacement_str += f'\n<script>window.TARGET_INSTANCE_ID = "{self.target_instance}";</script>'

# Perform the replacements
modified_html_content = html_content.replace("<!--STARTUP_CONFIG_PLACEHOLDER-->", replacement_str)

# Send response
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(modified_html_content.encode("utf-8"))
except Exception as e:
self.send_error(500, f"Internal Server Error: {e}")
else:
# For other files, use the default handler
super().do_GET()


def start_serving(
web_port=5173,
ws_port=8765,
unique_id=None,
target_instance=None,
node_editor_only=False,
non_blocking=False,
auto_open=False,
) -> tuple[socketserver.ThreadingTCPServer, threading.Thread] | None:
rr = RendererReact()
web_dir = rr.local_html_path.parent
# Create a partial function to pass the directory to the handler
handler = functools.partial(
CustomHTTPRequestHandler,
ws_port=ws_port,
unique_id=unique_id,
node_editor_only=node_editor_only,
target_instance=target_instance,
directory=str(web_dir),
)

class ThreadingTCPServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True

# Use port 0 to have the OS assign an available port
server = ThreadingTCPServer(("localhost", web_port), handler)
port = server.server_address[1]
print(
f"Web UI server started on port {port} with WebSocket port {ws_port} and unique ID {unique_id} and target instance {target_instance}"
)

# Open the default web browser
if auto_open:
webbrowser.open(f"http://localhost:{port}")

def start_server():
server.serve_forever()

if non_blocking:
server_thread = threading.Thread(target=start_server, daemon=True)
server_thread.start()
return server, server_thread
else:
start_server()
1 change: 1 addition & 0 deletions src/ada/comms/wsock/CommandType.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ class CommandType(object):
SERVER_REPLY = 11
VIEW_FILE_OBJECT = 12
DELETE_FILE_OBJECT = 13
START_NEW_NODE_EDITOR = 14
6 changes: 6 additions & 0 deletions src/ada/comms/wsock_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ def __init__(
self.procedure_store = ProcedureStore()
self.debug = debug

def get_client_by_instance_id(self, instance_id: int) -> Optional[ConnectedClient]:
for client in self.connected_clients:
if client.instance_id == instance_id:
return client
return None

async def handle_client(self, websocket: websockets.WebSocketServerProtocol, path: str):
client = await process_client(websocket, path)

Expand Down
1 change: 0 additions & 1 deletion src/ada/fem/formats/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ def get_exe_path(fea_type: FEATypes):
if exe_linux is None and bin_exe_linux.exists():
exe_linux = bin_exe_linux
exe_win = shutil.which(f"{exe_name}.exe")
fea_exe_paths = Config().fea_fem_exe_paths

if Config().fea_fem_exe_paths.get(exe_name, None) is not None:
exe_path = Config().fea_fem_exe_paths[exe_name]
Expand Down
20 changes: 11 additions & 9 deletions src/ada/procedural_modelling/load_procedures.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,25 @@ def get_procedure_from_script(script_path: pathlib.Path) -> Procedure:
tree = ast.parse(source_code, filename=str(script_path))

main_func = None
custom_decorator = None
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "main":
if isinstance(node, ast.FunctionDef):
main_func = node
break
if main_func.decorator_list:
custom_decorator = [
d for d in main_func.decorator_list if d.func.id in ("procedure_decorator", "component_decorator")
]
if custom_decorator is not None and len(custom_decorator) == 1:
break

if main_func is None:
raise Exception(f"No 'main' function found in {script_path}")

# extract decorator (if any)
decorator_config = {}
if main_func.decorator_list:
custom_decorator = [
d for d in main_func.decorator_list if d.func.id in ("procedure_decorator", "component_decorator")
]
if custom_decorator:
decorator = custom_decorator[0]
decorator_config = extract_decorator_options(decorator)
if custom_decorator:
decorator = custom_decorator[0]
decorator_config = extract_decorator_options(decorator)

# Extract parameters
params: dict[str, ParameterDC] = {}
Expand Down
2 changes: 1 addition & 1 deletion src/ada/visit/rendering/renderer_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def __init__(self, html_content: str, height: int = 500, unique_id: int = None,
html_inject_str += f"<script>window.WEBSOCKET_PORT = {self.ws_port};</script>"

# Inject the unique ID into the HTML content
self.html_content = html_content.replace("<!--WEBSOCKET_ID_PLACEHOLDER-->", html_inject_str)
self.html_content = html_content.replace("<!--STARTUP_CONFIG_PLACEHOLDER-->", html_inject_str)

# Escape and embed the HTML in the srcdoc of the iframe
srcdoc = html.escape(self.html_content)
Expand Down
Binary file modified src/ada/visit/rendering/resources/index.zip
Binary file not shown.
1 change: 1 addition & 0 deletions src/flatbuffers/schemas/commands.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum CommandType : byte {
SERVER_REPLY = 11,
VIEW_FILE_OBJECT = 12,
DELETE_FILE_OBJECT = 13,
START_NEW_NODE_EDITOR = 14,
}

enum TargetType : byte {
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import NodeEditorComponent from "./components/node_editor/NodeEditorComponent";

function App() {
const {isOptionsVisible} = useOptionsStore(); // use the useNavBarStore function
const {isNodeEditorVisible} = useNodeEditorStore();
const {isNodeEditorVisible, use_node_editor_only} = useNodeEditorStore();

return (
<div className={"relative flex flex-row h-full w-full bg-gray-900"}>
Expand All @@ -27,11 +27,11 @@ function App() {
</div>

<div className={"w-full h-full"}>
<CanvasComponent/>
{use_node_editor_only ? <NodeEditorComponent /> : <CanvasComponent/>}
</div>

{/* Only render NodeEditorComponent if it's visible */}
{isNodeEditorVisible && <NodeEditorComponent/>}
{isNodeEditorVisible && <NodeEditorComponent />}

{/* Only render NavBar if it's visible */}
{isOptionsVisible && (
Expand Down
Loading

0 comments on commit 99999c3

Please sign in to comment.