Skip to content

Commit c65b4ae

Browse files
Merge pull request #152 from diffCheckOrg/IO
receivers for http/tcp/web socket communication
2 parents 9964b85 + 4d287f2 commit c65b4ae

File tree

21 files changed

+952
-125
lines changed

21 files changed

+952
-125
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ repos:
3434
types-requests==2.31.0,
3535
numpy==2.0.1,
3636
pytest==8.3.1,
37+
websockets>=10.4,
3738
types-setuptools>=71.1.0.20240818
3839
]
3940
args: [--config=pyproject.toml]

deps/eigen

Submodule eigen updated from 11fd34c to 81044ec

deps/pybind11

Submodule pybind11 updated 144 files

environment.yml

48 Bytes
Binary file not shown.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#! python3
2+
3+
from ghpythonlib.componentbase import executingcomponent as component
4+
import os
5+
import tempfile
6+
import requests
7+
import threading
8+
import Rhino
9+
import Rhino.Geometry as rg
10+
import scriptcontext as sc
11+
from diffCheck import df_gh_canvas_utils
12+
13+
14+
class DFHTTPListener(component):
15+
16+
def __init__(self):
17+
try:
18+
ghenv.Component.ExpireSolution(True) # noqa: F821
19+
ghenv.Component.Attributes.PerformLayout() # noqa: F821
20+
except NameError:
21+
pass
22+
23+
df_gh_canvas_utils.add_button(ghenv.Component, "Load", 0, x_offset=60) # noqa: F821
24+
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
25+
26+
def RunScript(self,
27+
i_load: bool,
28+
i_ply_url: str):
29+
30+
prefix = 'http'
31+
32+
# initialize sticky variables
33+
sc.sticky.setdefault(f'{prefix}_ply_url', None) # last url processed
34+
sc.sticky.setdefault(f'{prefix}_imported_geom', None) # last geo imported from ply
35+
sc.sticky.setdefault(f'{prefix}_status_message', "Waiting..") # status message on component
36+
sc.sticky.setdefault(f'{prefix}_prev_load', False) # previous state of toggle
37+
sc.sticky.setdefault(f'{prefix}_thread_running', False) # is a background thread running?
38+
39+
def _import_job(url: str) -> None:
40+
41+
"""
42+
Downloads and imports a .ply file from a given URL in a background thread.
43+
Background job:
44+
- Downloads the .ply file from the URL
45+
- Imports it into the active Rhino document
46+
- Extracts the new geometry (point cloud or mesh)
47+
- Cleans up the temporary file and document objects
48+
- Updates sticky state and status message
49+
- Signals to GH that it should re-solve
50+
51+
:param url: A string representing a direct URL to a .ply file (e.g. from GitHub or local server).
52+
The file must end with ".ply".
53+
:returns: None
54+
"""
55+
56+
tmp = None
57+
try:
58+
if not url.lower().endswith('.ply'):
59+
raise ValueError("URL must end in .ply")
60+
61+
resp = requests.get(url, timeout=30)
62+
resp.raise_for_status()
63+
# save om temporary file
64+
fn = os.path.basename(url)
65+
tmp = os.path.join(tempfile.gettempdir(), fn)
66+
with open(tmp, 'wb') as f:
67+
f.write(resp.content)
68+
69+
doc = Rhino.RhinoDoc.ActiveDoc
70+
# recordd existing object IDs to detect new ones
71+
before_ids = {o.Id for o in doc.Objects}
72+
73+
# import PLY using Rhino's API
74+
opts = Rhino.FileIO.FilePlyReadOptions()
75+
ok = Rhino.FileIO.FilePly.Read(tmp, doc, opts)
76+
if not ok:
77+
raise RuntimeError("Rhino.FilePly.Read failed")
78+
79+
after_ids = {o.Id for o in doc.Objects}
80+
new_ids = after_ids - before_ids
81+
# get new pcd or mesh from document
82+
geom = None
83+
for guid in new_ids:
84+
g = doc.Objects.FindId(guid).Geometry
85+
if isinstance(g, rg.PointCloud):
86+
geom = g.Duplicate()
87+
break
88+
elif isinstance(g, rg.Mesh):
89+
geom = g.DuplicateMesh()
90+
break
91+
# remove imported objects
92+
for guid in new_ids:
93+
doc.Objects.Delete(guid, True)
94+
doc.Views.Redraw()
95+
96+
# store new geometry
97+
sc.sticky[f'{prefix}_imported_geom'] = geom
98+
count = geom.Count if isinstance(geom, rg.PointCloud) else geom.Vertices.Count
99+
if isinstance(geom, rg.PointCloud):
100+
sc.sticky[f'{prefix}_status_message'] = f"Loaded pcd with {count} pts"
101+
else:
102+
sc.sticky[f'{prefix}_status_message'] = f"Loaded mesh wih {count} vertices"
103+
ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message') # noqa: F821
104+
105+
except Exception as e:
106+
sc.sticky[f'{prefix}_imported_geom'] = None
107+
sc.sticky[f'{prefix}_status_message'] = f"Error: {e}"
108+
finally:
109+
try:
110+
os.remove(tmp)
111+
except Exception:
112+
pass
113+
# mark thread as finished
114+
sc.sticky[f'{prefix}_thread_running'] = False
115+
ghenv.Component.ExpireSolution(True) # noqa: F821
116+
117+
# check if the URL input has changed
118+
if sc.sticky[f'{prefix}_ply_url'] != i_ply_url:
119+
sc.sticky[f'{prefix}_ply_url'] = i_ply_url
120+
sc.sticky[f'{prefix}_status_message'] = "URL changed. Press Load"
121+
sc.sticky[f'{prefix}_thread_running'] = False
122+
sc.sticky[f'{prefix}_prev_load'] = False
123+
124+
# start importing if Load toggle is pressed and import thread is not already running
125+
if i_load and not sc.sticky[f'{prefix}_prev_load'] and not sc.sticky[f'{prefix}_thread_running']:
126+
sc.sticky[f'{prefix}_status_message'] = "Loading..."
127+
sc.sticky[f'{prefix}_thread_running'] = True
128+
threading.Thread(target=_import_job, args=(i_ply_url,), daemon=True).start()
129+
130+
sc.sticky[f'{prefix}_prev_load'] = i_load
131+
ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message', "") # noqa: F821
132+
133+
# output
134+
o_geometry = sc.sticky.get(f'{prefix}_imported_geom')
135+
136+
return [o_geometry]
4.24 KB
Loading
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "DFHTTPListener",
3+
"nickname": "HTTPIn",
4+
"category": "diffCheck",
5+
"subcategory": "IO",
6+
"description": "This component reads a ply file from the internet.",
7+
"exposure": 4,
8+
"instanceGuid": "ca4b5c94-6c85-4bc5-87f0-132cc34c4536",
9+
"ghpython": {
10+
"hideOutput": true,
11+
"hideInput": true,
12+
"isAdvancedMode": true,
13+
"marshalOutGuids": true,
14+
"iconDisplay": 2,
15+
"inputParameters": [
16+
{
17+
"name": "i_load",
18+
"nickname": "i_load",
19+
"description": "Button to import ply from url.",
20+
"optional": true,
21+
"allowTreeAccess": true,
22+
"showTypeHints": true,
23+
"scriptParamAccess": "item",
24+
"wireDisplay": "default",
25+
"sourceCount": 0,
26+
"typeHintID": "bool"
27+
},
28+
{
29+
"name": "i_ply_url",
30+
"nickname": "i_ply_url",
31+
"description": "The url where to get the pointcloud",
32+
"optional": true,
33+
"allowTreeAccess": true,
34+
"showTypeHints": true,
35+
"scriptParamAccess": "item",
36+
"wireDisplay": "default",
37+
"sourceCount": 0,
38+
"typeHintID": "str"
39+
}
40+
],
41+
"outputParameters": [
42+
{
43+
"name": "o_geometry",
44+
"nickname": "o_geo",
45+
"description": "The mesh or pcd that was imported.",
46+
"optional": false,
47+
"sourceCount": 0,
48+
"graft": false
49+
}
50+
]
51+
}
52+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#! python3
2+
3+
from ghpythonlib.componentbase import executingcomponent as component
4+
import socket
5+
import threading
6+
import json
7+
import time
8+
import scriptcontext as sc
9+
import Rhino.Geometry as rg
10+
import System.Drawing as sd
11+
from diffCheck import df_gh_canvas_utils
12+
from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML
13+
14+
class DFTCPListener(component):
15+
def __init__(self):
16+
try:
17+
ghenv.Component.ExpireSolution(True) # noqa: F821
18+
ghenv.Component.Attributes.PerformLayout() # noqa: F821
19+
except NameError:
20+
pass
21+
22+
for idx, label in enumerate(("Start", "Stop", "Load")):
23+
df_gh_canvas_utils.add_button(
24+
ghenv.Component, label, idx, x_offset=60) # noqa: F821
25+
df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821
26+
df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "5000", 4, 60, 20) # noqa: F821
27+
28+
def RunScript(self,
29+
i_start: bool,
30+
i_stop: bool,
31+
i_load: bool,
32+
i_host: str,
33+
i_port: int):
34+
35+
prefix = 'tcp'
36+
37+
# Sticky initialization
38+
sc.sticky.setdefault(f'{prefix}_server_sock', None)
39+
sc.sticky.setdefault(f'{prefix}_server_started', False)
40+
sc.sticky.setdefault(f'{prefix}_cloud_buffer_raw', [])
41+
sc.sticky.setdefault(f'{prefix}_latest_cloud', None)
42+
sc.sticky.setdefault(f'{prefix}_status_message', 'Waiting..')
43+
sc.sticky.setdefault(f'{prefix}_prev_start', False)
44+
sc.sticky.setdefault(f'{prefix}_prev_stop', False)
45+
sc.sticky.setdefault(f'{prefix}_prev_load', False)
46+
47+
# Client handler
48+
def handle_client(conn: socket.socket) -> None:
49+
"""
50+
Reads the incoming bytes from a single TCP client socket and stores valid data in a shared buffer.
51+
52+
:param conn: A socket object returned by `accept()` representing a live client connection.
53+
The client is expected to send newline-delimited JSON-encoded data, where each
54+
message is a list of 6D values: [x, y, z, r, g, b].
55+
56+
:returns: None
57+
"""
58+
buf = b''
59+
with conn:
60+
while sc.sticky.get(f'{prefix}_server_started', False):
61+
try:
62+
chunk = conn.recv(4096)
63+
if not chunk:
64+
break
65+
buf += chunk
66+
while b'\n' in buf:
67+
line, buf = buf.split(b'\n', 1)
68+
try:
69+
raw = json.loads(line.decode())
70+
except Exception:
71+
continue
72+
if isinstance(raw, list) and all(isinstance(pt, list) and len(pt) == 6 for pt in raw):
73+
sc.sticky[f'{prefix}_cloud_buffer_raw'] = raw
74+
except Exception:
75+
break
76+
time.sleep(0.05) # sleep briefly to prevent CPU spin
77+
78+
# thread to accept incoming connections
79+
def server_loop(sock: socket.socket) -> None:
80+
"""
81+
Accepts a single client connection and starts a background thread to handle it.
82+
83+
:param sock: A bound and listening TCP socket created by start_server().
84+
This socket will accept one incoming connection, then delegate it to handle_client().
85+
86+
:returns: None. This runs as a background thread and blocks on accept().
87+
"""
88+
try:
89+
conn, _ = sock.accept()
90+
handle_client(conn)
91+
except Exception:
92+
pass
93+
94+
# Start TCP server
95+
def start_server() -> None:
96+
"""
97+
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
98+
99+
:returns: None.
100+
"""
101+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
102+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
103+
sock.bind((i_host, i_port))
104+
sock.listen(1)
105+
sc.sticky[f'{prefix}_server_sock'] = sock
106+
sc.sticky[f'{prefix}_server_started'] = True
107+
sc.sticky[f'{prefix}_status_message'] = f'Listening on {i_host}:{i_port}'
108+
# Only accept one connection to keep it long-lived
109+
threading.Thread(target=server_loop, args=(sock,), daemon=True).start()
110+
111+
def stop_server() -> None:
112+
"""
113+
Stops the running TCP server by closing the listening socket and resetting internal state.
114+
115+
:returns: None.
116+
"""
117+
sock = sc.sticky.get(f'{prefix}_server_sock')
118+
if sock:
119+
try:
120+
sock.close()
121+
except Exception:
122+
pass
123+
sc.sticky[f'{prefix}_server_sock'] = None
124+
sc.sticky[f'{prefix}_server_started'] = False
125+
sc.sticky[f'{prefix}_cloud_buffer_raw'] = []
126+
sc.sticky[f'{prefix}_status_message'] = 'Stopped'
127+
128+
# Start or stop server based on inputs
129+
if i_start and not sc.sticky[f'{prefix}_prev_start']:
130+
start_server()
131+
if i_stop and not sc.sticky[f'{prefix}_prev_stop']:
132+
stop_server()
133+
134+
# Load buffered points into Rhino PointCloud
135+
if i_load and not sc.sticky[f'{prefix}_prev_load']:
136+
if not sc.sticky.get(f'{prefix}_server_started', False):
137+
sc.sticky[f'{prefix}_status_message'] = "Start Server First!"
138+
else:
139+
raw = sc.sticky.get(f'{prefix}_cloud_buffer_raw', [])
140+
if raw:
141+
pc = rg.PointCloud()
142+
for x, y, z, r, g, b in raw:
143+
pc.Add(rg.Point3d(x, y, z), sd.Color.FromArgb(int(r), int(g), int(b)))
144+
sc.sticky[f'{prefix}_latest_cloud'] = pc
145+
sc.sticky[f'{prefix}_status_message'] = f'Loaded pcd with {pc.Count} pts'
146+
else:
147+
sc.sticky[f'{prefix}_status_message'] = 'No data buffered'
148+
149+
# Update previous states
150+
sc.sticky[f'{prefix}_prev_start'] = i_start
151+
sc.sticky[f'{prefix}_prev_stop'] = i_stop
152+
sc.sticky[f'{prefix}_prev_load'] = i_load
153+
154+
# Update UI and output
155+
ghenv.Component.Message = sc.sticky[f'{prefix}_status_message'] # noqa: F821
156+
157+
o_cloud = sc.sticky[f'{prefix}_latest_cloud']
158+
return [o_cloud]
4.39 KB
Loading

0 commit comments

Comments
 (0)