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
24import math
25
26from simvx.core import (
27 Camera3D,
28 CSGBox3D,
29 CSGCombiner3D,
30 CSGOperation,
31 CSGSphere3D,
32 DirectionalLight3D,
33 Input,
34 InputMap,
35 Key,
36 Material,
37 MeshInstance3D,
38 MouseButton,
39 Node,
40 Property,
41 Text2D,
42 Vec3,
43)
44from simvx.graphics import App
45
46
47class CSGDemo(Node):
48 """Three side-by-side CSG boolean operation results with orbit camera."""
49
50 orbit_speed = Property(60.0, range=(10, 120))
51 cam_distance = Property(12.0, range=(5, 30))
52
53 def on_ready(self):
54 # Input actions (registered here so web export picks them up;
55 # module-level / __main__ registrations are skipped by WebApp).
56 InputMap.add_action("orbit_left", [Key.LEFT])
57 InputMap.add_action("orbit_right", [Key.RIGHT])
58 InputMap.add_action("orbit_up", [Key.UP])
59 InputMap.add_action("orbit_down", [Key.DOWN])
60 InputMap.add_action("quit", [Key.ESCAPE])
61
62 # Camera
63 self._yaw = 30.0
64 self._pitch = 25.0
65 self._cam = self.add_child(Camera3D(name="Camera", fov=55, near=0.1, far=100.0))
66
67 # Lighting
68 sun = self.add_child(DirectionalLight3D(name="Sun", intensity=1.2))
69 sun.look_at((-1.0, -2.0, -1.0))
70 fill = self.add_child(DirectionalLight3D(name="Fill", intensity=0.4, colour=(0.5, 0.6, 1.0)))
71 fill.look_at((1.0, -1.0, 2.0))
72
73 # Build the three CSG demos: same box + sphere pair, one boolean each.
74 spacing = 5.0
75 self._build(CSGOperation.UNION, "Union", (0.2, 0.6, 0.9, 1.0), Vec3(-spacing, 0, 0))
76 self._build(CSGOperation.SUBTRACT, "Subtract", (0.9, 0.3, 0.2, 1.0), Vec3(0, 0, 0))
77 self._build(CSGOperation.INTERSECT, "Intersect", (0.2, 0.8, 0.3, 1.0), Vec3(spacing, 0, 0))
78
79 # Labels (positions come from the live window size in _layout_labels)
80 self.add_child(Text2D(name="Title", text="CSG Boolean Operations", position=(10, 10), font_scale=1.5))
81 self._hint = self.add_child(
82 Text2D(name="Controls", text="Drag or arrow keys: orbit camera | Esc: quit", font_scale=1.1)
83 )
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(self, operation: CSGOperation, name: str, colour: tuple, position: Vec3):
107 """Combine a box and a sphere with *operation* and add the result.
108
109 The sphere carries the interesting operation; the box is always a UNION
110 because the first shape in a combiner just seeds the volume. The
111 combiner itself never enters the tree: reading ``combiner.mesh``
112 evaluates the boolean and hands back a plain Mesh to render.
113 """
114 combiner = CSGCombiner3D()
115 box = CSGBox3D(size=Vec3(2, 2, 2))
116 box.operation = CSGOperation.UNION
117 combiner.add_child(box)
118 sphere = CSGSphere3D(radius=1.3, rings=20, sectors=20)
119 sphere.operation = operation
120 combiner.add_child(sphere)
121
122 material = Material(colour=colour, roughness=0.35, metallic=0.3)
123 self.add_child(MeshInstance3D(name=name, mesh=combiner.mesh, material=material, position=position))
124
125 def _update_camera(self):
126 yaw_rad = math.radians(self._yaw)
127 pitch_rad = math.radians(self._pitch)
128 cp = math.cos(pitch_rad)
129 x = self.cam_distance * cp * math.sin(yaw_rad)
130 y = self.cam_distance * math.sin(pitch_rad)
131 z = self.cam_distance * cp * math.cos(yaw_rad)
132 self._cam.position = Vec3(x, y, z)
133 self._cam.look_at(Vec3(0, 0, 0))
134
135 def on_update(self, dt):
136 if Input.is_action_just_pressed("quit"):
137 self.app.quit()
138 return
139 if Input.is_action_pressed("orbit_left"):
140 self._yaw += self.orbit_speed * dt
141 self._auto_orbit = False
142 if Input.is_action_pressed("orbit_right"):
143 self._yaw -= self.orbit_speed * dt
144 self._auto_orbit = False
145 if Input.is_action_pressed("orbit_up"):
146 self._pitch = min(80.0, self._pitch + self.orbit_speed * dt)
147 self._auto_orbit = False
148 if Input.is_action_pressed("orbit_down"):
149 self._pitch = max(-10.0, self._pitch - self.orbit_speed * dt)
150 self._auto_orbit = False
151
152 # Mouse / touch drag orbit (touch arrives as MouseButton.LEFT on web)
153 if Input.is_mouse_button_pressed(MouseButton.LEFT):
154 delta = Input.mouse_delta
155 dx, dy = float(delta.x), float(delta.y)
156 if abs(dx) > 0.1 or abs(dy) > 0.1:
157 self._auto_orbit = False
158 self._yaw -= dx * 0.3
159 self._pitch = max(-10.0, min(80.0, self._pitch + dy * 0.3))
160
161 # Gentle idle spin until the first user input
162 if self._auto_orbit:
163 self._yaw += 8.0 * dt
164
165 self._layout_labels()
166 self._update_camera()
167
168
169if __name__ == "__main__":
170
171 App(title="CSG Boolean Operations", width=1280, height=720).run(CSGDemo())