Skip to content

Commit 492d9a1

Browse files
committed
Make point cloud loading asynchronous to prevent Grasshopper UI freezing when importing large TCP-received datasets.
1 parent 7d41f20 commit 492d9a1

File tree

2 files changed

+191
-102
lines changed

2 files changed

+191
-102
lines changed
Lines changed: 190 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#! python3
2-
31
from ghpythonlib.componentbase import executingcomponent as component
42
import socket
53
import threading
@@ -8,150 +6,241 @@
86
import scriptcontext as sc
97
import Rhino.Geometry as rg
108
import System.Drawing as sd
9+
import Grasshopper
1110
from diffCheck import df_gh_canvas_utils
1211

1312
class DFTCPListener(component):
1413
def __init__(self):
14+
super(DFTCPListener, self).__init__()
1515
try:
1616
ghenv.Component.ExpireSolution(True) # noqa: F821
1717
ghenv.Component.Attributes.PerformLayout() # noqa: F821
18-
except NameError:
18+
except:
1919
pass
2020

2121
for idx, label in enumerate(("Start", "Stop", "Load")):
22-
df_gh_canvas_utils.add_button(
23-
ghenv.Component, label, idx, x_offset=60) # noqa: F821
22+
df_gh_canvas_utils.add_button(ghenv.Component, label, idx, x_offset=60) # noqa: F821
2423
df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821
2524
df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "5000", 4, 60, 20) # noqa: F821
2625

27-
def RunScript(self,
28-
i_start: bool,
29-
i_stop: bool,
30-
i_load: bool,
31-
i_host: str,
32-
i_port: int):
33-
34-
prefix = 'tcp'
26+
def RunScript(self, i_start, i_stop, i_load, i_host, i_port):
27+
prefix = "tcp"
3528

