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