Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Bidirectional sync between the frontend and live backend COMPAS objects: gizmo drags/rotations (`object_transform`), toolbar-added geometry (`create_geometry`), and material edits (`material_edit`) now mutate the same live objects a running script sees, instead of only flowing updates one way. See `src/compas_threejs/viewer/BIDIRECTIONAL_SYNC.md`.
- `Workspace.transform_geometry(geometry, transformation)`: applies a `compas.geometry.Transformation` (or `Translation`/`Rotation`) to an existing geometry and sends only the small transform matrix to the frontend, instead of re-sending the full geometry - useful for moving/rotating large meshes without a full re-serialize on every update.

### Changed

- Bumped `compas-pb` to 1.2.0 (within the existing `>=1,<2` constraint), matching the version the bundled frontend's `compas-pb-ts` was upgraded to.
Expand Down
50 changes: 50 additions & 0 deletions examples/transform_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from compas.colors import Color
from compas.geometry import Box
from compas.geometry import Frame
from compas.geometry import Rotation
from compas.geometry import Sphere
from compas.geometry import Translation

from compas_threejs.materials import Material
from compas_threejs.ui import Button
from compas_threejs.viewer import App

viz = App()
viz.default_lighting = True
viz.show_edges = True
viz.world_axis = False

# A mesh with enough vertices that re-sending the full geometry on every update would be
# wasteful. transform_geometry() sends only a small transform matrix to the frontend
# instead, and mutates this exact mesh instance in place - so `mesh` always reflects its
# true current position, and a running loop like the one below never drifts out of sync
# with what's on screen. See BIDIRECTIONAL_SYNC.md for the full picture.
center = Frame([-3, 0, 0], [1, 0, 0], [0, 1, 0])
sphere = Sphere(1.5, center)
mesh = sphere.to_mesh(False, 32, 32)
viz.add_geometry(mesh, Material(color=Color.azure()))

box = Box(1, 1, 1, Frame([3, 0, 0], [1, 0, 0], [0, 1, 0]))
viz.add_geometry(box, Material(color=Color.red()))


def loop(time):
# Spins the mesh continuously around its own center. Each tick sends only a tiny
# {"dispatch": "handle_geometry", "type": "apply_transform", ...} message, not the
# whole (thousands-of-vertices) mesh.
viz.transform_geometry(mesh, Rotation.from_axis_and_angle([0, 0, 1], 0.02, center.point))


viz.loop = loop


def move_box():
# Translation and Rotation are both COMPAS subclasses of Transformation, so either
# can be passed directly to transform_geometry().
viz.transform_geometry(box, Translation.from_vector([0, 0.5, 0]))


button = Button(text="Move box +Y", action=move_box, label="Transform")
viz.add_ui_element(button)

viz.start()
296 changes: 296 additions & 0 deletions src/compas_threejs/viewer/BIDIRECTIONAL_SYNC.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/compas_threejs/viewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def __init__(self, host: str = "127.0.0.1", websocket_port: int = 9001, frontend

self.server = AppServer(frontend_dir=frontend_dir)
self.outbox = Outbox(self.server)
self.inbox = Inbox()
self.inbox = Inbox(self)

# Setter Attributes
self._loop_interval = 0.01
Expand Down Expand Up @@ -392,6 +392,11 @@ def update_geometry(self, geometry):
"""Updates an existing geometry object in the main workspace."""
self.main.update_geometry(geometry)

def transform_geometry(self, geometry, transformation):
"""Applies a transformation to an existing geometry object in the main workspace.
See `Workspace.transform_geometry`."""
self.main.transform_geometry(geometry, transformation)

def remove_object(self, geometry):
"""Removes a geometry object from the main workspace."""
self.main.remove_object(geometry)
Expand Down
131 changes: 130 additions & 1 deletion src/compas_threejs/viewer/inbox.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
import json
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

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."""

def __init__(self):
def __init__(self, app=None):
self.app = app # back-reference to the owning App, used to reach Workspace.update_geometry
self.buttons = dict()
self.geometry_registry = dict()
self.material_registry = dict()
self.metadata_registry = dict()
self.object_actions_registry = dict()
self.brep_viewmesh_registry = dict() # brep_id -> viewmesh
self.action_registry = dict() # action name -> callable, for manually-registered actions
self.lock = threading.Lock() # guards geometry mutation against concurrent App.loop callbacks

self._handlers = {
"ui_callback": self._handle_ui_callback,
"object_picked": self._handle_object_picked,
"object_action_callback": self._handle_object_action_callback,
"loaded_json": self._handle_loaded_json,
"other_action": self._handle_other_action,
"object_transform": self._handle_object_transform,
"create_geometry": self._handle_create_geometry,
"material_edit": self._handle_material_edit,
}

# ---- REGISTRATION (called by Workspace when something is sent out) -------------------------
Expand All @@ -36,6 +59,9 @@ def register_geometry(self, obj_id, geometry, metadata=None, actions=None):
for action in actions:
self.buttons[str(action.guid)] = action.action

def register_material(self, obj_id, material):
self.material_registry[str(obj_id)] = material

def register_button(self, guid, action):
self.buttons[guid] = action

Expand Down Expand Up @@ -147,6 +173,109 @@ def _handle_object_action_callback(self, message, outbox, workspace_id):
else:
console.log(f"[yellow]Unrecognized action or missing handler for action ID: {action_id}[/yellow]")

def _handle_object_transform(self, message, outbox, workspace_id):
"""Applies a gizmo edit made in the frontend to the corresponding live backend object.

`matrix` is a delta transform (a 4x4 nested list, row-major) - the frontend computes
it as (matrix after drag) * (matrix before drag)^-1, so it represents the world-space
change the drag applied, regardless of where the object started. Applying it in place
(rather than replacing the registered object) means anything else still mutating this
same object - e.g. a `App.loop` callback rotating it every frame - continues from the
new position instead of the object snapping back.
"""
guid = message.get("guid")
matrix = message.get("matrix")
console.log(f"[blue]Received object transform from frontend. Object ID: {guid}[/blue]")
geometry = self.geometry_registry.get(guid)
if geometry is None or matrix is None:
console.log(f"[yellow]Unrecognized object_transform target: {guid}[/yellow]")
return

transformation = Transformation.from_matrix(matrix)
with self.lock:
geometry.transform(transformation)

if self.app is not None:
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.

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.
"""
type_name = message.get("type")
entry = _CREATABLE_TYPES.get(type_name)
if entry is None:
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]")
return

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_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.

