CSG

Constructive Solid Geometry boolean operations on 3D shapes.

▶ Run in browser

Tags: 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())