From 80eceacd759786afe1b82eae2f4ae142ac490501 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 10:49:00 +0200 Subject: [PATCH 1/4] feat: create lines, polylines and polygons from create_geometry points create_geometry now accepts type line/polyline/polygon with a "points" list, for frontend drawing tools. A polygon is created as a closed Polyline, since the viewer can't display COMPAS Polygons yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + src/compas_threejs/viewer/inbox.py | 45 ++++++++++++++++++- tests/test_create_geometry.py | 70 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/test_create_geometry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7a824..516093c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `create_geometry` messages can now create a `line`, `polyline` or `polygon` from a `points` list, for frontend drawing tools such as `compas_threejs_draw`. A polygon is created as a closed `Polyline`, since the viewer can't display COMPAS `Polygon`s yet. + ### Changed ### Removed diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 4502971..baac2da 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -4,7 +4,9 @@ from compas.colors import Color from compas.geometry import Box from compas.geometry import Frame +from compas.geometry import Line from compas.geometry import Point +from compas.geometry import Polyline from compas.geometry import Sphere from compas.geometry import Transformation from rich.console import Console @@ -21,6 +23,31 @@ "point": (Point, ()), } +# Types a "create_geometry" message builds from a list of points ("points": +# [[x, y, z], ...]) instead of one location, with the fewest points each needs. +_POINT_LIST_TYPES = {"line": 2, "polyline": 2, "polygon": 3} + + +def _geometry_from_points(type_name, points): + """Builds a line, polyline or polygon from `points`, or returns None if they are invalid. + + A polygon becomes a closed Polyline (its first point repeated at the end): the viewer + can't display COMPAS Polygons yet, and a closed polyline shows the same outline. + """ + try: + if any(len(point) != 3 for point in points): + return None + points = [Point(*map(float, point)) for point in points] + except (TypeError, ValueError): + return None + if len(points) < _POINT_LIST_TYPES[type_name]: + return None + if type_name == "line": + return Line(points[0], points[1]) if len(points) == 2 else None + if type_name == "polygon": + return Polyline(points + [points[0]]) + return Polyline(points) + class Inbox: """Routes messages coming in from the frontend and owns the registries needed to resolve them.""" @@ -199,7 +226,8 @@ def _handle_object_transform(self, message, outbox, workspace_id): self.app.get_workspace(workspace_id).update_geometry(geometry) def _handle_create_geometry(self, message, outbox, workspace_id): - """Creates a new backend geometry object from a frontend "Add Box/Sphere/Point" action. + """Creates a new backend geometry object from a frontend "Add Box/Sphere/Point" action, + or a line/polyline/polygon drawn point by point (see `_POINT_LIST_TYPES`). Reuses `Workspace.add_geometry` for the outbound side, so the created object is registered and broadcast exactly like anything added by a running script - the @@ -207,6 +235,9 @@ def _handle_create_geometry(self, message, outbox, workspace_id): reconnects the same way any other geometry does. """ type_name = message.get("type") + if type_name in _POINT_LIST_TYPES: + self._create_from_points(type_name, message.get("points"), workspace_id) + return entry = _CREATABLE_TYPES.get(type_name) if entry is None: console.log(f"[yellow]Unrecognized create_geometry type: {type_name}[/yellow]") @@ -229,6 +260,18 @@ def _handle_create_geometry(self, message, outbox, workspace_id): console.log(f"[blue]Creating {type_name} from frontend at {point}[/blue]") self.app.get_workspace(workspace_id).add_geometry(geometry, Material()) + def _create_from_points(self, type_name, points, workspace_id): + """Creates a line/polyline/polygon drawn point by point in the frontend.""" + if self.app is None: + console.log("[yellow]create_geometry received but Inbox has no App reference[/yellow]") + return + geometry = _geometry_from_points(type_name, points or []) + if geometry is None: + console.log(f"[yellow]Ignoring create_geometry {type_name} with invalid points: {points}[/yellow]") + return + console.log(f"[blue]Creating {type_name} from frontend with {len(points)} points[/blue]") + self.app.get_workspace(workspace_id).add_geometry(geometry, Material()) + def _handle_material_edit(self, message, outbox, workspace_id): """Applies a toolbar material edit (color/metalness/roughness) made in the frontend to the corresponding live backend Material instance. diff --git a/tests/test_create_geometry.py b/tests/test_create_geometry.py new file mode 100644 index 0000000..948970e --- /dev/null +++ b/tests/test_create_geometry.py @@ -0,0 +1,70 @@ +import unittest + +from compas.geometry import Box +from compas.geometry import Line +from compas.geometry import Point +from compas.geometry import Polyline + +from compas_threejs.viewer.inbox import Inbox + + +class _Workspace: + def __init__(self): + self.added = [] + + def add_geometry(self, geometry, material=None): + self.added.append(geometry) + + +class _App: + def __init__(self): + self.workspace = _Workspace() + + def get_workspace(self, workspace_id): + return self.workspace + + +class CreateGeometryTests(unittest.TestCase): + def setUp(self): + self.app = _App() + self.inbox = Inbox(self.app) + + def create(self, **message): + self.inbox._handle_create_geometry({"dispatch": "create_geometry", **message}, None, "main") + return self.app.workspace.added + + def test_line_from_two_points(self): + [line] = self.create(type="line", points=[[0, 0, 0], [1, 2, 3]]) + self.assertIsInstance(line, Line) + self.assertEqual(list(line.end), [1.0, 2.0, 3.0]) + + def test_polyline_keeps_its_points(self): + [polyline] = self.create(type="polyline", points=[[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + self.assertIsInstance(polyline, Polyline) + self.assertEqual(len(polyline.points), 3) + + def test_polygon_becomes_a_closed_polyline(self): + [polygon] = self.create(type="polygon", points=[[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + self.assertIsInstance(polygon, Polyline) + self.assertEqual(len(polygon.points), 4) + self.assertEqual(list(polygon.points[0]), list(polygon.points[-1])) + + def test_invalid_points_are_ignored(self): + self.create(type="line", points=[[0, 0, 0]]) + self.create(type="line", points=[[0, 0, 0], [1, 1, 1], [2, 2, 2]]) + self.create(type="polygon", points=[[0, 0, 0], [1, 0, 0]]) + self.create(type="polyline", points=[[0, 0], [1, 1]]) + self.create(type="polyline", points=[["a", 0, 0], [1, 1, 1]]) + self.create(type="polyline") + self.assertEqual(self.app.workspace.added, []) + + def test_single_location_types_still_work(self): + [box] = self.create(type="box", point=[1, 2, 3], params={"xsize": 2, "ysize": 3, "zsize": 4}) + self.assertIsInstance(box, Box) + self.assertEqual(list(box.frame.point), [1.0, 2.0, 3.0]) + [_, point] = self.create(type="point", point=[4, 5, 6]) + self.assertIsInstance(point, Point) + + +if __name__ == "__main__": + unittest.main() From 96ca7fe6863668746c1d5fa6829a2246014439f1 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 13:14:03 +0200 Subject: [PATCH 2/4] feat: arcs, circles, real polygons, delete, extrude and creation callbacks for frontend drawing Moves frontend geometry creation into compas_threejs.viewer.drawing and extends it: polygons are now real COMPAS Polygons, circles come from a location and radius, and arcs from start/end/through points (with angles kept off 0, which compas-pb-ts 2.0 rejects). create_geometry keeps a guid sent by the frontend. New delete_geometry and extrude_geometry messages remove an object and extrude a polygon into a prism mesh. App.on_create and App.on_delete let scripts react to what the frontend creates or deletes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- src/compas_threejs/viewer/app.py | 44 +++++++ src/compas_threejs/viewer/drawing.py | 182 +++++++++++++++++++++++++++ src/compas_threejs/viewer/inbox.py | 128 +++++++++---------- tests/test_create_geometry.py | 148 +++++++++++++++++++--- 5 files changed, 419 insertions(+), 87 deletions(-) create mode 100644 src/compas_threejs/viewer/drawing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 516093c..f0c4423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `create_geometry` messages can now create a `line`, `polyline` or `polygon` from a `points` list, for frontend drawing tools such as `compas_threejs_draw`. A polygon is created as a closed `Polyline`, since the viewer can't display COMPAS `Polygon`s yet. +- Frontend drawing support, for tools such as `compas_threejs_draw`: `create_geometry` messages can create a `line`, `polyline`, `polygon` or three-point `arc` from a `points` list, and a `circle` from a location and radius (see `compas_threejs.viewer.drawing`). A `guid` in the message becomes the new object's guid. +- `delete_geometry` messages remove an object, and `extrude_geometry` messages extrude a polygon into a new prism mesh, keeping the polygon. +- `App.on_create` and `App.on_delete` register callbacks (usable as decorators) for objects the frontend creates or deletes. ### Changed diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index cf3c0e1..38e0ed2 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -378,6 +378,50 @@ def register_action(self, name: str, callable_function): """ self.inbox.register_action(name, callable_function) + def on_create(self, callback): + """ + Registers `callback` to be called with each object the frontend creates - drawn + with a tool such as `compas_threejs_draw`'s, added from the toolbar, or extruded. + Usable as a decorator. The object is already in the scene when it is called. + + Parameters + ---------- + callback : callable + Called as ``callback(geometry)`` on the server thread. + + Returns + ------- + callable + `callback` itself, so it can be used as ``@app.on_create``. + + Examples + -------- + >>> app = App() + >>> @app.on_create + ... def created(geometry): + ... print("drawn:", geometry) + """ + self.inbox.create_callbacks.append(callback) + return callback + + def on_delete(self, callback): + """ + Registers `callback` to be called with each object the frontend deletes, after it + has been removed from the scene. Usable as a decorator, like `on_create`. + + Parameters + ---------- + callback : callable + Called as ``callback(geometry)`` on the server thread. + + Returns + ------- + callable + `callback` itself. + """ + self.inbox.delete_callbacks.append(callback) + return callback + # ---- GEOMETRY / LIGHTS / MATERIALS / TEXT / UI (forwarded to the main workspace) ----------- def add_geometry(self, geometry, material=None, metadata=None, actions=None): diff --git a/src/compas_threejs/viewer/drawing.py b/src/compas_threejs/viewer/drawing.py new file mode 100644 index 0000000..2024c26 --- /dev/null +++ b/src/compas_threejs/viewer/drawing.py @@ -0,0 +1,182 @@ +"""Builds COMPAS objects from the frontend's drawing messages. + +The frontend drawing tools (for example `compas_threejs_draw`) send +`create_geometry` messages with either one location (`point` plus `params`) or a +list of `points`, and `extrude_geometry` messages for a drawn polygon. These +helpers turn them into COMPAS geometry, or return None for invalid input so the +Inbox can log and ignore it. +""" + +import math +import uuid + +from compas.datastructures import Mesh +from compas.geometry import Arc +from compas.geometry import Box +from compas.geometry import Circle +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyline +from compas.geometry import Sphere +from compas.geometry import Vector + +# Types created at one location ("point") from positive numeric "params". +SIZED_TYPES = { + "point": (), + "box": ("xsize", "ysize", "zsize"), + "sphere": ("radius",), + "circle": ("radius",), +} + +# Types created from a list of points, with the fewest points each needs. +POINT_LIST_TYPES = {"line": 2, "polyline": 2, "polygon": 3, "arc": 3} + +CREATABLE_TYPES = set(SIZED_TYPES) | set(POINT_LIST_TYPES) + +_EPS = 1e-9 + + +def _point(value): + """A Point from an [x, y, z] list, or None.""" + try: + if len(value) != 3: + return None + return Point(*(float(v) for v in value)) + except (TypeError, ValueError): + return None + + +def _points(values): + """Points from a list of [x, y, z] lists, or None if any is invalid.""" + if not isinstance(values, (list, tuple)): + return None + points = [_point(value) for value in values] + return None if any(point is None for point in points) else points + + +def geometry_from_message(message): + """The COMPAS object a `create_geometry` message describes, or None if it is invalid.""" + type_name = message.get("type") + if type_name in POINT_LIST_TYPES: + points = _points(message.get("points")) + if points is None or len(points) < POINT_LIST_TYPES[type_name]: + return None + if type_name == "line": + return Line(points[0], points[1]) if len(points) == 2 else None + if type_name == "polyline": + return Polyline(points) + if type_name == "polygon": + return Polygon(points) + return arc_through_points(*points) if len(points) == 3 else None + + if type_name not in SIZED_TYPES: + return None + location = _point(message.get("point") or [0.0, 0.0, 0.0]) + if location is None: + return None + if type_name == "point": + return location + params = message.get("params") or {} + try: + sizes = {name: float(params.get(name, 1.0)) for name in SIZED_TYPES[type_name]} + except (TypeError, ValueError): + return None + if any(value <= 0 for value in sizes.values()): + return None + frame = Frame(location, [1, 0, 0], [0, 1, 0]) + if type_name == "box": + return Box(frame=frame, **sizes) + if type_name == "sphere": + return Sphere(frame=frame, **sizes) + return Circle(frame=frame, **sizes) + + +def arc_through_points(start, end, through): + """The arc from `start` to `end` passing through `through`, or None if the three + points are collinear. + + The arc's angles are placed symmetrically around pi (from pi - sweep/2 to + pi + sweep/2), with its frame turned to match. Same arc, but its start angle is + never 0: compas-pb-ts 2.0 treats an angle of exactly 0 as missing and rejects the + arc, so the viewer could not show it. + """ + a, b, c = Vector(*start), Vector(*end), Vector(*through) + ab, ac = b - a, c - a + normal = ab.cross(ac) + if normal.length < _EPS: + return None + center = a + (normal.cross(ab) * ac.length**2 + ac.cross(normal) * ab.length**2) * (0.5 / normal.length**2) + radius = (a - center).length + + # The rotation sense that goes start -> through -> end. + axis = (c - a).cross(b - c) + axis.unitize() + u = a - center + u.unitize() + w = axis.cross(u) + to_end = b - center + sweep = math.atan2(to_end.dot(w), to_end.dot(u)) % (2 * math.pi) + if sweep < _EPS: + return None + + start_angle = math.pi - sweep / 2 + # Turn the frame so the arc's start sits at `start_angle` from its x-axis. + xaxis = u * math.cos(start_angle) - w * math.sin(start_angle) + yaxis = axis.cross(xaxis) + frame = Frame(Point(*center), xaxis, yaxis) + return Arc(radius, start_angle, start_angle + sweep, frame=frame) + + +def _profile_points(geometry): + """The corners of a polygon or closed polyline, or None for anything else.""" + if isinstance(geometry, Polygon): + return [Vector(*point) for point in geometry.points] + if isinstance(geometry, Polyline): + points = [Vector(*point) for point in geometry.points] + if len(points) >= 4 and (points[0] - points[-1]).length < _EPS: + return points[:-1] + return None + + +def extrude(geometry, height): + """A closed prism mesh from a polygon (or closed polyline) extruded `height` along + world Z - or along its own normal if it is vertical - or None if it can't be. + """ + points = _profile_points(geometry) + if points is None or len(points) < 3 or abs(height) < _EPS: + return None + + normal = Vector(0, 0, 0) + for i, current in enumerate(points): + following = points[(i + 1) % len(points)] + normal += current.cross(following) + if normal.length < _EPS: + return None + normal.unitize() + up = Vector(0, 0, 1) if abs(normal.z) > 1e-6 else normal + direction = up * height + # Wind the profile counterclockwise around the extrusion direction, so every face + # of the prism ends up facing outward. + if normal.dot(direction) < 0: + points = list(reversed(points)) + + count = len(points) + vertices = [list(point) for point in points] + [list(point + direction) for point in points] + faces = [list(reversed(range(count))), list(range(count, 2 * count))] + for i in range(count): + j = (i + 1) % count + faces.append([i, j, count + j, count + i]) + return Mesh.from_vertices_and_faces(vertices, faces) + + +def apply_guid(geometry, guid): + """Gives `geometry` the frontend's `guid`, so the frontend can refer to what it drew + (for example to undo it). Returns False if `guid` is not a valid UUID. + """ + try: + geometry._guid = uuid.UUID(str(guid)) + except (TypeError, ValueError): + return False + return True diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index baac2da..c6e79d1 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -2,52 +2,14 @@ import threading from compas.colors import Color -from compas.geometry import Box -from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Point -from compas.geometry import Polyline -from compas.geometry import Sphere from compas.geometry import Transformation from rich.console import Console from compas_threejs.materials import Material +from compas_threejs.viewer import drawing console = Console() -# Maps a frontend-creatable type name to its COMPAS constructor and the numeric -# parameter names a "create_geometry" message is allowed to set on it. -_CREATABLE_TYPES = { - "box": (Box, ("xsize", "ysize", "zsize")), - "sphere": (Sphere, ("radius",)), - "point": (Point, ()), -} - -# Types a "create_geometry" message builds from a list of points ("points": -# [[x, y, z], ...]) instead of one location, with the fewest points each needs. -_POINT_LIST_TYPES = {"line": 2, "polyline": 2, "polygon": 3} - - -def _geometry_from_points(type_name, points): - """Builds a line, polyline or polygon from `points`, or returns None if they are invalid. - - A polygon becomes a closed Polyline (its first point repeated at the end): the viewer - can't display COMPAS Polygons yet, and a closed polyline shows the same outline. - """ - try: - if any(len(point) != 3 for point in points): - return None - points = [Point(*map(float, point)) for point in points] - except (TypeError, ValueError): - return None - if len(points) < _POINT_LIST_TYPES[type_name]: - return None - if type_name == "line": - return Line(points[0], points[1]) if len(points) == 2 else None - if type_name == "polygon": - return Polyline(points + [points[0]]) - return Polyline(points) - class Inbox: """Routes messages coming in from the frontend and owns the registries needed to resolve them.""" @@ -71,8 +33,14 @@ def __init__(self, app=None): "other_action": self._handle_other_action, "object_transform": self._handle_object_transform, "create_geometry": self._handle_create_geometry, + "delete_geometry": self._handle_delete_geometry, + "extrude_geometry": self._handle_extrude_geometry, "material_edit": self._handle_material_edit, } + # Called with each object the frontend creates or deletes - see App.on_create / + # App.on_delete. + self.create_callbacks = [] + self.delete_callbacks = [] # ---- REGISTRATION (called by Workspace when something is sent out) ------------------------- @@ -226,51 +194,71 @@ def _handle_object_transform(self, message, outbox, workspace_id): self.app.get_workspace(workspace_id).update_geometry(geometry) def _handle_create_geometry(self, message, outbox, workspace_id): - """Creates a new backend geometry object from a frontend "Add Box/Sphere/Point" action, - or a line/polyline/polygon drawn point by point (see `_POINT_LIST_TYPES`). + """Creates a backend geometry object drawn or added in the frontend - see + `drawing.geometry_from_message` for the types and their fields. Reuses `Workspace.add_geometry` for the outbound side, so the created object is registered and broadcast exactly like anything added by a running script - the frontend needs no special handling to receive it, and it persists across - reconnects the same way any other geometry does. + reconnects the same way any other geometry does. A "guid" in the message becomes + the object's guid, so the frontend can refer to it later (e.g. to undo it). """ type_name = message.get("type") - if type_name in _POINT_LIST_TYPES: - self._create_from_points(type_name, message.get("points"), workspace_id) - return - entry = _CREATABLE_TYPES.get(type_name) - if entry is None: + if type_name not in drawing.CREATABLE_TYPES: console.log(f"[yellow]Unrecognized create_geometry type: {type_name}[/yellow]") return - if self.app is None: - console.log("[yellow]create_geometry received but Inbox has no App reference[/yellow]") + geometry = drawing.geometry_from_message(message) + if geometry is None: + console.log(f"[yellow]Ignoring invalid create_geometry {type_name}: {message}[/yellow]") return + self._add_created(geometry, message.get("guid"), type_name, workspace_id) - constructor, allowed_params = entry - point = message.get("point") or [0.0, 0.0, 0.0] - params = message.get("params") or {} - kwargs = {name: params.get(name, 1.0) for name in allowed_params} - - if type_name == "point": - geometry = constructor(*point) - else: - frame = Frame(Point(*point), [1, 0, 0], [0, 1, 0]) - geometry = constructor(frame=frame, **kwargs) - - console.log(f"[blue]Creating {type_name} from frontend at {point}[/blue]") - self.app.get_workspace(workspace_id).add_geometry(geometry, Material()) + def _handle_extrude_geometry(self, message, outbox, workspace_id): + """Extrudes a polygon (or closed polyline) into a prism mesh of the message's + "height", added as a new object - the original stays.""" + source = self.geometry_registry.get(message.get("guid")) + try: + height = float(message.get("height")) + except (TypeError, ValueError): + height = 0.0 + mesh = drawing.extrude(source, height) if source is not None else None + if mesh is None: + console.log(f"[yellow]Ignoring extrude_geometry for {message.get('guid')} (not a polygon, or no height)[/yellow]") + return + self._add_created(mesh, message.get("new_guid"), "extrusion", workspace_id) - def _create_from_points(self, type_name, points, workspace_id): - """Creates a line/polyline/polygon drawn point by point in the frontend.""" + def _add_created(self, geometry, guid, label, workspace_id): if self.app is None: - console.log("[yellow]create_geometry received but Inbox has no App reference[/yellow]") + console.log("[yellow]Frontend geometry received but Inbox has no App reference[/yellow]") return - geometry = _geometry_from_points(type_name, points or []) - if geometry is None: - console.log(f"[yellow]Ignoring create_geometry {type_name} with invalid points: {points}[/yellow]") - return - console.log(f"[blue]Creating {type_name} from frontend with {len(points)} points[/blue]") + if guid is not None: + if not drawing.apply_guid(geometry, guid): + console.log(f"[yellow]Ignoring invalid guid from frontend: {guid}[/yellow]") + return + if str(geometry.guid) in self.geometry_registry: + console.log(f"[yellow]Ignoring {label} with a guid already in use: {guid}[/yellow]") + return + console.log(f"[blue]Creating {label} from frontend[/blue]") self.app.get_workspace(workspace_id).add_geometry(geometry, Material()) + self._notify(self.create_callbacks, geometry) + + def _handle_delete_geometry(self, message, outbox, workspace_id): + """Removes an object the frontend deleted, e.g. with the Delete key or an undo.""" + guid = message.get("guid") + geometry = self.geometry_registry.get(guid) + if geometry is None or self.app is None: + console.log(f"[yellow]Unrecognized delete_geometry target: {guid}[/yellow]") + return + console.log(f"[blue]Deleting {type(geometry).__name__} from frontend[/blue]") + self.app.get_workspace(workspace_id).remove_object(geometry) + self._notify(self.delete_callbacks, geometry) + + def _notify(self, callbacks, geometry): + for callback in list(callbacks): + try: + callback(geometry) + except Exception as error: # a script's callback must not break the server + console.log(f"[red]Error in {getattr(callback, '__name__', callback)}: {error}[/red]") def _handle_material_edit(self, message, outbox, workspace_id): """Applies a toolbar material edit (color/metalness/roughness) made in the frontend diff --git a/tests/test_create_geometry.py b/tests/test_create_geometry.py index 948970e..3bd4616 100644 --- a/tests/test_create_geometry.py +++ b/tests/test_create_geometry.py @@ -1,24 +1,40 @@ +import math import unittest +import uuid +from compas.datastructures import Mesh +from compas.geometry import Arc from compas.geometry import Box +from compas.geometry import Circle from compas.geometry import Line from compas.geometry import Point +from compas.geometry import Polygon from compas.geometry import Polyline +from compas_threejs.viewer.drawing import arc_through_points +from compas_threejs.viewer.drawing import extrude from compas_threejs.viewer.inbox import Inbox class _Workspace: - def __init__(self): + def __init__(self, inbox): + self.inbox = inbox self.added = [] + self.removed = [] def add_geometry(self, geometry, material=None): self.added.append(geometry) + self.inbox.geometry_registry[str(geometry.guid)] = geometry + + def remove_object(self, geometry): + self.removed.append(geometry) + self.inbox.forget_geometry(str(geometry.guid)) class _App: def __init__(self): - self.workspace = _Workspace() + self.inbox = Inbox(self) + self.workspace = _Workspace(self.inbox) def get_workspace(self, workspace_id): return self.workspace @@ -27,12 +43,15 @@ def get_workspace(self, workspace_id): class CreateGeometryTests(unittest.TestCase): def setUp(self): self.app = _App() - self.inbox = Inbox(self.app) + self.inbox = self.app.inbox - def create(self, **message): - self.inbox._handle_create_geometry({"dispatch": "create_geometry", **message}, None, "main") + def send(self, dispatch, **message): + self.inbox._handlers[dispatch]({"dispatch": dispatch, **message}, None, "main") return self.app.workspace.added + def create(self, **message): + return self.send("create_geometry", **message) + def test_line_from_two_points(self): [line] = self.create(type="line", points=[[0, 0, 0], [1, 2, 3]]) self.assertIsInstance(line, Line) @@ -43,27 +62,124 @@ def test_polyline_keeps_its_points(self): self.assertIsInstance(polyline, Polyline) self.assertEqual(len(polyline.points), 3) - def test_polygon_becomes_a_closed_polyline(self): + def test_polygon_is_a_real_polygon(self): [polygon] = self.create(type="polygon", points=[[0, 0, 0], [1, 0, 0], [1, 1, 0]]) - self.assertIsInstance(polygon, Polyline) - self.assertEqual(len(polygon.points), 4) - self.assertEqual(list(polygon.points[0]), list(polygon.points[-1])) + self.assertIsInstance(polygon, Polygon) + self.assertEqual(len(polygon.points), 3) + + def test_circle_from_center_and_radius(self): + [circle] = self.create(type="circle", point=[1, 2, 0], params={"radius": 1.5}) + self.assertIsInstance(circle, Circle) + self.assertEqual(circle.radius, 1.5) + self.assertEqual(list(circle.frame.point), [1.0, 2.0, 0.0]) + + def test_arc_through_three_points(self): + [arc] = self.create(type="arc", points=[[1, 0, 0], [-1, 0, 0], [0, 1, 0]]) + self.assertIsInstance(arc, Arc) + self.assertAlmostEqual(arc.radius, 1.0) + self.assertEqual([round(v, 9) for v in arc.point_at(0)], [1.0, 0.0, 0.0]) + self.assertEqual([round(v, 9) for v in arc.point_at(1)], [-1.0, 0.0, 0.0]) + self.assertEqual([round(v, 9) for v in arc.point_at(0.5)], [0.0, 1.0, 0.0]) + # Never a start angle of 0, which compas-pb-ts 2.0 rejects. + self.assertGreater(arc.start_angle, 0) + + def test_arc_longer_than_half_a_circle(self): + arc = arc_through_points([1, 0, 0], [0, 1, 0], [0, -1, 0]) + self.assertAlmostEqual(arc.end_angle - arc.start_angle, 1.5 * math.pi) + self.assertEqual([round(v, 6) for v in arc.point_at(0.5)], [-0.707107, -0.707107, 0.0]) + + def test_single_location_types_still_work(self): + [box] = self.create(type="box", point=[1, 2, 3], params={"xsize": 2, "ysize": 3, "zsize": 4}) + self.assertIsInstance(box, Box) + self.assertEqual(list(box.frame.point), [1.0, 2.0, 3.0]) + [_, point] = self.create(type="point", point=[4, 5, 6]) + self.assertIsInstance(point, Point) - def test_invalid_points_are_ignored(self): + def test_invalid_input_is_ignored(self): self.create(type="line", points=[[0, 0, 0]]) self.create(type="line", points=[[0, 0, 0], [1, 1, 1], [2, 2, 2]]) self.create(type="polygon", points=[[0, 0, 0], [1, 0, 0]]) self.create(type="polyline", points=[[0, 0], [1, 1]]) self.create(type="polyline", points=[["a", 0, 0], [1, 1, 1]]) self.create(type="polyline") + self.create(type="arc", points=[[0, 0, 0], [1, 1, 0], [2, 2, 0]]) + self.create(type="sphere", point=[0, 0, 0], params={"radius": 0}) + self.create(type="teapot", point=[0, 0, 0]) self.assertEqual(self.app.workspace.added, []) - def test_single_location_types_still_work(self): - [box] = self.create(type="box", point=[1, 2, 3], params={"xsize": 2, "ysize": 3, "zsize": 4}) - self.assertIsInstance(box, Box) - self.assertEqual(list(box.frame.point), [1.0, 2.0, 3.0]) - [_, point] = self.create(type="point", point=[4, 5, 6]) - self.assertIsInstance(point, Point) + def test_uses_the_frontend_guid_once(self): + guid = str(uuid.uuid4()) + [point] = self.create(type="point", point=[0, 0, 0], guid=guid) + self.assertEqual(str(point.guid), guid) + self.create(type="point", point=[1, 1, 1], guid=guid) + self.create(type="point", point=[1, 1, 1], guid="not-a-uuid") + self.assertEqual(len(self.app.workspace.added), 1) + + def test_delete_removes_the_object(self): + guid = str(uuid.uuid4()) + [point] = self.create(type="point", point=[0, 0, 0], guid=guid) + self.send("delete_geometry", guid=guid) + self.assertEqual(self.app.workspace.removed, [point]) + self.assertNotIn(guid, self.inbox.geometry_registry) + self.send("delete_geometry", guid=guid) + self.assertEqual(len(self.app.workspace.removed), 1) + + def test_extrude_adds_a_prism_and_keeps_the_polygon(self): + guid = str(uuid.uuid4()) + new_guid = str(uuid.uuid4()) + points = [[0, 0, 0], [0, 2, 0], [1, 2, 0], [1, 1, 0], [2, 1, 0], [2, 0, 0]] + [polygon] = self.create(type="polygon", points=points, guid=guid) + + self.send("extrude_geometry", guid=guid, height=2, new_guid=new_guid) + + [kept, prism] = self.app.workspace.added + self.assertIs(kept, polygon) + self.assertIsInstance(prism, Mesh) + self.assertEqual(str(prism.guid), new_guid) + self.assertEqual(prism.number_of_faces(), 8) + self.assertAlmostEqual(prism.volume(), 6.0) + + def test_extrude_ignores_what_is_not_a_polygon(self): + guid = str(uuid.uuid4()) + self.create(type="line", points=[[0, 0, 0], [1, 0, 0]], guid=guid) + self.send("extrude_geometry", guid=guid, height=1) + self.send("extrude_geometry", guid=str(uuid.uuid4()), height=1) + self.assertEqual(len(self.app.workspace.added), 1) + + def test_extrude_downward_still_faces_outward(self): + prism = extrude(Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]), -3) + self.assertAlmostEqual(prism.volume(), 3.0) + + def test_callbacks_hear_about_created_and_deleted_objects(self): + created, deleted = [], [] + self.inbox.create_callbacks.append(created.append) + self.inbox.delete_callbacks.append(deleted.append) + + def broken(geometry): + raise RuntimeError("a script bug") + + self.inbox.create_callbacks.append(broken) + guid = str(uuid.uuid4()) + [point] = self.create(type="point", point=[0, 0, 0], guid=guid) + self.send("delete_geometry", guid=guid) + + self.assertEqual(created, [point]) + self.assertEqual(deleted, [point]) + + +class AppHookTests(unittest.TestCase): + def test_on_create_and_on_delete_register_decorator_style(self): + from compas_threejs.viewer import App + + app = App(websocket_port=9555) + + @app.on_create + def created(geometry): + pass + + self.assertIs(app.inbox.create_callbacks[-1], created) + self.assertIs(app.on_delete(print), print) + self.assertIs(app.inbox.delete_callbacks[-1], print) if __name__ == "__main__": From f4d522858b1a7ab859e9c050b5592b3e4bfec12e Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 13:36:46 +0200 Subject: [PATCH 3/4] docs(examples): add draw.py, reacting to geometry drawn in the viewer Uses App.on_create / App.on_delete: drawn polygons become 3 m rooms tagged with their floor area, with a running total; lines and polylines are colored by length; deleting or undoing a polygon removes its room. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + examples/draw.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 examples/draw.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c4423..74846a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Frontend drawing support, for tools such as `compas_threejs_draw`: `create_geometry` messages can create a `line`, `polyline`, `polygon` or three-point `arc` from a `points` list, and a `circle` from a location and radius (see `compas_threejs.viewer.drawing`). A `guid` in the message becomes the new object's guid. - `delete_geometry` messages remove an object, and `extrude_geometry` messages extrude a polygon into a new prism mesh, keeping the polygon. - `App.on_create` and `App.on_delete` register callbacks (usable as decorators) for objects the frontend creates or deletes. +- `examples/draw.py`: reacts to geometry drawn in the viewer - drawn polygons become rooms with their floor area, lines are colored by length. ### Changed diff --git a/examples/draw.py b/examples/draw.py new file mode 100644 index 0000000..9b61edb --- /dev/null +++ b/examples/draw.py @@ -0,0 +1,90 @@ +"""Reacting to geometry drawn in the viewer. + +Run this script, then open a viewer that has drawing tools - for example +compas_threejs_draw's dev page (`npm run dev` there, then reload it once this script +is running). Whatever you draw arrives here as a real COMPAS object, and this script +responds to it: + +- a drawn Polygon (or Rectangle) becomes the floor of a 3 m tall room, with a tag + showing its area; +- drawn lines and polylines are colored by length: green when short, red when long; +- a tag above the scene keeps the total floor area of all rooms. + +Deleting or undoing a polygon in the viewer removes its room and tag again. +""" + +from compas.colors import Color +from compas.geometry import Line +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyline + +from compas_threejs.materials import Material +from compas_threejs.tag import TextTag +from compas_threejs.viewer import App +from compas_threejs.viewer.drawing import extrude + +ROOM_HEIGHT = 3.0 +# Lines this long or longer are fully red; shorter ones blend from green. +LONG_LINE = 5.0 + +app = App() +app.set_view(Point(12, -18, 16), target=Point(0, 0, 0)) + +room_material = Material(color=Color.from_hex("#8fb3de"), transparent=True, opacity=0.6) +total_tag = TextTag(point=Point(0, 0, 6), text="Floor area: 0.00 m²", color=Color.black()) +app.add_tag(total_tag) + +# polygon guid -> (room mesh, area tag, floor area) +rooms = {} + + +def update_total(): + total = sum(area for _, _, area in rooms.values()) + total_tag.text = f"Floor area: {total:.2f} m² in {len(rooms)} room(s)" + app.update_tag(total_tag) + + +def add_room(polygon): + room = extrude(polygon, ROOM_HEIGHT) + if room is None: + return + area = polygon.area + tag = TextTag(point=polygon.centroid + [0, 0, ROOM_HEIGHT], text=f"{area:.2f} m²") + app.add_geometry(room, room_material) + app.add_tag(tag) + rooms[str(polygon.guid)] = (room, tag, area) + update_total() + + +def color_by_length(curve): + material = app.inbox.material_registry.get(str(curve.guid)) + if material is None: + return + t = min(curve.length / LONG_LINE, 1.0) + material.color = Color(t, 1.0 - t, 0.2) + app.update_material(material) + + +@app.on_create +def created(geometry): + print(f"drawn: {type(geometry).__name__}") + if isinstance(geometry, Polygon): + add_room(geometry) + elif isinstance(geometry, (Line, Polyline)): + color_by_length(geometry) + + +@app.on_delete +def deleted(geometry): + print(f"deleted: {type(geometry).__name__}") + entry = rooms.pop(str(geometry.guid), None) + if entry is None: + return + room, tag, _ = entry + app.remove_object(room) + app.remove_object(tag) + update_total() + + +app.start() From 36b2424efc1d6e5bdc6d8ac58b43db8f569aeca3 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 23 Sep 2026 14:15:12 +0200 Subject: [PATCH 4/4] feat: orient drawn boxes and circles by their drawing plane, extrude along the normal create_geometry takes optional xaxis/yaxis for boxes and circles, so shapes drawn on a vertical or tilted drawing plane keep its orientation. Extrude now follows the polygon's own normal (turned up for horizontal polygons, as before), so walls drawn on a vertical plane get their thickness. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/compas_threejs/viewer/drawing.py | 22 +++++++++++++++++++--- tests/test_create_geometry.py | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74846a1..1cb1acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Frontend drawing support, for tools such as `compas_threejs_draw`: `create_geometry` messages can create a `line`, `polyline`, `polygon` or three-point `arc` from a `points` list, and a `circle` from a location and radius (see `compas_threejs.viewer.drawing`). A `guid` in the message becomes the new object's guid. -- `delete_geometry` messages remove an object, and `extrude_geometry` messages extrude a polygon into a new prism mesh, keeping the polygon. +- `delete_geometry` messages remove an object, and `extrude_geometry` messages extrude a polygon along its normal into a new prism mesh, keeping the polygon. Boxes and circles take their frame axes (`xaxis`/`yaxis`) from the message, so shapes drawn on any drawing plane keep its orientation. - `App.on_create` and `App.on_delete` register callbacks (usable as decorators) for objects the frontend creates or deletes. - `examples/draw.py`: reacts to geometry drawn in the viewer - drawn polygons become rooms with their floor area, lines are colored by length. diff --git a/src/compas_threejs/viewer/drawing.py b/src/compas_threejs/viewer/drawing.py index 2024c26..a041fbc 100644 --- a/src/compas_threejs/viewer/drawing.py +++ b/src/compas_threejs/viewer/drawing.py @@ -85,7 +85,9 @@ def geometry_from_message(message): return None if any(value <= 0 for value in sizes.values()): return None - frame = Frame(location, [1, 0, 0], [0, 1, 0]) + frame = _frame(location, message.get("xaxis"), message.get("yaxis")) + if frame is None: + return None if type_name == "box": return Box(frame=frame, **sizes) if type_name == "sphere": @@ -93,6 +95,19 @@ def geometry_from_message(message): return Circle(frame=frame, **sizes) +def _frame(location, xaxis, yaxis): + """A frame at `location` with the message's axes - the drawing plane a box or + circle was drawn on - or world XY if it sent none. None if they are invalid.""" + if xaxis is None and yaxis is None: + return Frame(location, [1, 0, 0], [0, 1, 0]) + xaxis, yaxis = _point(xaxis), _point(yaxis) + if xaxis is None or yaxis is None: + return None + if Vector(*xaxis).cross(Vector(*yaxis)).length < _EPS: + return None + return Frame(location, xaxis, yaxis) + + def arc_through_points(start, end, through): """The arc from `start` to `end` passing through `through`, or None if the three points are collinear. @@ -142,7 +157,8 @@ def _profile_points(geometry): def extrude(geometry, height): """A closed prism mesh from a polygon (or closed polyline) extruded `height` along - world Z - or along its own normal if it is vertical - or None if it can't be. + its normal - turned to point up, so a horizontal polygon rises along world Z + whichever way its points run - or None if it can't be. """ points = _profile_points(geometry) if points is None or len(points) < 3 or abs(height) < _EPS: @@ -155,7 +171,7 @@ def extrude(geometry, height): if normal.length < _EPS: return None normal.unitize() - up = Vector(0, 0, 1) if abs(normal.z) > 1e-6 else normal + up = normal * -1 if normal.z < -1e-6 else normal direction = up * height # Wind the profile counterclockwise around the extrusion direction, so every face # of the prism ends up facing outward. diff --git a/tests/test_create_geometry.py b/tests/test_create_geometry.py index 3bd4616..8533fac 100644 --- a/tests/test_create_geometry.py +++ b/tests/test_create_geometry.py @@ -95,6 +95,28 @@ def test_single_location_types_still_work(self): [_, point] = self.create(type="point", point=[4, 5, 6]) self.assertIsInstance(point, Point) + def test_box_and_circle_on_a_drawing_plane(self): + axes = {"xaxis": [1, 0, 0], "yaxis": [0, 0, 1]} + [box] = self.create(type="box", point=[1, -0.5, 0.5], params={"xsize": 2, "ysize": 1, "zsize": 1}, **axes) + [_, circle] = self.create(type="circle", point=[5, 0, 5], params={"radius": 1}, **axes) + for frame in (box.frame, circle.frame): + self.assertEqual([round(v, 9) for v in frame.zaxis], [0.0, -1.0, 0.0]) + # Its corners sit on the XZ plane side, 1 deep toward -Y. + ys = sorted({round(p[1], 9) for p in box.points}) + self.assertEqual(ys, [-1.0, 0.0]) + + def test_invalid_axes_are_ignored(self): + self.create(type="box", point=[0, 0, 0], params={"xsize": 1, "ysize": 1, "zsize": 1}, xaxis=[1, 0, 0], yaxis=[2, 0, 0]) + self.create(type="circle", point=[0, 0, 0], params={"radius": 1}, xaxis=[1, 0]) + self.assertEqual(self.app.workspace.added, []) + + def test_extrude_a_vertical_polygon_along_its_normal(self): + # A 2 x 3 wall in the XZ plane, wound so its normal points to -Y. + prism = extrude(Polygon([[0, 0, 0], [2, 0, 0], [2, 0, 3], [0, 0, 3]]), 0.5) + self.assertAlmostEqual(prism.volume(), 3.0) + ys = sorted({round(prism.vertex_attribute(v, "y"), 9) for v in prism.vertices()}) + self.assertEqual(ys, [-0.5, 0.0]) + def test_invalid_input_is_ignored(self): self.create(type="line", points=[[0, 0, 0]]) self.create(type="line", points=[[0, 0, 0], [1, 1, 1], [2, 2, 2]])