sculpt/
sculpt.rs

1//! Interactive 3D sculpting example with smooth brushes.
2//!
3//! Controls:
4//! - Middle click + drag: Rotate camera
5//! - Right click (hold): Smooth add material
6//! - Middle click (hold): Smooth remove material  
7//! - Shift + Right click: Hard add (CSG union)
8//! - Shift + Left click: Hard remove (CSG subtract)
9//! - B: Toggle blur/smooth brush
10//! - Scroll wheel: Adjust brush size
11//! - [ / ]: Adjust brush strength
12//! - WASD/Space/Shift: Move camera
13
14use bevy::{
15    input::mouse::{MouseMotion, MouseWheel},
16    prelude::*,
17    window::PrimaryWindow,
18};
19use bevy_sculpter::prelude::*;
20use chunky_bevy::prelude::*;
21
22fn main() {
23    App::new()
24        .add_plugins(DefaultPlugins)
25        .add_plugins(SurfaceNetsPlugin)
26        .insert_resource(DensityFieldMeshSize(vec3(10., 10., 10.)))
27        .init_resource::<SculptBrush>()
28        .add_systems(Startup, (setup, show_chunks))
29        .add_systems(
30            Update,
31            (fly_camera, sculpt_terrain, update_brush_preview, ui_text),
32        )
33        .run();
34}
35
36fn show_chunks(mut show_chunks: ResMut<NextState<ChunkBoundryVisualizer>>) {
37    show_chunks.set(ChunkBoundryVisualizer::On);
38}
39
40#[derive(Clone, Copy, PartialEq, Eq, Default)]
41enum BrushMode {
42    #[default]
43    Smooth,
44    _Hard,
45    Blur,
46}
47
48#[derive(Resource)]
49struct SculptBrush {
50    radius: f32,
51    min_radius: f32,
52    max_radius: f32,
53    strength: f32,
54    min_strength: f32,
55    max_strength: f32,
56    falloff: f32,
57    mode: BrushMode,
58}
59
60impl Default for SculptBrush {
61    fn default() -> Self {
62        Self {
63            radius: 2.0,
64            min_radius: 0.5,
65            max_radius: 8.0,
66            strength: 5.0, // Units per second for smooth brush
67            min_strength: 0.5,
68            max_strength: 20.0,
69            falloff: 2.0, // Quadratic falloff
70            mode: BrushMode::Smooth,
71        }
72    }
73}
74
75#[derive(Component)]
76struct BrushPreview;
77
78#[derive(Component)]
79struct UiText;
80
81#[derive(Component)]
82struct FlyCam {
83    speed: f32,
84    sensitivity: f32,
85    pitch: f32,
86    yaw: f32,
87}
88
89impl Default for FlyCam {
90    fn default() -> Self {
91        Self {
92            speed: 20.0,
93            sensitivity: 0.003,
94            pitch: 0.0,
95            yaw: 0.0,
96        }
97    }
98}
99
100fn setup(
101    mut commands: Commands,
102    mut meshes: ResMut<Assets<Mesh>>,
103    mut materials: ResMut<Assets<StandardMaterial>>,
104) {
105    for x in -1..=1 {
106        for y in -1..=1 {
107            for z in -1..=1 {
108                let mut field = DensityField::new();
109                let local_center = vec3(16.0, 16.0, 16.0);
110                let global_offset = vec3(x as f32, y as f32, z as f32) * 32.0;
111                let sphere_center = vec3(0.0, 0.0, 0.0);
112                let local_sphere_center = sphere_center - global_offset + local_center;
113                bevy_sculpter::helpers::fill_sphere(&mut field, local_sphere_center, 20.0);
114                commands.spawn((Chunk, ChunkPos(ivec3(x, y, z)), field, DensityFieldDirty));
115            }
116        }
117    }
118
119    // Brush preview sphere
120    commands.spawn((
121        Mesh3d(meshes.add(Sphere::new(1.0).mesh().ico(2).unwrap())),
122        MeshMaterial3d(materials.add(StandardMaterial {
123            base_color: Color::srgba(0.2, 0.8, 0.2, 0.3),
124            alpha_mode: AlphaMode::Blend,
125            unlit: true,
126            ..default()
127        })),
128        Transform::from_scale(Vec3::ZERO),
129        BrushPreview,
130    ));
131
132    commands.spawn((
133        Camera3d::default(),
134        Transform::from_xyz(30.0, 30.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y),
135        FlyCam::default(),
136    ));
137
138    commands.spawn((
139        DirectionalLight {
140            illuminance: 10000.0,
141            shadows_enabled: true,
142            ..default()
143        },
144        Transform::from_xyz(10.0, 20.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y),
145    ));
146
147    commands.spawn((
148        Text::new(""),
149        Node {
150            position_type: PositionType::Absolute,
151            top: Val::Px(10.0),
152            left: Val::Px(10.0),
153            ..default()
154        },
155        UiText,
156    ));
157}
158
159fn fly_camera(
160    time: Res<Time>,
161    keyboard: Res<ButtonInput<KeyCode>>,
162    mouse_buttons: Res<ButtonInput<MouseButton>>,
163    mut mouse_motion: MessageReader<MouseMotion>,
164    mut scroll: MessageReader<MouseWheel>,
165    mut query: Query<(&mut Transform, &mut FlyCam)>,
166    mut brush: ResMut<SculptBrush>,
167) {
168    let Ok((mut transform, mut fly_cam)) = query.single_mut() else {
169        return;
170    };
171
172    // Toggle brush mode with B
173    if keyboard.just_pressed(KeyCode::KeyB) {
174        brush.mode = match brush.mode {
175            BrushMode::Smooth => BrushMode::Blur,
176            BrushMode::Blur => BrushMode::Smooth,
177            BrushMode::_Hard => BrushMode::Smooth,
178        };
179    }
180
181    // Adjust strength with [ and ]
182    if keyboard.just_pressed(KeyCode::BracketLeft) {
183        brush.strength = (brush.strength - 1.0).max(brush.min_strength);
184    }
185    if keyboard.just_pressed(KeyCode::BracketRight) {
186        brush.strength = (brush.strength + 1.0).min(brush.max_strength);
187    }
188
189    if mouse_buttons.pressed(MouseButton::Middle) {
190        for motion in mouse_motion.read() {
191            fly_cam.yaw -= motion.delta.x * fly_cam.sensitivity;
192            fly_cam.pitch -= motion.delta.y * fly_cam.sensitivity;
193            fly_cam.pitch = fly_cam.pitch.clamp(-1.5, 1.5);
194        }
195        transform.rotation = Quat::from_euler(EulerRot::YXZ, fly_cam.yaw, fly_cam.pitch, 0.0);
196    } else {
197        mouse_motion.clear();
198    }
199
200    for ev in scroll.read() {
201        brush.radius = (brush.radius + ev.y * 0.2).clamp(brush.min_radius, brush.max_radius);
202    }
203
204    let mut velocity = Vec3::ZERO;
205    let forward = transform.forward();
206    let right = transform.right();
207
208    if keyboard.pressed(KeyCode::KeyW) {
209        velocity += *forward;
210    }
211    if keyboard.pressed(KeyCode::KeyS) {
212        velocity -= *forward;
213    }
214    if keyboard.pressed(KeyCode::KeyA) {
215        velocity -= *right;
216    }
217    if keyboard.pressed(KeyCode::KeyD) {
218        velocity += *right;
219    }
220    if keyboard.pressed(KeyCode::Space) {
221        velocity += Vec3::Y;
222    }
223    if keyboard.pressed(KeyCode::ShiftLeft) {
224        velocity -= Vec3::Y;
225    }
226
227    if velocity.length_squared() > 0.0 {
228        velocity = velocity.normalize() * fly_cam.speed * time.delta_secs();
229        transform.translation += velocity;
230    }
231}
232
233fn sculpt_terrain(
234    time: Res<Time>,
235    keyboard: Res<ButtonInput<KeyCode>>,
236    mouse_buttons: Res<ButtonInput<MouseButton>>,
237    window_q: Query<&Window, With<PrimaryWindow>>,
238    camera_q: Query<(&Camera, &GlobalTransform), With<FlyCam>>,
239    mut chunks: Query<(&ChunkPos, &mut DensityField)>,
240    mesh_size: Res<DensityFieldMeshSize>,
241    brush: Res<SculptBrush>,
242    mut commands: Commands,
243    chunk_entities: Query<Entity, With<ChunkPos>>,
244) {
245    let adding = mouse_buttons.pressed(MouseButton::Right);
246    let removing = mouse_buttons.pressed(MouseButton::Left);
247
248    if !adding && !removing {
249        return;
250    }
251
252    let Ok(window) = window_q.single() else {
253        return;
254    };
255    let Some(cursor_pos) = window.cursor_position() else {
256        return;
257    };
258    let Ok((camera, cam_transform)) = camera_q.single() else {
259        return;
260    };
261    let Ok(ray) = camera.viewport_to_world(cam_transform, cursor_pos) else {
262        return;
263    };
264
265    let Some(hit_point) = raycast_terrain(&chunks, &mesh_size, ray) else {
266        return;
267    };
268
269    let world_brush_radius = brush.radius;
270    let chunk_world_size = mesh_size.0;
271    let use_hard_brush =
272        keyboard.pressed(KeyCode::ControlLeft) || keyboard.pressed(KeyCode::ControlRight);
273
274    for (chunk_pos, mut field) in chunks.iter_mut() {
275        let chunk_world_origin = chunk_pos.0.as_vec3() * chunk_world_size;
276        let local_hit = hit_point - chunk_world_origin;
277
278        let scale = Vec3::new(32.0, 32.0, 32.0) / chunk_world_size;
279        let grid_center = local_hit * scale;
280        let grid_radius = world_brush_radius * scale.x;
281
282        // AABB check
283        let chunk_min = Vec3::ZERO;
284        let chunk_max = Vec3::splat(32.0);
285        let brush_min = grid_center - Vec3::splat(grid_radius);
286        let brush_max = grid_center + Vec3::splat(grid_radius);
287
288        if brush_max.x < chunk_min.x
289            || brush_min.x > chunk_max.x
290            || brush_max.y < chunk_min.y
291            || brush_min.y > chunk_max.y
292            || brush_max.z < chunk_min.z
293            || brush_min.z > chunk_max.z
294        {
295            continue;
296        }
297
298        if use_hard_brush {
299            // Hard CSG brush (instant)
300            bevy_sculpter::helpers::brush_sphere(&mut field, grid_center, grid_radius, adding);
301        } else {
302            match brush.mode {
303                BrushMode::Smooth => {
304                    // Smooth brush: rate is strength per second
305                    // Negative rate = add material (decrease SDF)
306                    // Positive rate = remove material (increase SDF)
307                    let rate = if adding {
308                        -brush.strength
309                    } else {
310                        brush.strength
311                    };
312                    bevy_sculpter::helpers::brush_smooth_timed(
313                        &mut field,
314                        grid_center,
315                        grid_radius,
316                        rate,
317                        time.delta_secs(),
318                        brush.falloff,
319                    );
320                }
321                BrushMode::Blur => {
322                    // Blur/smooth brush
323                    bevy_sculpter::helpers::brush_blur(
324                        &mut field,
325                        grid_center,
326                        grid_radius,
327                        brush.strength * 0.1 * time.delta_secs(),
328                        brush.falloff,
329                    );
330                }
331                BrushMode::_Hard => {
332                    bevy_sculpter::helpers::brush_sphere(
333                        &mut field,
334                        grid_center,
335                        grid_radius,
336                        adding,
337                    );
338                }
339            }
340        }
341    }
342
343    for entity in chunk_entities.iter() {
344        commands.entity(entity).insert(DensityFieldDirty);
345    }
346}
347
348fn raycast_terrain(
349    chunks: &Query<(&ChunkPos, &mut DensityField)>,
350    mesh_size: &DensityFieldMeshSize,
351    ray: Ray3d,
352) -> Option<Vec3> {
353    let chunk_world_size = mesh_size.0;
354    let max_dist = 200.0;
355    let step = 0.1;
356    let mut t = 0.0;
357
358    while t < max_dist {
359        let point = ray.origin + ray.direction * t;
360        let chunk_coord = (point / chunk_world_size).floor().as_ivec3();
361
362        for (chunk_pos, field) in chunks.iter() {
363            if chunk_pos.0 != chunk_coord {
364                continue;
365            }
366
367            let chunk_origin = chunk_pos.0.as_vec3() * chunk_world_size;
368            let local_pos = point - chunk_origin;
369            let scale = Vec3::new(32.0, 32.0, 32.0) / chunk_world_size;
370            let grid_pos = local_pos * scale;
371
372            if grid_pos.x >= 0.0
373                && grid_pos.x < 32.0
374                && grid_pos.y >= 0.0
375                && grid_pos.y < 32.0
376                && grid_pos.z >= 0.0
377                && grid_pos.z < 32.0
378            {
379                let density = field.get(grid_pos.x as u32, grid_pos.y as u32, grid_pos.z as u32);
380                if density < 0.0 {
381                    return Some(point);
382                }
383            }
384        }
385        t += step;
386    }
387    None
388}
389
390fn update_brush_preview(
391    window_q: Query<&Window, With<PrimaryWindow>>,
392    camera_q: Query<(&Camera, &GlobalTransform), With<FlyCam>>,
393    chunks: Query<(&ChunkPos, &mut DensityField)>,
394    mesh_size: Res<DensityFieldMeshSize>,
395    brush: Res<SculptBrush>,
396    mut preview_q: Query<(&mut Transform, &MeshMaterial3d<StandardMaterial>), With<BrushPreview>>,
397    mut materials: ResMut<Assets<StandardMaterial>>,
398) {
399    let Ok((mut preview_transform, mat_handle)) = preview_q.single_mut() else {
400        return;
401    };
402    let Ok(window) = window_q.single() else {
403        return;
404    };
405    let Some(cursor_pos) = window.cursor_position() else {
406        preview_transform.scale = Vec3::ZERO;
407        return;
408    };
409    let Ok((camera, cam_transform)) = camera_q.single() else {
410        return;
411    };
412    let Ok(ray) = camera.viewport_to_world(cam_transform, cursor_pos) else {
413        return;
414    };
415
416    if let Some(hit) = raycast_terrain(&chunks, &mesh_size, ray) {
417        preview_transform.translation = hit;
418        preview_transform.scale = Vec3::splat(brush.radius);
419
420        // Change color based on mode
421        if let Some(mat) = materials.get_mut(&mat_handle.0) {
422            mat.base_color = match brush.mode {
423                BrushMode::Smooth => Color::srgba(0.2, 0.8, 0.2, 0.3),
424                BrushMode::Blur => Color::srgba(0.2, 0.2, 0.8, 0.3),
425                BrushMode::_Hard => Color::srgba(0.8, 0.2, 0.2, 0.3),
426            };
427        }
428    } else {
429        preview_transform.scale = Vec3::ZERO;
430    }
431}
432
433fn ui_text(brush: Res<SculptBrush>, mut text_q: Query<&mut Text, With<UiText>>) {
434    let Ok(mut text) = text_q.single_mut() else {
435        return;
436    };
437
438    let mode_str = match brush.mode {
439        BrushMode::Smooth => "Smooth (continuous)",
440        BrushMode::Blur => "Blur/Smooth surface",
441        BrushMode::_Hard => "Hard (CSG)",
442    };
443
444    *text = Text::new(format!(
445        "Sculpt Controls:\n\
446         Middle Click + Drag: Rotate camera\n\
447         Right Click (hold): Add material\n\
448         Left Click (hold): Remove material\n\
449         Ctrl + Click: Hard brush (instant CSG)\n\
450         \n\
451         B: Toggle brush mode\n\
452         Scroll: Brush size ({:.1})\n\
453         [ ]: Brush strength ({:.1})\n\
454         \n\
455         Mode: {}",
456        brush.radius, brush.strength, mode_str
457    ));
458}