29+
# ----------------------------
3630
# Sticky initialization
37-
sc.sticky.setdefault(f'{prefix}_server_sock', None)
38-
sc.sticky.setdefault(f'{prefix}_server_started', False)
39-
sc.sticky.setdefault(f'{prefix}_cloud_buffer_raw', [])
40-
sc.sticky.setdefault(f'{prefix}_latest_cloud', None)
41-
sc.sticky.setdefault(f'{prefix}_status_message', 'Waiting..')
42-
sc.sticky.setdefault(f'{prefix}_prev_start', False)
43-
sc.sticky.setdefault(f'{prefix}_prev_stop', False)
44-
sc.sticky.setdefault(f'{prefix}_prev_load', False)
45-
46-
# Client handler
47-
def handle_client(conn: socket.socket) -> None:
48-
"""
49-
Reads the incoming bytes from a single TCP client socket and stores valid data in a shared buffer.
50-
51-
:param conn: A socket object returned by `accept()` representing a live client connection.
52-
The client is expected to send newline-delimited JSON-encoded data, where each
53-
message is a list of 6D values: [x, y, z, r, g, b].
54-
55-
:returns: None
56-
"""
57-
buf = b''
31+
# ----------------------------
32+
sc.sticky.setdefault(prefix + "_server_sock", None)
33+
sc.sticky.setdefault(prefix + "_server_started", False)
34+
sc.sticky.setdefault(prefix + "_cloud_buffer_raw", [])
35+
sc.sticky.setdefault(prefix + "_latest_cloud", None)
36+
sc.sticky.setdefault(prefix + "_status_message", "Waiting..")
37+
sc.sticky.setdefault(prefix + "_prev_start", False)
38+
sc.sticky.setdefault(prefix + "_prev_stop", False)
39+
sc.sticky.setdefault(prefix + "_prev_load", False)
40+
41+
# Receiving state
42+
sc.sticky.setdefault(prefix + "_is_receiving", False)
43+
sc.sticky.setdefault(prefix + "_recv_bytes", 0)
44+
45+
# Loading state
46+
sc.sticky.setdefault(prefix + "_is_loading", False)
47+
sc.sticky.setdefault(prefix + "_load_progress", (0, 0)) # (done, total)
48+
sc.sticky.setdefault(prefix + "_load_started_at", None)
49+
sc.sticky.setdefault(prefix + "_load_duration_s", None)
50+
51+
# ----------------------------
52+
# Helper: schedule safe refresh on GH UI/solution thread
53+
# ----------------------------
54+
def request_expire(delay_ms=200, recompute=True):
55+
try:
56+
comp = ghenv.Component # noqa: F821
57+
doc = comp.OnPingDocument()
58+
if doc is None:
59+
return
60+
61+
def cb(_):
62+
try:
63+
comp.ExpireSolution(recompute)
64+
except:
65+
pass
66+
67+
doc.ScheduleSolution(int(delay_ms), Grasshopper.Kernel.GH_Document.GH_ScheduleDelegate(cb))
68+
except:
69+
pass
70+
71+
# ----------------------------
72+
# TCP receive thread
73+
# ----------------------------
74+
def handle_client(conn):
75+
buf = b""
76+
sc.sticky[prefix + "_is_receiving"] = True
77+
sc.sticky[prefix + "_recv_bytes"] = 0
78+
sc.sticky[prefix + "_status_message"] = "Client connected; receiving..."
79+
request_expire(0, True)
80+
5881
with conn:
59-
while sc.sticky.get(f'{prefix}_server_started', False):
82+
while sc.sticky.get(prefix + "_server_started", False):
6083
try:
61-
chunk = conn.recv(4096)
84+
chunk = conn.recv(65536)
6285
if not chunk:
6386
break
87+
sc.sticky[prefix + "_recv_bytes"] += len(chunk)
6488
buf += chunk
65-
while b'\n' in buf:
66-
line, buf = buf.split(b'\n', 1)
89+
90+
# Expect ONE message terminated by '\n'
91+
if b"\n" in buf:
92+
line, buf = buf.split(b"\n", 1)
93+
6794
try:
68-
raw = json.loads(line.decode())
69-
except Exception:
70-
continue
95+
raw = json.loads(line.decode("utf-8"))
96+
except Exception as e:
97+
sc.sticky[prefix + "_status_message"] = "JSON error: {}".format(repr(e))
98+
request_expire(0, True)
99+
break
100+
71101
if isinstance(raw, list) and all(isinstance(pt, list) and len(pt) == 6 for pt in raw):
72-
sc.sticky[f'{prefix}_cloud_buffer_raw'] = raw
73-
except Exception:
74-
break
75-
time.sleep(0.05) # sleep briefly to prevent CPU spin
102+
sc.sticky[prefix + "_cloud_buffer_raw"] = raw
103+
sc.sticky[prefix + "_status_message"] = "Buffered {} pts".format(len(raw))
104+
else:
105+
sc.sticky[prefix + "_status_message"] = "Invalid payload (expected [[x,y,z,r,g,b],...])"
106+
107+
request_expire(0, True)
108+
break
76109

77-
# thread to accept incoming connections
78-
def server_loop(sock: socket.socket) -> None:
79-
"""
80-
Accepts a single client connection and starts a background thread to handle it.
110+
except Exception as e:
111+
sc.sticky[prefix + "_status_message"] = "Recv error: {}".format(repr(e))
112+
request_expire(0, True)
113+
break
81114

82-
:param sock: A bound and listening TCP socket created by start_server().
83-
This socket will accept one incoming connection, then delegate it to handle_client().
115+
sc.sticky[prefix + "_is_receiving"] = False
116+
request_expire(0, True)
84117

85-
:returns: None. This runs as a background thread and blocks on accept().
86-
"""
118+
def server_loop(sock):
87119
try:
88120
conn, _ = sock.accept()
89121
handle_client(conn)
90-
except Exception:
91-
pass
92-
93-
# Start TCP server
94-
def start_server() -> None:
95-
"""
96-
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
122+
except Exception as e:
123+
sc.sticky[prefix + "_status_message"] = "Accept error: {}".format(repr(e))
124+
request_expire(0, True)
97125

98-
:returns: None.
99-
"""
126+
def start_server():
100127
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
101128
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
102-
sock.bind((i_host, i_port))
129+
sock.bind((i_host, int(i_port)))
103130
sock.listen(1)
104-
sc.sticky[f'{prefix}_server_sock'] = sock
105-
sc.sticky[f'{prefix}_server_started'] = True
106-
sc.sticky[f'{prefix}_status_message'] = f'Listening on {i_host}:{i_port}'
107-
# Only accept one connection to keep it long-lived
108-
threading.Thread(target=server_loop, args=(sock,), daemon=True).start()
109131

