diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7a824..1cb1acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ 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 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. + ### Changed ### Removed 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() 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..a041fbc --- /dev/null +++ b/src/compas_threejs/viewer/drawing.py @@ -0,0 +1,198 @@ +"""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, 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": + return Sphere(frame=frame, **sizes) + 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. + + 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 + 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: + 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 = 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. + 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 4502971..c6e79d1 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -2,25 +2,14 @@ import threading from compas.colors import Color -from compas.geometry import Box -from compas.geometry import Frame -from compas.geometry import Point -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, ()), -} - class Inbox: """Routes messages coming in from the frontend and owns the registries needed to resolve them.""" @@ -44,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) ------------------------- @@ -199,35 +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. + """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") - 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) + 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) - console.log(f"[blue]Creating {type_name} from frontend at {point}[/blue]") + def _add_created(self, geometry, guid, label, workspace_id): + if self.app is None: + console.log("[yellow]Frontend geometry received but Inbox has no App reference[/yellow]") + return + 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 new file mode 100644 index 0000000..8533fac --- /dev/null +++ b/tests/test_create_geometry.py @@ -0,0 +1,208 @@ +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, 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.inbox = Inbox(self) + self.workspace = _Workspace(self.inbox) + + def get_workspace(self, workspace_id): + return self.workspace + + +class CreateGeometryTests(unittest.TestCase): + def setUp(self): + self.app = _App() + self.inbox = self.app.inbox + + 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) + 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_is_a_real_polygon(self): + [polygon] = self.create(type="polygon", points=[[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + 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_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]]) + 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_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__": + unittest.main()