Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions examples/draw.py
Original file line number Diff line number Diff line change
@@ -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()
44 changes: 44 additions & 0 deletions src/compas_threejs/viewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
198 changes: 198 additions & 0 deletions src/compas_threejs/viewer/drawing.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading