diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d037c14..49a47fc7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,7 @@ repos: types-requests==2.31.0, numpy==2.0.1, pytest==8.3.1, + websockets>=10.4, types-setuptools>=71.1.0.20240818 ] args: [--config=pyproject.toml] diff --git a/README.md b/README.md index a9d09ab6..8f3cec61 100644 --- a/README.md +++ b/README.md @@ -64,24 +64,59 @@ gantt title diffCheck - general overview excludes weekends - section Publication - Abstract edition :active, absed, 2024-03-01, 2024-03-15 - Submission abstract ICSA :milestone, icsaabs, 2024-03-15, 0d - Paper edition :paperd, 2024-10-01, 2024-10-30 - Submission paper ICSA :milestone, icsapap, 2024-10-30, 0d - - section Code development - Backend development :backenddev, after icsaabs, 6w - Rhino/Grasshopper integration :rhghinteg, after backenddev, 6w - Documentation & Interface :docuint, after fabar, 3w + section Workshop + Workshop dryrun :milestone, crit, dryrun, 2025-09-15, 1d + Workshop in Boston :workshop, 2025-11-16, 2d + + section Component development + Pose estimation :CD1, 2025-05-15, 1w + Communication w/ hardware :CD2, after CD1, 3w + Pose comparison :CD3, after CD1, 3w + General PC manipulation :CD4, after CD1, 6w + Data analysis component :CD5, after CD3, 3w + + section Workshop preparation + Workshop scenario :doc1, 2025-08-01, 1w + New compilation documentation :doc2, after mac, 2w + New components documentation :doc2, 2025-08-01, 4w + Development of special pipeline for data:doc3, after doc1, 3w + + section Cross-platform + adaptation of CMake for mac compilation :mac, 2025-07-01, 3w section Prototype testing - Fabrication of AR Prototype :crit, fabar, 2024-07-01, 2024-08-30 - Fabrication of CNC Prototype :crit, fabcnc, 2024-07-01, 2024-08-30 - Fabrication of Robot Prototype :crit, fabrob, 2024-07-01, 2024-08-30 - Data collection and evaluation :dataeval, after fabrob, 4w + Fabrication of iterative prototype :fab, 2025-08-01, 2w ``` + + ## How to contribute If you want to contribute to the project, please refer to the [contribution guidelines]([./CONTRIBUTING.md](https://diffcheckorg.github.io/diffCheck/contribute.html)). + +## Logic +The logic of the workflow is currently as follows: + +```mermaid +stateDiagram-v2 + state "[breps to assemble]" as s1 + state "scan of latest element placed" as s2 + state "get pose of i-th brep" as s3 + state "get pose of i-1-th brep" as s4 + state "compute pose of i-1-th element from scan" as s5 + state "compute pose difference" as s6 + state "compute pose correction" as s7 + state "assemble i-th-element" as s8 + state "i += 1" as s9 + [*]-->s2 + s1-->s3 + s1-->s4 + s2-->s5 + s5-->s6 + s4-->s6 + s6-->s7 + s3-->s7 + s7-->s8 + s8-->s9 + s9-->[*] +``` diff --git a/deps/eigen b/deps/eigen index 11fd34cc..81044ec1 160000 --- a/deps/eigen +++ b/deps/eigen @@ -1 +1 @@ -Subproject commit 11fd34cc1c398f2c2311339ed3b008b1114544eb +Subproject commit 81044ec13df7608d0d9d86aff2ef9805fc69bed1 diff --git a/deps/pybind11 b/deps/pybind11 index 708ce4d9..03d8f487 160000 --- a/deps/pybind11 +++ b/deps/pybind11 @@ -1 +1 @@ -Subproject commit 708ce4d9c7bf55075608eb3cfcb5fa0dc43e070f +Subproject commit 03d8f48750ba4486a2c9aeff82e9702109db5cb3 diff --git a/environment.yml b/environment.yml index a24851e4..193c4dcf 100644 Binary files a/environment.yml and b/environment.yml differ diff --git a/src/gh/components/DF_http_listener/code.py b/src/gh/components/DF_http_listener/code.py new file mode 100644 index 00000000..c35719ba --- /dev/null +++ b/src/gh/components/DF_http_listener/code.py @@ -0,0 +1,136 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import os +import tempfile +import requests +import threading +import Rhino +import Rhino.Geometry as rg +import scriptcontext as sc +from diffCheck import df_gh_canvas_utils + + +class DFHTTPListener(component): + + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + df_gh_canvas_utils.add_button(ghenv.Component, "Load", 0, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Ply_url", "https://github.com/diffCheckOrg/diffCheck/raw/refs/heads/main/tests/test_data/cube_mesh.ply", 1, 60, 20) # noqa: F821 + + def RunScript(self, + i_load: bool, + i_ply_url: str): + + prefix = 'http' + + # initialize sticky variables + sc.sticky.setdefault(f'{prefix}_ply_url', None) # last url processed + sc.sticky.setdefault(f'{prefix}_imported_geom', None) # last geo imported from ply + sc.sticky.setdefault(f'{prefix}_status_message', "Waiting..") # status message on component + sc.sticky.setdefault(f'{prefix}_prev_load', False) # previous state of toggle + sc.sticky.setdefault(f'{prefix}_thread_running', False) # is a background thread running? + + def _import_job(url: str) -> None: + + """ + Downloads and imports a .ply file from a given URL in a background thread. + Background job: + - Downloads the .ply file from the URL + - Imports it into the active Rhino document + - Extracts the new geometry (point cloud or mesh) + - Cleans up the temporary file and document objects + - Updates sticky state and status message + - Signals to GH that it should re-solve + + :param url: A string representing a direct URL to a .ply file (e.g. from GitHub or local server). + The file must end with ".ply". + :returns: None + """ + + tmp = None + try: + if not url.lower().endswith('.ply'): + raise ValueError("URL must end in .ply") + + resp = requests.get(url, timeout=30) + resp.raise_for_status() + # save om temporary file + fn = os.path.basename(url) + tmp = os.path.join(tempfile.gettempdir(), fn) + with open(tmp, 'wb') as f: + f.write(resp.content) + + doc = Rhino.RhinoDoc.ActiveDoc + # recordd existing object IDs to detect new ones + before_ids = {o.Id for o in doc.Objects} + + # import PLY using Rhino's API + opts = Rhino.FileIO.FilePlyReadOptions() + ok = Rhino.FileIO.FilePly.Read(tmp, doc, opts) + if not ok: + raise RuntimeError("Rhino.FilePly.Read failed") + + after_ids = {o.Id for o in doc.Objects} + new_ids = after_ids - before_ids + # get new pcd or mesh from document + geom = None + for guid in new_ids: + g = doc.Objects.FindId(guid).Geometry + if isinstance(g, rg.PointCloud): + geom = g.Duplicate() + break + elif isinstance(g, rg.Mesh): + geom = g.DuplicateMesh() + break + # remove imported objects + for guid in new_ids: + doc.Objects.Delete(guid, True) + doc.Views.Redraw() + + # store new geometry + sc.sticky[f'{prefix}_imported_geom'] = geom + count = geom.Count if isinstance(geom, rg.PointCloud) else geom.Vertices.Count + if isinstance(geom, rg.PointCloud): + sc.sticky[f'{prefix}_status_message'] = f"Loaded pcd with {count} pts" + else: + sc.sticky[f'{prefix}_status_message'] = f"Loaded mesh wih {count} vertices" + ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message') # noqa: F821 + + except Exception as e: + sc.sticky[f'{prefix}_imported_geom'] = None + sc.sticky[f'{prefix}_status_message'] = f"Error: {e}" + finally: + try: + os.remove(tmp) + except Exception: + pass + # mark thread as finished + sc.sticky[f'{prefix}_thread_running'] = False + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # check if the URL input has changed + if sc.sticky[f'{prefix}_ply_url'] != i_ply_url: + sc.sticky[f'{prefix}_ply_url'] = i_ply_url + sc.sticky[f'{prefix}_status_message'] = "URL changed. Press Load" + sc.sticky[f'{prefix}_thread_running'] = False + sc.sticky[f'{prefix}_prev_load'] = False + + # start importing if Load toggle is pressed and import thread is not already running + if i_load and not sc.sticky[f'{prefix}_prev_load'] and not sc.sticky[f'{prefix}_thread_running']: + sc.sticky[f'{prefix}_status_message'] = "Loading..." + sc.sticky[f'{prefix}_thread_running'] = True + threading.Thread(target=_import_job, args=(i_ply_url,), daemon=True).start() + + sc.sticky[f'{prefix}_prev_load'] = i_load + ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message', "") # noqa: F821 + + # output + o_geometry = sc.sticky.get(f'{prefix}_imported_geom') + + return [o_geometry] diff --git a/src/gh/components/DF_http_listener/icon.png b/src/gh/components/DF_http_listener/icon.png new file mode 100644 index 00000000..44df06fe Binary files /dev/null and b/src/gh/components/DF_http_listener/icon.png differ diff --git a/src/gh/components/DF_http_listener/metadata.json b/src/gh/components/DF_http_listener/metadata.json new file mode 100644 index 00000000..e029ea3f --- /dev/null +++ b/src/gh/components/DF_http_listener/metadata.json @@ -0,0 +1,52 @@ +{ + "name": "DFHTTPListener", + "nickname": "HTTPIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component reads a ply file from the internet.", + "exposure": 4, + "instanceGuid": "ca4b5c94-6c85-4bc5-87f0-132cc34c4536", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to import ply from url.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_ply_url", + "nickname": "i_ply_url", + "description": "The url where to get the pointcloud", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + } + ], + "outputParameters": [ + { + "name": "o_geometry", + "nickname": "o_geo", + "description": "The mesh or pcd that was imported.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_tcp_listener/code.py b/src/gh/components/DF_tcp_listener/code.py new file mode 100644 index 00000000..8ff40dfc --- /dev/null +++ b/src/gh/components/DF_tcp_listener/code.py @@ -0,0 +1,157 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import socket +import threading +import json +import time +import scriptcontext as sc +import Rhino.Geometry as rg +import System.Drawing as sd +from diffCheck import df_gh_canvas_utils + +class DFTCPListener(component): + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + for idx, label in enumerate(("Start", "Stop", "Load")): + df_gh_canvas_utils.add_button( + ghenv.Component, label, idx, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "5000", 4, 60, 20) # noqa: F821 + + def RunScript(self, + i_start: bool, + i_stop: bool, + i_load: bool, + i_host: str, + i_port: int): + + prefix = 'tcp' + + # Sticky initialization + sc.sticky.setdefault(f'{prefix}_server_sock', None) + sc.sticky.setdefault(f'{prefix}_server_started', False) + sc.sticky.setdefault(f'{prefix}_cloud_buffer_raw', []) + sc.sticky.setdefault(f'{prefix}_latest_cloud', None) + sc.sticky.setdefault(f'{prefix}_status_message', 'Waiting..') + sc.sticky.setdefault(f'{prefix}_prev_start', False) + sc.sticky.setdefault(f'{prefix}_prev_stop', False) + sc.sticky.setdefault(f'{prefix}_prev_load', False) + + # Client handler + def handle_client(conn: socket.socket) -> None: + """ + Reads the incoming bytes from a single TCP client socket and stores valid data in a shared buffer. + + :param conn: A socket object returned by `accept()` representing a live client connection. + The client is expected to send newline-delimited JSON-encoded data, where each + message is a list of 6D values: [x, y, z, r, g, b]. + + :returns: None + """ + buf = b'' + with conn: + while sc.sticky.get(f'{prefix}_server_started', False): + try: + chunk = conn.recv(4096) + if not chunk: + break + buf += chunk + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + try: + raw = json.loads(line.decode()) + except Exception: + continue + if isinstance(raw, list) and all(isinstance(pt, list) and len(pt) == 6 for pt in raw): + sc.sticky[f'{prefix}_cloud_buffer_raw'] = raw + except Exception: + break + time.sleep(0.05) # sleep briefly to prevent CPU spin + + # thread to accept incoming connections + def server_loop(sock: socket.socket) -> None: + """ + Accepts a single client connection and starts a background thread to handle it. + + :param sock: A bound and listening TCP socket created by start_server(). + This socket will accept one incoming connection, then delegate it to handle_client(). + + :returns: None. This runs as a background thread and blocks on accept(). + """ + try: + conn, _ = sock.accept() + handle_client(conn) + except Exception: + pass + + # Start TCP server + def start_server() -> None: + """ + creates and binds a TCP socket on the given host/port, marks the server as started and then starts the accept_loop in a background thread + + :returns: None. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((i_host, i_port)) + sock.listen(1) + sc.sticky[f'{prefix}_server_sock'] = sock + sc.sticky[f'{prefix}_server_started'] = True + sc.sticky[f'{prefix}_status_message'] = f'Listening on {i_host}:{i_port}' + # Only accept one connection to keep it long-lived + threading.Thread(target=server_loop, args=(sock,), daemon=True).start() + + def stop_server() -> None: + """ + Stops the running TCP server by closing the listening socket and resetting internal state. + + :returns: None. + """ + sock = sc.sticky.get(f'{prefix}_server_sock') + if sock: + try: + sock.close() + except Exception: + pass + sc.sticky[f'{prefix}_server_sock'] = None + sc.sticky[f'{prefix}_server_started'] = False + sc.sticky[f'{prefix}_cloud_buffer_raw'] = [] + sc.sticky[f'{prefix}_status_message'] = 'Stopped' + + # Start or stop server based on inputs + if i_start and not sc.sticky[f'{prefix}_prev_start']: + start_server() + if i_stop and not sc.sticky[f'{prefix}_prev_stop']: + stop_server() + + # Load buffered points into Rhino PointCloud + if i_load and not sc.sticky[f'{prefix}_prev_load']: + if not sc.sticky.get(f'{prefix}_server_started', False): + sc.sticky[f'{prefix}_status_message'] = "Start Server First!" + else: + raw = sc.sticky.get(f'{prefix}_cloud_buffer_raw', []) + if raw: + pc = rg.PointCloud() + for x, y, z, r, g, b in raw: + pc.Add(rg.Point3d(x, y, z), sd.Color.FromArgb(int(r), int(g), int(b))) + sc.sticky[f'{prefix}_latest_cloud'] = pc + sc.sticky[f'{prefix}_status_message'] = f'Loaded pcd with {pc.Count} pts' + else: + sc.sticky[f'{prefix}_status_message'] = 'No data buffered' + + # Update previous states + sc.sticky[f'{prefix}_prev_start'] = i_start + sc.sticky[f'{prefix}_prev_stop'] = i_stop + sc.sticky[f'{prefix}_prev_load'] = i_load + + # Update UI and output + ghenv.Component.Message = sc.sticky[f'{prefix}_status_message'] # noqa: F821 + + o_cloud = sc.sticky[f'{prefix}_latest_cloud'] + return [o_cloud] diff --git a/src/gh/components/DF_tcp_listener/icon.png b/src/gh/components/DF_tcp_listener/icon.png new file mode 100644 index 00000000..f8251581 Binary files /dev/null and b/src/gh/components/DF_tcp_listener/icon.png differ diff --git a/src/gh/components/DF_tcp_listener/metadata.json b/src/gh/components/DF_tcp_listener/metadata.json new file mode 100644 index 00000000..0b13cd90 --- /dev/null +++ b/src/gh/components/DF_tcp_listener/metadata.json @@ -0,0 +1,88 @@ +{ + "name": "DFTCPListener", + "nickname": "TCPIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component get point cloud data from a tcp sender", + "exposure": 4, + "instanceGuid": "61a9cc27-864d-4892-bd39-5d97dbccbefb", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_start", + "nickname": "i_start", + "description": "Button to start the TCP server", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_stop", + "nickname": "i_stop", + "description": "Button to stop the server and release the port", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to get the latest PCD from the buffer", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_host", + "nickname": "i_host", + "description": "The host to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_port", + "nickname": "i_port", + "description": "The port to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "int" + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The Rhino pcd that was received.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_visualization_settings/code.py b/src/gh/components/DF_visualization_settings/code.py index 565f562c..61c2cb98 100644 --- a/src/gh/components/DF_visualization_settings/code.py +++ b/src/gh/components/DF_visualization_settings/code.py @@ -1,124 +1,11 @@ #! python3 -import System -import typing import Rhino from ghpythonlib.componentbase import executingcomponent as component -import Grasshopper as gh -from Grasshopper import Instances from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML from diffCheck import df_visualization - - -def add_str_valuelist(self, - values_list: typing.List[str], - nickname: str, - indx: int, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=87 - ) -> None: - """ - Adds a value list of string values to the component input - - :param values_list: a list of string values to add to the value list - :param nickname: the nickname of the value list - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the value list from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - valuelist = gh.Kernel.Special.GH_ValueList() - valuelist.NickName = nickname - valuelist.Description = "Select the value to use with DFVizSettings" - selected = valuelist.FirstSelectedItem - valuelist.ListItems.Clear() - for v in values_list: - vli = gh.Kernel.Special.GH_ValueListItem(str(v),str('"' + v + '"')) - valuelist.ListItems.Add(vli) - if selected in values_list: - valuelist.SelectItem(values_list.index(selected)) - valuelist.CreateAttributes() - valuelist.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (valuelist.Attributes.Bounds.Width) - X_offset, - Y_param_coord - (valuelist.Attributes.Bounds.Height / 2 + 0.1) - ) - valuelist.Attributes.ExpireLayout() - gh.Instances.ActiveCanvas.Document.AddObject(valuelist, False) - ghenv.Component.Params.Input[indx].AddSource(valuelist) # noqa: F821 - -def add_slider(self, - nickname: str, - indx: int, - lower_bound: float, - upper_bound: float, - default_value: float, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=100 - ) -> None: - """ - Adds a slider to the component input - - :param nickname: the nickname of the slider - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the slider from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - slider = gh.Kernel.Special.GH_NumberSlider() - slider.NickName = nickname - slider.Description = "Set the value for the threshold" - slider.Slider.Minimum = System.Decimal(lower_bound) - slider.Slider.Maximum = System.Decimal(upper_bound) - slider.Slider.DecimalPlaces = 3 - slider.Slider.SmallChange = System.Decimal(0.001) - slider.Slider.LargeChange = System.Decimal(0.01) - slider.Slider.Value = System.Decimal(default_value) - slider.CreateAttributes() - slider.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (slider.Attributes.Bounds.Width) - X_offset, - Y_param_coord - (slider.Attributes.Bounds.Height / 2 - 0.1) - ) - slider.Attributes.ExpireLayout() - gh.Instances.ActiveCanvas.Document.AddObject(slider, False) - ghenv.Component.Params.Input[indx].AddSource(slider) # noqa: F821 - -def add_plane_object(self, - nickname: str, - indx: int, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=75 - ) -> None: - """ - Adds a plane object to the component input - - :param nickname: the nickname of the plane object - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the plane object from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - doc = Instances.ActiveCanvas.Document - if doc: - plane = gh.Kernel.Parameters.Param_Plane() - plane.NickName = nickname - plane.CreateAttributes() - plane.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (plane.Attributes.Bounds.Width) - X_offset, - Y_param_coord - ) - plane.Attributes.ExpireLayout() - doc.AddObject(plane, False) - ghenv.Component.Params.Input[indx].AddSource(plane) # noqa: F821 +from diffCheck import df_gh_canvas_utils class DFVisualizationSettings(component): @@ -129,43 +16,44 @@ def __init__(self): ghenv.Component.ExpireSolution(True) # noqa: F821 ghenv.Component.Attributes.PerformLayout() # noqa: F821 params = getattr(ghenv.Component.Params, "Input") # noqa: F821 + for j in range(len(params)): Y_cord = params[j].Attributes.InputGrip.Y X_cord = params[j].Attributes.Pivot.X input_indx = j if "i_value_type" == params[j].NickName: - add_str_valuelist( + df_gh_canvas_utils.add_str_valuelist( ghenv.Component, # noqa: F821 self.poss_value_types, "DF_value_t", input_indx, X_cord, Y_cord) if "i_palette" == params[j].NickName: - add_str_valuelist( + df_gh_canvas_utils.add_str_valuelist( ghenv.Component, # noqa: F821 self.poss_palettes, "DF_palette", input_indx, X_cord, Y_cord) if "i_legend_height" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_legend_height", input_indx, 0.000, 20.000, 10.000, X_cord, Y_cord) if "i_legend_width" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_legend_width", input_indx, 0.000, 2.000, 0.500, X_cord, Y_cord) if "i_legend_plane" == params[j].NickName: - add_plane_object( + df_gh_canvas_utils.add_plane_object( ghenv.Component, # noqa: F821 "DF_legend_plane", input_indx, X_cord, Y_cord) if "i_histogram_scale_factor" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_histogram_scale_factor", input_indx, diff --git a/src/gh/components/DF_websocket_listener/code.py b/src/gh/components/DF_websocket_listener/code.py new file mode 100644 index 00000000..09b66696 --- /dev/null +++ b/src/gh/components/DF_websocket_listener/code.py @@ -0,0 +1,156 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import threading +import asyncio +import json +import scriptcontext as sc +import Rhino.Geometry as rg +import System.Drawing as sd +from websockets.server import serve +from diffCheck import df_gh_canvas_utils + +class DFWSServerListener(component): + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + for idx, label in enumerate(("Start", "Stop", "Load")): + df_gh_canvas_utils.add_button( + ghenv.Component, label, idx, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "9000", 4, 60, 20) # noqa: F821 + + def RunScript(self, + i_start: bool, + i_stop: bool, + i_load: bool, + i_host: str, + i_port: int): + + prefix = 'ws' + + # Persistent state across runs + sc.sticky.setdefault(f'{prefix}_server', None) + sc.sticky.setdefault(f'{prefix}_loop', None) + sc.sticky.setdefault(f'{prefix}_thread', None) + sc.sticky.setdefault(f'{prefix}_last_pcd', None) + sc.sticky.setdefault(f'{prefix}_loaded_pcd', None) + sc.sticky.setdefault(f'{prefix}_logs', []) + sc.sticky.setdefault(f'{prefix}_thread_started', False) + sc.sticky.setdefault(f'{prefix}_prev_start', False) + sc.sticky.setdefault(f'{prefix}_prev_stop', False) + sc.sticky.setdefault(f'{prefix}_prev_load', False) + + logs = sc.sticky[f'{prefix}_logs'] + + # STOP server + if i_stop and sc.sticky.pop(f'{prefix}_thread_started', False): + server = sc.sticky.pop(f'{prefix}_server', None) + loop = sc.sticky.pop(f'{prefix}_loop', None) + if server and loop: + try: + server.close() + asyncio.run_coroutine_threadsafe(server.wait_closed(), loop) + logs.append("WebSocket server close initiated") + except Exception as e: + logs.append(f"Error closing server: {e}") + sc.sticky[f'{prefix}_thread'] = None + logs.append("Cleared previous WebSocket server flag") + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # START server + if i_start and not sc.sticky[f'{prefix}_thread_started']: + + async def echo(ws, path: str) -> None: + """ + Handles a single WebSocket client connection and reads messages containing point cloud data. + + :param ws: A WebSocket connection object from the 'websockets' server, representing a live client. + :param path: The URL path for the connection (unused here but required by the API). + + :returns: None. Updates sc.sticky['ws_last_pcd'] with the most recent valid list of points. + Each message is expected to be a JSON list of 6-element lists: + [x, y, z, r, g, b] for each point. + """ + logs.append("[GH] Client connected") + try: + async for msg in ws: + try: + pcd = json.loads(msg) + if isinstance(pcd, list) and all(isinstance(pt, (list, tuple)) and len(pt) == 6 for pt in pcd): + sc.sticky[f'{prefix}_last_pcd'] = pcd + logs.append(f"Received PCD with {len(pcd)} points") + else: + logs.append("Invalid PCD format") + except Exception as inner: + logs.append(f"PCD parse error: {inner}") + except Exception as outer: + logs.append(f"Handler crashed: {outer}") + + async def server_coro() -> None: + """ + Coroutine that starts the WebSocket server and waits for it to be closed. + + :returns: None. Stores the server object in sc.sticky['ws_server'] and the event loop + in sc.sticky['ws_loop']. Also logs progress to sc.sticky['ws_logs']. + """ + loop = asyncio.get_running_loop() + sc.sticky[f'{prefix}_loop'] = loop + + logs.append(f"server_coro starting on {i_host}:{i_port}") + server = await serve(echo, i_host, i_port) + sc.sticky[f'{prefix}_server'] = server + logs.append(f"Listening on ws://{i_host}:{i_port}") + await server.wait_closed() + logs.append("Server coroutine exited") + + def run_server() -> None: + """ + Blocking function that runs the WebSocket server coroutine in this thread. + + :returns: None. Used as the target for a background thread. Logs errors if server startup fails. + """ + try: + asyncio.run(server_coro()) + except Exception as ex: + logs.append(f"WebSocket server ERROR: {ex}") + + t = threading.Thread(target=run_server, daemon=True) + t.start() + sc.sticky[f'{prefix}_thread'] = t + sc.sticky[f'{prefix}_thread_started'] = True + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # LOAD buffered PCD on i_load rising edge + if i_load and not sc.sticky[f'{prefix}_prev_load']: + if not sc.sticky.get(f'{prefix}_server'): + logs.append("Start Server First!") + else: + sc.sticky[f'{prefix}_loaded_pcd'] = sc.sticky.get(f'{prefix}_last_pcd') + cnt = len(sc.sticky[f'{prefix}_loaded_pcd']) if sc.sticky[f'{prefix}_loaded_pcd'] else 0 + logs.append(f"Loaded pcd with {cnt} pts") + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # BUILD output PointCloud + raw = sc.sticky.get(f'{prefix}_loaded_pcd') + if isinstance(raw, list) and all(isinstance(pt, (list, tuple)) and len(pt) == 6 for pt in raw): + pc = rg.PointCloud() + for x, y, z, r, g, b in raw: + pt = rg.Point3d(x, y, z) + col = sd.Color.FromArgb(r, g, b) + pc.Add(pt, col) + o_cloud = pc + else: + o_cloud = None + + # UPDATE UI message & return outputs + ghenv.Component.Message = logs[-1] if logs else 'Waiting..' # noqa: F821 + sc.sticky[f'{prefix}_prev_start'] = i_start + sc.sticky[f'{prefix}_prev_stop'] = i_stop + sc.sticky[f'{prefix}_prev_load'] = i_load + + return [o_cloud] diff --git a/src/gh/components/DF_websocket_listener/icon.png b/src/gh/components/DF_websocket_listener/icon.png new file mode 100644 index 00000000..8a2268ef Binary files /dev/null and b/src/gh/components/DF_websocket_listener/icon.png differ diff --git a/src/gh/components/DF_websocket_listener/metadata.json b/src/gh/components/DF_websocket_listener/metadata.json new file mode 100644 index 00000000..ce4707e7 --- /dev/null +++ b/src/gh/components/DF_websocket_listener/metadata.json @@ -0,0 +1,88 @@ +{ + "name": "DFWSListener", + "nickname": "WSIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component receives a pcd via websocket connection.", + "exposure": 4, + "instanceGuid": "4e87cc43-8f9f-4f8f-a63a-49f76229db3e", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_start", + "nickname": "i_start", + "description": "Button to start the TCP server", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_stop", + "nickname": "i_stop", + "description": "Stop the server and release the port", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to get the latest PCD from the buffer", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_host", + "nickname": "i_host", + "description": "The host for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_port", + "nickname": "i_port", + "description": "The port to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "int" + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The pcd that was received.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO b/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO index f88b43ec..78a74a86 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO +++ b/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO @@ -1,4 +1,4 @@ -Metadata-Version: 2.4 +Metadata-Version: 2.1 Name: diffCheck Version: 1.3.0 Summary: DiffCheck is a package to check the differences between two timber structures @@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9 Description-Content-Type: text/markdown Requires-Dist: numpy Requires-Dist: pybind11>=2.5.0 +Requires-Dist: websockets>=10.4 Dynamic: author Dynamic: author-email Dynamic: classifier diff --git a/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt b/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt index 8887cb03..e64d5fc2 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt +++ b/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt @@ -9,7 +9,7 @@ diffCheck/df_joint_detector.py diffCheck/df_transformations.py diffCheck/df_util.py diffCheck/df_visualization.py -diffCheck/diffcheck_bindings.cp312-win_amd64.pyd +diffCheck/diffcheck_bindings.cp39-win_amd64.pyd diffCheck.egg-info/PKG-INFO diffCheck.egg-info/SOURCES.txt diffCheck.egg-info/dependency_links.txt diff --git a/src/gh/diffCheck/diffCheck.egg-info/requires.txt b/src/gh/diffCheck/diffCheck.egg-info/requires.txt index b2195e0b..15579520 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/requires.txt +++ b/src/gh/diffCheck/diffCheck.egg-info/requires.txt @@ -1,2 +1,3 @@ numpy pybind11>=2.5.0 +websockets>=10.4 diff --git a/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py b/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py new file mode 100644 index 00000000..9a46bcd3 --- /dev/null +++ b/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py @@ -0,0 +1,202 @@ +from Grasshopper import Instances +import Grasshopper as gh +import System.Drawing as sd +import System +import typing + + +def add_str_valuelist(comp, + values_list: typing.List[str], + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 87 + ) -> None: + """ + Adds a value list of string values to the component input + + :param values_list: a list of string values to add to the value list + :param nickname: the nickname of the value list + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the value list from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + valuelist = gh.Kernel.Special.GH_ValueList() + valuelist.NickName = nickname + valuelist.Description = "Select the value to use with DFVizSettings" + selected = valuelist.FirstSelectedItem + valuelist.ListItems.Clear() + for v in values_list: + vli = gh.Kernel.Special.GH_ValueListItem(str(v), str('"' + v + '"')) + valuelist.ListItems.Add(vli) + if selected in values_list: + valuelist.SelectItem(values_list.index(selected)) + valuelist.CreateAttributes() + valuelist.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (valuelist.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (valuelist.Attributes.Bounds.Height / 2 + 0.1) + ) + valuelist.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(valuelist, False) + inp.AddSource(valuelist) # noqa: F821 + + +def add_slider(comp, + nickname: str, + indx: int, + lower_bound: float, + upper_bound: float, + default_value: float, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 100 + ) -> None: + """ + Adds a slider to the component input + + :param nickname: the nickname of the slider + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the slider from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + slider = gh.Kernel.Special.GH_NumberSlider() + slider.NickName = nickname + slider.Description = "Set the value for the threshold" + slider.Slider.Minimum = System.Decimal(lower_bound) + slider.Slider.Maximum = System.Decimal(upper_bound) + slider.Slider.DecimalPlaces = 3 + slider.Slider.SmallChange = System.Decimal(0.001) + slider.Slider.LargeChange = System.Decimal(0.01) + slider.Slider.Value = System.Decimal(default_value) + slider.CreateAttributes() + slider.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (slider.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (slider.Attributes.Bounds.Height / 2 - 0.1) + ) + slider.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(slider, False) + inp.AddSource(slider) # noqa: F821 + + +def add_plane_object(comp, + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 75 + ) -> None: + """ + Adds a plane object to the component input + + :param nickname: the nickname of the plane object + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the plane object from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + doc = Instances.ActiveCanvas.Document + if doc: + plane = gh.Kernel.Parameters.Param_Plane() + plane.NickName = nickname + plane.CreateAttributes() + plane.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (plane.Attributes.Bounds.Width) - X_offset, + Y_param_coord + ) + plane.Attributes.ExpireLayout() + doc.AddObject(plane, False) + inp.AddSource(plane) # noqa: F821 + + +def add_button(comp, + nickname: str, + indx: int, + x_offset: int = 60 + ) -> None: + """ + Adds a one-shot Boolean button to the left of a component input. + + :param comp: The Grasshopper component to which the button will be added. + :param nickname: The display label of the button (e.g. "Start", "Load"). + :param indx: The index of the component input to wire the button into. + :param x_offset: Horizontal distance (in pixels) to place the button to the left of the input. + """ + + inp = comp.Params.Input[indx] + # only add if nothing already connected + if inp.SourceCount == 0: + # create the one-shot button + btn = gh.Kernel.Special.GH_ButtonObject() + btn.NickName = nickname + btn.Value = False # always starts False + # build its UI attributes so we can measure size & position + btn.CreateAttributes() + + # compute pivot: left of the input grip + grip = inp.Attributes.InputGrip + # X = input pivot X, Y = grip Y + pivot_x = grip.X - btn.Attributes.Bounds.Width - x_offset + pivot_y = grip.Y - btn.Attributes.Bounds.Height/2 + btn.Attributes.Pivot = sd.PointF(pivot_x, pivot_y) + btn.Attributes.ExpireLayout() + + # drop it onto the canvas (non-grouped) + Instances.ActiveCanvas.Document.AddObject(btn, False) + # wire it into the component + inp.AddSource(btn) + + +def add_panel(comp, + nickname: str, + text: str, + indx: int, + x_offset: int = 60, + panel_height: int = 20 + ) -> None: + """ + Adds a text panel to the left of a component input with a default string value. + + :param comp: The Grasshopper component to which the panel will be added. + :param nickname: The label shown at the top of the panel (e.g. "Host", "Port"). + :param text: The default string to display inside the panel. + :param indx: The index of the component input to connect the panel to. + :param x_offset: Horizontal distance (in pixels) to place the panel left of the input. + :param panel_height: Height of the panel in pixels (default is 20). + + :returns: None. The panel is created, positioned, and connected if no existing source is present. + """ + + inp = comp.Params.Input[indx] + if inp.SourceCount == 0: + panel = gh.Kernel.Special.GH_Panel() + # Set the panel's displayed text + panel.UserText = text + panel.NickName = nickname + panel.CreateAttributes() + + # adjust height while preserving width + bounds = panel.Attributes.Bounds + panel.Attributes.Bounds = System.Drawing.RectangleF( + bounds.X, + bounds.Y, + bounds.Width, + panel_height + ) + + # Position left of input grip + grip = inp.Attributes.InputGrip + px = grip.X - panel.Attributes.Bounds.Width - x_offset + py = grip.Y - panel.Attributes.Bounds.Height / 2 + panel.Attributes.Pivot = sd.PointF(px, py) + panel.Attributes.ExpireLayout() + Instances.ActiveCanvas.Document.AddObject(panel, False) + inp.AddSource(panel) diff --git a/src/gh/diffCheck/setup.py b/src/gh/diffCheck/setup.py index 181bbb0c..bbb48856 100644 --- a/src/gh/diffCheck/setup.py +++ b/src/gh/diffCheck/setup.py @@ -8,7 +8,8 @@ packages=find_packages(), install_requires=[ "numpy", - "pybind11>=2.5.0" + "pybind11>=2.5.0", + "websockets>=10.4" # other dependencies... ], description="DiffCheck is a package to check the differences between two timber structures", diff --git a/src/gh/examples/simple_tcp_sender.py b/src/gh/examples/simple_tcp_sender.py new file mode 100644 index 00000000..348d96aa --- /dev/null +++ b/src/gh/examples/simple_tcp_sender.py @@ -0,0 +1,23 @@ +import socket +import time +import random +import json + +host = '127.0.0.1' +port = 5000 + + +def random_colored_point(): + x, y, z = [round(random.uniform(-10, 10), 2) for _ in range(3)] + r, g, b = [random.randint(0, 255) for _ in range(3)] + return [x, y, z, r, g, b] + + +with socket.create_connection((host, port)) as s: + print("Connected to GH") + while True: + cloud = [random_colored_point() for _ in range(1000)] + msg = json.dumps(cloud) + "\n" + s.sendall(msg.encode()) + print("Sent cloud with", len(cloud), "colored points") + time.sleep(1) diff --git a/src/gh/examples/simple_ws_sender.py b/src/gh/examples/simple_ws_sender.py new file mode 100644 index 00000000..edf1cb40 --- /dev/null +++ b/src/gh/examples/simple_ws_sender.py @@ -0,0 +1,31 @@ +import asyncio +import websockets +import random +import json + + +def random_colored_point(): + x, y, z = [round(random.uniform(-10, 10), 2) for _ in range(3)] + r, g, b = [random.randint(0, 255) for _ in range(3)] + return [x, y, z, r, g, b] + + +async def send_pointcloud(host="127.0.0.1", port=9000): + uri = f"ws://{host}:{port}" + print(f"Connecting to {uri}…") + try: + async with websockets.connect(uri) as ws: + counter = 0 + while True: + counter += 1 + # generate and send 1 000 random points + pcd = [random_colored_point() for _ in range(1000)] + await ws.send(json.dumps(pcd)) + print(f"[{counter}] Sent PCD with {len(pcd)} points") + await asyncio.sleep(5) + + except Exception as e: + print(f"Connection error: {e}") + +if __name__ == "__main__": + asyncio.run(send_pointcloud(host="127.0.0.1", port=9000))