Skip to content

Commit 1a6dcf0

Browse files
Support pygfx text elements (#181)
* Support setting text/markdown on pygfx Text (#177) * Support pygfx text child nodes via proxy elements * Use pygfx text renderer API for text elements * Add interactive pygfx landmarks text-label example
1 parent a2a1e90 commit 1a6dcf0

4 files changed

Lines changed: 378 additions & 9 deletions

File tree

collagraph/renderers/pygfx_renderer.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22
from typing import Any, Callable
3+
from weakref import ref
34

45
import pygfx as gfx
56

@@ -9,6 +10,22 @@
910
DEFAULT_ATTR_CACHE = {}
1011

1112

13+
class _TextElementProxy(gfx.WorldObject):
14+
def __init__(self):
15+
super().__init__()
16+
self._cg_content = ""
17+
self._cg_parent_text_ref = None
18+
19+
@property
20+
def _cg_parent_text(self) -> gfx.Text | None:
21+
if self._cg_parent_text_ref is None:
22+
return None
23+
return self._cg_parent_text_ref()
24+
25+
def _cg_set_parent_text(self, parent: gfx.Text | None):
26+
self._cg_parent_text_ref = ref(parent) if parent else None
27+
28+
1229
class PygfxRenderer(Renderer):
1330
"""Renderer for Pygfx objects"""
1431

@@ -37,7 +54,8 @@ def register_asyncio(self):
3754

3855
def create_element(self, type: str) -> gfx.WorldObject:
3956
"""Create pygfx element for the given type"""
40-
type = type.lower().replace("-", "")
57+
type = type.lower().replace("-", "").replace("_", "")
58+
4159
if element_type := ELEMENT_TYPE_CACHE.get(type):
4260
self._trigger()
4361
return element_type()
@@ -53,27 +71,53 @@ def create_element(self, type: str) -> gfx.WorldObject:
5371
raise ValueError(f"Can't create element of type: {type}")
5472

5573
def create_text_element(self):
56-
raise NotImplementedError
74+
self._trigger()
75+
return _TextElementProxy()
5776

5877
def insert(
5978
self,
6079
el: gfx.WorldObject,
6180
parent: gfx.WorldObject,
6281
anchor: gfx.WorldObject | None = None,
6382
):
83+
is_text_proxy = isinstance(el, _TextElementProxy)
6484
try:
6585
parent.add(el, before=anchor)
6686
except ValueError:
6787
warnings.warn(f"Could not find anchor in {parent}")
6888
parent.add(el)
89+
90+
if is_text_proxy and isinstance(parent, gfx.Text):
91+
el._cg_set_parent_text(parent)
92+
self._sync_text_from_proxy_children(parent)
93+
return
94+
6995
self._trigger()
7096

7197
def remove(self, el: gfx.WorldObject, parent: gfx.WorldObject):
7298
parent.remove(el)
99+
100+
if isinstance(el, _TextElementProxy):
101+
if isinstance(parent, gfx.Text):
102+
self._sync_text_from_proxy_children(parent)
103+
el._cg_set_parent_text(None)
104+
return
105+
73106
self._trigger()
74107

75108
def set_element_text(self, el, value: str):
76-
raise NotImplementedError
109+
el._cg_content = value
110+
if parent := el._cg_parent_text:
111+
self._sync_text_from_proxy_children(parent)
112+
113+
def _sync_text_from_proxy_children(self, parent: gfx.Text):
114+
content = "".join(
115+
child._cg_content
116+
for child in parent.children
117+
if isinstance(child, _TextElementProxy)
118+
)
119+
parent.set_text(content)
120+
self._trigger()
77121

78122
def set_attribute(self, el: gfx.WorldObject, attr: str, value: Any):
79123
key = f"{type(el).__name__}.{attr}"
@@ -84,6 +128,16 @@ def set_attribute(self, el: gfx.WorldObject, attr: str, value: Any):
84128
for attribute in attrs:
85129
el = getattr(el, attribute)
86130

131+
if isinstance(el, gfx.Text):
132+
if attr == "text":
133+
el.set_text(value)
134+
self._trigger()
135+
return
136+
if attr == "markdown":
137+
el.set_markdown(value)
138+
self._trigger()
139+
return
140+
87141
if key not in DEFAULT_ATTR_CACHE:
88142
if hasattr(el, attr):
89143
default_value = getattr(el, attr)

examples/pygfx/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ uv run collagraph --renderer pygfx --state '{"count": 100}' examples/pygfx/point
1616
Shows a point cloud. Adjust the count to see how far you can push it.
1717

1818

19+
## Landmarks example
20+
21+
```shell
22+
uv run collagraph --renderer pygfx --state '{"count": 25}' examples/pygfx/landmarks.cgx
23+
```
24+
Shows random 3D landmarks with labels rendered as child text nodes of `<text>` elements.
25+
Click a landmark sphere to increment and rename its label while highlighting both sphere and text color.
26+
27+
1928
## Component example
2029

2130
```shell

examples/pygfx/landmarks.cgx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<!--
2+
Run this example as follows:
3+
uv run collagraph --renderer pygfx --state '{"count": 25}' examples/pygfx/landmarks.cgx
4+
-->
5+
<ambient-light />
6+
<point-light :local.position="(12, 18, 10)" />
7+
<group v-for="idx, landmark in enumerate(landmarks)">
8+
<group :local.position="landmark['position']">
9+
<mesh
10+
:geometry="sphere_geometry"
11+
:material="selected_sphere_material if idx == selected else sphere_material"
12+
@click="lambda ev: click_landmark(idx)"
13+
/>
14+
<text
15+
anchor="bottom-center"
16+
:font_size="14"
17+
:local.position="(0, 0.75, 0)"
18+
:material="selected_label_material if idx == selected else label_material"
19+
screen_space
20+
>
21+
{{landmark['name']}}
22+
#{{idx}}
23+
</text>
24+
</group>
25+
</group>
26+
27+
<script>
28+
import random
29+
30+
import collagraph as cg
31+
import pygfx as gfx
32+
33+
34+
sphere_geometry = gfx.sphere_geometry(radius=0.35)
35+
sphere_material = gfx.MeshPhongMaterial(color=[0.2, 0.8, 1.0], pick_write=True)
36+
selected_sphere_material = gfx.MeshPhongMaterial(color=[1.0, 0.4, 0.2], pick_write=True)
37+
38+
label_material = gfx.TextMaterial(color="#D6F4FF")
39+
selected_label_material = gfx.TextMaterial(color="#FFE066")
40+
41+
LANDMARK_NAMES = [
42+
"Harbor",
43+
"Bridge",
44+
"Museum",
45+
"Library",
46+
"Station",
47+
"Observatory",
48+
"Garden",
49+
"Square",
50+
"Market",
51+
"Beacon",
52+
]
53+
54+
55+
def rand_position():
56+
return (
57+
round(random.uniform(-12.0, 12.0), 2),
58+
round(random.uniform(-8.0, 8.0), 2),
59+
round(random.uniform(-12.0, 12.0), 2),
60+
)
61+
62+
63+
class Landmarks(cg.Component):
64+
def init(self):
65+
random.seed(42)
66+
count = int(self.props.get("count", 20))
67+
self.state["selected"] = -1
68+
self.state["landmarks"] = [
69+
{
70+
"base_name": LANDMARK_NAMES[idx % len(LANDMARK_NAMES)],
71+
"name": LANDMARK_NAMES[idx % len(LANDMARK_NAMES)],
72+
"position": rand_position(),
73+
"clicks": 0,
74+
}
75+
for idx in range(count)
76+
]
77+
78+
def click_landmark(self, idx):
79+
landmark = self.state["landmarks"][idx]
80+
landmark["clicks"] += 1
81+
landmark["name"] = f"{landmark['base_name']} ({landmark['clicks']})"
82+
self.state["selected"] = idx
83+
</script>
84+

0 commit comments

Comments
 (0)