110-
def stop_server() -> None:
111-
"""
112-
Stops the running TCP server by closing the listening socket and resetting internal state.
132+
sc.sticky[prefix + "_server_sock"] = sock
133+
sc.sticky[prefix + "_server_started"] = True
134+
sc.sticky[prefix + "_status_message"] = "Listening on {}:{}".format(i_host, i_port)
135+
136+
threading.Thread(target=server_loop, args=(sock,), daemon=True).start()
137+
request_expire(0, True)
113138

114-
:returns: None.
115-
"""
116-
sock = sc.sticky.get(f'{prefix}_server_sock')
139+
def stop_server():
140+
sock = sc.sticky.get(prefix + "_server_sock")
117141
if sock:
118142
try:
119143
sock.close()
120-
except Exception:
144+
except:
121145
pass
122-
sc.sticky[f'{prefix}_server_sock'] = None
123-
sc.sticky[f'{prefix}_server_started'] = False
124-
sc.sticky[f'{prefix}_cloud_buffer_raw'] = []
125-
sc.sticky[f'{prefix}_status_message'] = 'Stopped'
126146

127-
# Start or stop server based on inputs
128-
if i_start and not sc.sticky[f'{prefix}_prev_start']:
147+
sc.sticky[prefix + "_server_sock"] = None
148+
sc.sticky[prefix + "_server_started"] = False
149+
sc.sticky[prefix + "_is_receiving"] = False
150+
sc.sticky[prefix + "_is_loading"] = False
151+
sc.sticky[prefix + "_cloud_buffer_raw"] = []
152+
sc.sticky[prefix + "_status_message"] = "Stopped"
153+
request_expire(0, True)
154+
155+
# ----------------------------
156+
# Async load (build Rhino PointCloud)
157+
# ----------------------------
158+
def build_pointcloud_async(raw_snapshot):
159+
try:
160+
sc.sticky[prefix + "_is_loading"] = True
161+
sc.sticky[prefix + "_load_started_at"] = time.time()
162+
sc.sticky[prefix + "_load_duration_s"] = None
163+
164+
total = len(raw_snapshot)
165+
sc.sticky[prefix + "_load_progress"] = (0, total)
166+
sc.sticky[prefix + "_status_message"] = "Loading {} pts...".format(total)
167+
request_expire(0, True)
168+
169+
pc = rg.PointCloud()
170+
171+
step = 25000 if total > 25000 else 5000
172+
last_ui = time.time()
173+
174+
for i, pt in enumerate(raw_snapshot):
175+
x, y, z, r, g, b = pt
176+
pc.Add(rg.Point3d(x, y, z), sd.Color.FromArgb(int(r), int(g), int(b)))
177+
178+
if (i + 1) % step == 0:
179+
sc.sticky[prefix + "_load_progress"] = (i + 1, total)
180+
now = time.time()
181+
if now - last_ui > 0.3:
182+
request_expire(0, True)
183+
last_ui = now
184+
185+
sc.sticky[prefix + "_latest_cloud"] = pc
186+
187+
dur = time.time() - sc.sticky[prefix + "_load_started_at"]
188+
sc.sticky[prefix + "_load_duration_s"] = dur
189+
sc.sticky[prefix + "_load_progress"] = (total, total)
190+
sc.sticky[prefix + "_status_message"] = "Loaded {} pts in {:.2f}s".format(pc.Count, dur)
191+
192+
# Force final recompute so output updates immediately
193+
request_expire(0, True)
194+
195+
except Exception as e:
196+
sc.sticky[prefix + "_status_message"] = "Load error: {}".format(repr(e))
197+
request_expire(0, True)
198+
finally:
199+
sc.sticky[prefix + "_is_loading"] = False
200+
request_expire(0, True)
201+
202+
# ----------------------------
203+
# UI: start/stop/load button edges
204+
# ----------------------------
205+
if i_start and not sc.sticky[prefix + "_prev_start"]:
129206
start_server()
130-
if i_stop and not sc.sticky[f'{prefix}_prev_stop']:
207+
208+
if i_stop and not sc.sticky[prefix + "_prev_stop"]:
131209
stop_server()
132210