Reuses `Workspace.update_material` for the outbound side - the same method
`examples/objects_action.py`'s "Make it blue" action already calls - so an edit made
here and a script-driven update both end up mutating and broadcasting through the
same live Material instance rather than drifting out of sync.
"""
guid = message.get("guid")
if self.geometry_registry.get(guid) is None or self.app is None:
console.log(f"[yellow]Unrecognized material_edit target: {guid}[/yellow]")
return

material = self.material_registry.get(guid)
if material is None:
material = Material()
material._geometry_guid = str(guid)
self.material_registry[guid] = material

updates = {}
if "color" in message:
try:
updates["color"] = Color.from_hex(message["color"])
except (TypeError, ValueError) as error:
console.log(f"[yellow]Ignoring invalid material_edit color for {guid}: {error}[/yellow]")
return
if "metalness" in message:
updates["metalness"] = message["metalness"]
if "roughness" in message:
updates["roughness"] = message["roughness"]

# Apply through the property setters (which validate ranges) against a rollback
# snapshot, so a single invalid field can't leave `material` half-updated while
# still skipping the broadcast below - either every field commits, or none do.
original = {name: getattr(material, name) for name in updates}
try:
for name, value in updates.items():
setattr(material, name, value)
except (TypeError, ValueError) as error:
for name, value in original.items():
setattr(material, name, value)
console.log(f"[yellow]Ignoring invalid material_edit for {guid}: {error}[/yellow]")
return

self.app.get_workspace(workspace_id).update_material(material)

def _handle_loaded_json(self, message, outbox, workspace_id):
console.log("[blue]Received loaded JSON from frontend.[/blue]")
json_data = message.get("json_data")
Expand Down
16 changes: 11 additions & 5 deletions src/compas_threejs/viewer/outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,27 @@ def send_bytes(
persist: bool = True,
workspace_id: str = "main",
remove_key=None,
broadcast: bool = True,
):
"""Sends raw binary data now if the server is running, otherwise queues it.

`remove_key`, if given, tells the server to drop that key from its persisted scene
state - used so a removed object's earlier "add" broadcast isn't replayed to clients
that connect (or reconnect) after the removal.

`broadcast`, if False, still persists `binary_data` under `obj_id` for future
reconnects but skips sending it to clients already connected - used to silently
refresh the reconnect-replay snapshot after already notifying live clients through
a smaller message (see `Workspace.transform_geometry`).
"""
loop = self.server.get_loop()
if loop:
asyncio.run_coroutine_threadsafe(
self.server.broadcast(binary_data, obj_id, persist=persist, workspace_id=workspace_id, remove_key=remove_key),
self.server.broadcast(binary_data, obj_id, persist=persist, workspace_id=workspace_id, remove_key=remove_key, broadcast=broadcast),
loop,
)
else:
self._queue.append((binary_data, obj_id, persist, workspace_id, remove_key))
self._queue.append((binary_data, obj_id, persist, workspace_id, remove_key, broadcast))

def send_dict(self, message: dict, *, workspace_id: str = "main", remove_key=None, obj_id=None):
"""Serializes a dictionary message and sends it.
Expand Down Expand Up @@ -64,16 +70,16 @@ def forget(self, key, *, workspace_id: str = "main"):
loop,
)
else:
self._queue.append((None, "", False, workspace_id, key))
self._queue.append((None, "", False, workspace_id, key, True))

