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