133-
# Load buffered points into Rhino PointCloud
134-
if i_load and not sc.sticky[f'{prefix}_prev_load']:
135-
if not sc.sticky.get(f'{prefix}_server_started', False):
136-
sc.sticky[f'{prefix}_status_message'] = "Start Server First!"
211+
if i_load and not sc.sticky[prefix + "_prev_load"]:
212+
if not sc.sticky.get(prefix + "_server_started", False):
213+
sc.sticky[prefix + "_status_message"] = "Start Server First!"
214+
elif sc.sticky.get(prefix + "_is_loading", False):
215+
sc.sticky[prefix + "_status_message"] = "Already loading..."
137216
else:
138-
raw = sc.sticky.get(f'{prefix}_cloud_buffer_raw', [])
217+
raw = sc.sticky.get(prefix + "_cloud_buffer_raw", [])
139218
if raw:
140-
pc = rg.PointCloud()
141-
for x, y, z, r, g, b in raw:
142-
pc.Add(rg.Point3d(x, y, z), sd.Color.FromArgb(int(r), int(g), int(b)))
143-
sc.sticky[f'{prefix}_latest_cloud'] = pc
144-
sc.sticky[f'{prefix}_status_message'] = f'Loaded pcd with {pc.Count} pts'
219+
raw_snapshot = list(raw) # snapshot
220+
threading.Thread(target=build_pointcloud_async, args=(raw_snapshot,), daemon=True).start()
145221
else:
146-
sc.sticky[f'{prefix}_status_message'] = 'No data buffered'
222+
sc.sticky[prefix + "_status_message"] = "No data buffered"
223+
request_expire(0, True)
224+
225+
# ----------------------------
226+
# Live status while receiving/loading
227+
# ----------------------------
228+
if sc.sticky.get(prefix + "_is_receiving", False):
229+
b = sc.sticky.get(prefix + "_recv_bytes", 0)
230+
sc.sticky[prefix + "_status_message"] = "Receiving... {:.1f} MB".format(b / (1024.0 * 1024.0))
231+
request_expire(300, True)
232+
233+
if sc.sticky.get(prefix + "_is_loading", False):
234+
done, total = sc.sticky.get(prefix + "_load_progress", (0, 0))
235+
if total:
236+
sc.sticky[prefix + "_status_message"] = "Loading... {}/{} pts".format(done, total)
237+
request_expire(300, True)
147238

148239
# Update previous states
149-
sc.sticky[f'{prefix}_prev_start'] = i_start
150-
sc.sticky[f'{prefix}_prev_stop'] = i_stop
151-
sc.sticky[f'{prefix}_prev_load'] = i_load
152-
153-
# Update UI and output
154-
ghenv.Component.Message = sc.sticky[f'{prefix}_status_message'] # noqa: F821
240+
sc.sticky[prefix + "_prev_start"] = i_start
241+
sc.sticky[prefix + "_prev_stop"] = i_stop
242+
sc.sticky[prefix + "_prev_load"] = i_load
155243

156-
o_cloud = sc.sticky[f'{prefix}_latest_cloud']
157-
return [o_cloud]
244+
# Output
245+
ghenv.Component.Message = sc.sticky[prefix + "_status_message"] # noqa: F821
246+
return [sc.sticky[prefix + "_latest_cloud"]]

src/gh/examples/simple_tcp_sender.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def random_colored_point():
1616
with socket.create_connection((host, port)) as s:
1717
print("Connected to GH")
1818
while True:
19-
cloud = [random_colored_point() for _ in range(1000)]
19+
cloud = [random_colored_point() for _ in range(1000000)]
2020
msg = json.dumps(cloud) + "\n"
2121
s.sendall(msg.encode())
2222
print("Sent cloud with", len(cloud), "colored points")

0 commit comments

Comments
 (0)