CSG¶
Constructive Solid Geometry boolean operations on 3D shapes.
▶ Run in browserTags: 3d
Demonstrates:
CSG Union: combining two shapes into one solid
CSG Subtract: punching a hole through a shape
CSG Intersect: keeping only the overlapping volume
CSGCombiner3D generating meshes from boolean operations
Camera orbit with mouse/touch drag or arrow keys
The camera orbits slowly on its own until the first user input.
Controls: Drag (mouse / touch) - Orbit camera Arrow keys - Orbit camera Escape - Quit
Run: uv run python examples/features/3d/csg.py
Source¶
1"""CSG: Constructive Solid Geometry boolean operations on 3D shapes.
2
3# /// simvx
4# web = { width = 1280, height = 720 }
5# ///
6
7Demonstrates:
8 - CSG Union: combining two shapes into one solid
9 - CSG Subtract: punching a hole through a shape
10 - CSG Intersect: keeping only the overlapping volume
11 - CSGCombiner3D generating meshes from boolean operations
12 - Camera orbit with mouse/touch drag or arrow keys
13
14The camera orbits slowly on its own until the first user input.
15
16Controls:
17 Drag (mouse / touch) - Orbit camera
18 Arrow keys - Orbit camera
19 Escape - Quit
20
21Run: uv run python examples/features/3d/csg.py
22"""
23
24
25import math
26
27from simvx.core import (
28 Camera3D,
29 CSGBox3D,
30 CSGCombiner3D,
31 CSGOperation,
32 CSGSphere3D,
33 DirectionalLight3D,
34 Input,
35 InputMap,
36 Key,
37 Material,
38 MeshInstance3D,
39 MouseButton,
40 Node,
41 Property,
42 Text2D,
43 Vec3,
44)
45from simvx.graphics import App
46
47
48class CSGDemo(Node):
49 """Three side-by-side CSG boolean operation results with orbit camera."""
50
51 orbit_speed = Property(60.0, range=(10, 120))
52 cam_distance = Property(12.0, range=(5, 30))
53
54 def on_ready(self):
55 # Input actions (registered here so web export picks them up;
56 # module-level / __main__ registrations are skipped by WebApp).
57 InputMap.add_action("orbit_left", [Key.LEFT])
58 InputMap.add_action("orbit_right", [Key.RIGHT])
59 InputMap.add_action("orbit_up", [Key.UP])
60 InputMap.add_action("orbit_down", [Key.DOWN])
61 InputMap.add_action("quit", [Key.ESCAPE])
62
63 # Camera
64 self._yaw = 30.0
65 self._pitch = 25.0
66 self._cam = self.add_child(Camera3D(name="Camera", fov=55, near=0.1, far=100.0))
67
68 # Lighting
69 sun = self.add_child(DirectionalLight3D(name="Sun", intensity=1.2))
70 sun.look_at((-1.0, -2.0, -1.0))
71 fill = self.add_child(DirectionalLight3D(name="Fill", intensity=0.4, colour=(0.5, 0.6, 1.0)))
72 fill.look_at((1.0, -1.0, 2.0))
73
74 # Build the three CSG demos
75 spacing = 5.0
76 self._build_union(position=Vec3(-spacing, 0, 0))
77 self._build_subtract(position=Vec3(0, 0, 0))
78 self._build_intersect(position=Vec3(spacing, 0, 0))
79
80 # Labels (positions come from the live window size in _layout_labels)
81 self.add_child(Text2D(name="Title", text="CSG Boolean Operations", position=(10, 10), font_scale=1.5))
82 self._hint = self.add_child(
83 Text2D(name="Controls", text="Drag or arrow keys: orbit camera | Esc: quit", font_scale=1.1))
84 self._labels = [
85 self.add_child(Text2D(name="LblUnion", text="UNION", font_scale=1.3, align="centre")),
86 self.add_child(Text2D(name="LblSubtract", text="SUBTRACT", font_scale=1.3, align="centre")),
87 self.add_child(Text2D(name="LblIntersect", text="INTERSECT", font_scale=1.3, align="centre")),
88 ]
89 self._win_size = None
90 self._auto_orbit = True
91
92 self._layout_labels()
93 self._update_camera()
94
95 def _layout_labels(self):
96 """Anchor the controls hint and column labels to the live window size."""
97 size = (self.app.width, self.app.height)
98 if size == self._win_size:
99 return
100 self._win_size = size
101 w, h = size
102 self._hint.position = (10, h - 30)
103 for label, fx in zip(self._labels, (0.25, 0.5, 0.75), strict=True):
104 label.position = (w * fx, 50)
105
106 def _build_union(self, position: Vec3):
107 """Box + Sphere combined (both UNION)."""
108 combiner = CSGCombiner3D()
109 box = CSGBox3D(size=Vec3(2, 2, 2))
110 box.operation = CSGOperation.UNION
111 combiner.add_child(box)
112 sphere = CSGSphere3D(radius=1.3, rings=20, sectors=20)
113 sphere.operation = CSGOperation.UNION
114 combiner.add_child(sphere)
115 mesh = combiner.mesh
116 mat = Material(colour=(0.2, 0.6, 0.9, 1.0), roughness=0.4, metallic=0.2)
117 mi = MeshInstance3D(name="Union", mesh=mesh, material=mat, position=position)
118 self.add_child(mi)
119
120 def _build_subtract(self, position: Vec3):
121 """Box with sphere hole punched through it."""
122 combiner = CSGCombiner3D()
123 box = CSGBox3D(size=Vec3(2, 2, 2))
124 box.operation = CSGOperation.UNION
125 combiner.add_child(box)
126 sphere = CSGSphere3D(radius=1.3, rings=20, sectors=20)
127 sphere.operation = CSGOperation.SUBTRACT
128 combiner.add_child(sphere)
129 mesh = combiner.mesh
130 mat = Material(colour=(0.9, 0.3, 0.2, 1.0), roughness=0.35, metallic=0.3)
131 mi = MeshInstance3D(name="Subtract", mesh=mesh, material=mat, position=position)
132 self.add_child(mi)
133
134 def _build_intersect(self, position: Vec3):
135 """Only the volume where box and sphere overlap."""
136 combiner = CSGCombiner3D()
137 box = CSGBox3D(size=Vec3(2, 2, 2))
138 box.operation = CSGOperation.UNION
139 combiner.add_child(box)
140 sphere = CSGSphere3D(radius=1.3, rings=20, sectors=20)
141 sphere.operation = CSGOperation.INTERSECT
142 combiner.add_child(sphere)
143 mesh = combiner.mesh
144 mat = Material(colour=(0.2, 0.8, 0.3, 1.0), roughness=0.3, metallic=0.4)
145 mi = MeshInstance3D(name="Intersect", mesh=mesh, material=mat, position=position)
146 self.add_child(mi)
147
148 def _update_camera(self):
149 yaw_rad = math.radians(self._yaw)
150 pitch_rad = math.radians(self._pitch)
151 cp = math.cos(pitch_rad)
152 x = self.cam_distance * cp * math.sin(yaw_rad)
153 y = self.cam_distance * math.sin(pitch_rad)
154 z = self.cam_distance * cp * math.cos(yaw_rad)
155 self._cam.position = Vec3(x, y, z)
156 self._cam.look_at(Vec3(0, 0, 0))
157
158 def on_update(self, dt):
159 if Input.is_action_just_pressed("quit"):
160 self.app.quit()
161 return
162 if Input.is_action_pressed("orbit_left"):
163 self._yaw += self.orbit_speed * dt
164 self._auto_orbit = False
165 if Input.is_action_pressed("orbit_right"):
166 self._yaw -= self.orbit_speed * dt
167 self._auto_orbit = False
168 if Input.is_action_pressed("orbit_up"):
169 self._pitch = min(80.0, self._pitch + self.orbit_speed * dt)
170 self._auto_orbit = False
171 if Input.is_action_pressed("orbit_down"):
172 self._pitch = max(-10.0, self._pitch - self.orbit_speed * dt)
173 self._auto_orbit = False
174
175 # Mouse / touch drag orbit (touch arrives as MouseButton.LEFT on web)
176 if Input.is_mouse_button_pressed(MouseButton.LEFT):
177 delta = Input.mouse_delta
178 dx, dy = float(delta.x), float(delta.y)
179 if abs(dx) > 0.1 or abs(dy) > 0.1:
180 self._auto_orbit = False
181 self._yaw -= dx * 0.3
182 self._pitch = max(-10.0, min(80.0, self._pitch + dy * 0.3))
183
184 # Gentle idle spin until the first user input
185 if self._auto_orbit:
186 self._yaw += 8.0 * dt
187
188 self._layout_labels()
189 self._update_camera()
190
191
192if __name__ == "__main__":
193
194 App(title="CSG Boolean Operations", width=1280, height=720).run(CSGDemo())