def flush(self):
"""Sends any messages that were queued before the server was ready."""
loop = self.server.get_loop()
if not loop:
return
for binary_data, obj_id, persist, workspace_id, remove_key in self._queue:
for binary_data, obj_id, persist, workspace_id, remove_key, broadcast in self._queue:
asyncio.run_coroutine_threadsafe(
self.server.broadcast(binary_data, obj_id, persist=persist, workspace_id=workspace_id, remove_key=remove_key),
self.server.broadcast(binary_data, obj_id, persist=persist, workspace_id=workspace_id, remove_key=remove_key, broadcast=broadcast),
loop,
)
self._queue.clear()
11 changes: 10 additions & 1 deletion src/compas_threejs/viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,14 @@ async def broadcast(
persist: bool = True,
workspace_id: str = "main",
remove_key=None,
broadcast: bool = True,
):
"""Broadcast binary data or dictionary configurations cleanly to workspace clients."""
"""Broadcast binary data or dictionary configurations cleanly to workspace clients.

`broadcast`, if False, still applies the persist/remove_key bookkeeping above but
skips sending anything to already-connected clients - used to silently refresh a
persisted snapshot for future reconnects only (see `Outbox.send_bytes`).
"""
if remove_key is not None:
# Drops the object's earlier persisted "add" broadcast so it isn't replayed to
# clients that connect (or reconnect) after this removal.
Expand All @@ -113,6 +119,9 @@ async def broadcast(
# Pure state cleanup (see Outbox.forget) - nothing to broadcast live.
return

if not broadcast:
return

target_clients = self.workspaces[workspace_id]
if not target_clients:
return
Expand Down
40 changes: 40 additions & 0 deletions src/compas_threejs/viewer/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import compas_pb
from compas.colors import Color
from compas.geometry import Point
from compas.geometry import Transformation
from compas_brep import Brep
from rich.console import Console

Expand Down Expand Up @@ -317,6 +318,7 @@ def add_geometry(self, geometry, material=None, metadata=None, actions=None):

if material:
material._geometry_guid = str(obj_id)
self.app.inbox.register_material(obj_id, material)
material_dict = material.as_dict()
material_dict["geometryBackendGuid"] = str(obj_id)
material_data = compas_pb.pb_dump_bts(material_dict)
Expand Down Expand Up @@ -354,6 +356,44 @@ def update_geometry(self, geometry):
binary_data = compas_pb.pb_dump_bts(geometry)
self.app.outbox.send_bytes(binary_data, obj_id, workspace_id=self.workspace_id)

def transform_geometry(self, geometry, transformation: Transformation):
"""
Applies a transformation to an existing geometry object and sends only the
transform to the frontend, instead of re-sending the full geometry.

Parameters
----------
geometry : compas.geometry.Geometry | compas.datastructures.Mesh
The geometry object to transform. Must already have been added via
`add_geometry`.
transformation : compas.geometry.Transformation
The transformation to apply. `Translation` and `Rotation` are also
accepted, since both are subclasses of `Transformation`.
"""
obj_id = geometry.guid

if isinstance(geometry, Brep):
# A Brep's displayed viewmesh is a cached mesh generated independently
# of the Brep's own frame (see add_geometry/update_geometry) - a rigid
# transform on the Brep doesn't rigidly move that cached mesh, so
# there's no lightweight path here; fall back to a full regenerate+resend.
geometry.transform(transformation)
self.update_geometry(geometry)
return

geometry.transform(transformation)

self.app.outbox.send_dict(
{"dispatch": "handle_geometry", "type": "apply_transform", "guid": str(obj_id), "matrix": transformation.matrix},
workspace_id=self.workspace_id,
)
# Refresh the reconnect-replay snapshot to the new position too, so a client
# that connects after this call sees the object where it actually is -
# without re-broadcasting the full geometry to clients that already applied
# the small delta above live.
binary_data = compas_pb.pb_dump_bts(geometry)
self.app.outbox.send_bytes(binary_data, obj_id, workspace_id=self.workspace_id, broadcast=False)

def remove_object(self, geometry):
"""Removes a geometry object from this workspace."""
# If the geometry is a Brep, remove its viewmesh from the registry
Expand Down
Loading