jackdaw 0.4.0

A 3D level editor built with Bevy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
use bevy::{input_focus::InputFocus, prelude::*};

use crate::default_style;
use crate::{
    selection::Selection, viewport::MainViewportCamera, viewport_util::point_in_polygon_2d,
};

use super::{BrushEditMode, BrushMeshCache, BrushSelection, EditMode};
use jackdaw_geometry::{brush_planes_to_world, compute_brush_geometry};
use jackdaw_jsn::{Brush, BrushFaceData, BrushPlane};

/// Reactive cleanup: when the active brush entity is no longer
/// selected, drop out of brush-edit mode. The digit-key mode switches
/// (1/2/3/4) and the Escape exit-to-Object live in
/// [`crate::edit_mode_ops`].
pub(super) fn drop_brush_edit_on_deselect(
    input_focus: Res<InputFocus>,
    selection: Res<Selection>,
    mut edit_mode: ResMut<EditMode>,
    mut brush_selection: ResMut<BrushSelection>,
    modal: Res<crate::modal_transform::ModalTransformState>,
) {
    if input_focus.0.is_some() || modal.active.is_some() {
        return;
    }

    if let EditMode::BrushEdit(_) = *edit_mode
        && let Some(brush_entity) = brush_selection.entity
        && selection.primary() != Some(brush_entity)
    {
        // Save last selected face for extend-to-brush fallback
        if !brush_selection.faces.is_empty() {
            brush_selection.last_face_entity = Some(brush_entity);
            brush_selection.last_face_index = brush_selection.faces.last().copied();
        }
        *edit_mode = EditMode::Object;
        brush_selection.clear();
    }
}

pub(crate) struct PendingSubDrag {
    pub click_pos: Vec2,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum FaceExtrudeMode {
    #[default]
    Merge, // Push/pull existing face plane
    Extend, // Create new brush from face extrusion
}

#[derive(Resource, Default)]
pub(crate) struct BrushDragState {
    pub pending: Option<PendingSubDrag>,
    pub active: bool,
    pub extrude_mode: FaceExtrudeMode,
    /// When true, exits to Object mode when drag completes or is cancelled.
    pub quick_action: bool,
    pub(crate) start_brush: Option<Brush>,
    pub(crate) start_cursor: Vec2,
    pub(crate) drag_face_normal: Vec3,
    /// World-space face polygon vertices for extend preview.
    pub extend_face_polygon: Vec<Vec3>,
    /// World-space face normal for extend preview.
    pub extend_face_normal: Vec3,
    /// Current extrude depth during extend drag.
    pub extend_depth: f32,
    /// Multi-viewport: camera + UI-node entities captured at drag
    /// start so the drag stays bound to its origin viewport even if
    /// the cursor wanders into another panel.
    pub(crate) drag_camera: Option<Entity>,
    pub(crate) drag_viewport: Option<Entity>,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum VertexDragConstraint {
    #[default]
    Free,
    AxisX,
    AxisY,
    AxisZ,
}

#[derive(Resource, Default)]
pub(crate) struct VertexDragState {
    pub pending: Option<PendingSubDrag>,
    pub active: bool,
    pub constraint: VertexDragConstraint,
    pub(crate) start_brush: Option<Brush>,
    pub(crate) start_cursor: Vec2,
    pub(crate) start_vertex_positions: Vec<Vec3>,
    /// Full vertex list at drag start (for hull rebuild).
    pub(crate) start_all_vertices: Vec<Vec3>,
    /// Per-face polygon indices at drag start (for hull rebuild).
    pub(crate) start_face_polygons: Vec<Vec<usize>>,
    /// New vertex position for Shift+drag split (edge midpoint or face center).
    pub(crate) split_vertex: Option<Vec3>,
    /// Multi-viewport: see [`BrushDragState::drag_camera`].
    pub(crate) drag_camera: Option<Entity>,
    pub(crate) drag_viewport: Option<Entity>,
}

/// Compute a local-space offset for brush vertex/edge drag based on mouse movement.
pub(crate) fn compute_brush_drag_offset(
    constraint: VertexDragConstraint,
    mouse_delta: Vec2,
    cam_tf: &GlobalTransform,
    camera: &Camera,
    brush_global: &GlobalTransform,
) -> Option<Vec3> {
    let brush_pos = brush_global.translation();
    let cam_dist = (cam_tf.translation() - brush_pos).length();
    let scale = cam_dist * 0.003;

    let offset = match constraint {
        VertexDragConstraint::Free => {
            let cam_right = cam_tf.right().as_vec3();
            let cam_up = cam_tf.up().as_vec3();
            let world_offset =
                cam_right * mouse_delta.x * scale + cam_up * (-mouse_delta.y) * scale;
            let (_, brush_rot, _) = brush_global.to_scale_rotation_translation();
            brush_rot.inverse() * world_offset
        }
        constraint => {
            let axis_dir = match constraint {
                VertexDragConstraint::AxisX => Vec3::X,
                VertexDragConstraint::AxisY => Vec3::Y,
                VertexDragConstraint::AxisZ => Vec3::Z,
                VertexDragConstraint::Free => unreachable!(),
            };
            let origin_screen = camera.world_to_viewport(cam_tf, brush_pos).ok()?;
            let (_, brush_rot, _) = brush_global.to_scale_rotation_translation();
            let world_axis = brush_rot * axis_dir;
            let axis_screen = camera
                .world_to_viewport(cam_tf, brush_pos + world_axis)
                .ok()?;
            let screen_axis = (axis_screen - origin_screen).normalize_or_zero();
            let projected = mouse_delta.dot(screen_axis);
            axis_dir * projected * scale
        }
    };
    Some(offset)
}

#[derive(Resource, Default)]
pub(crate) struct EdgeDragState {
    pub pending: Option<PendingSubDrag>,
    pub active: bool,
    pub constraint: VertexDragConstraint,
    pub(crate) start_brush: Option<Brush>,
    pub(crate) start_cursor: Vec2,
    /// Start positions for each selected edge's two endpoints (vertex indices + positions).
    pub(crate) start_edge_vertices: Vec<(usize, Vec3)>,
    /// Full vertex list at drag start (for hull rebuild).
    pub(crate) start_all_vertices: Vec<Vec3>,
    /// Per-face polygon indices at drag start (for hull rebuild).
    pub(crate) start_face_polygons: Vec<Vec<usize>>,
    /// Multi-viewport: see [`BrushDragState::drag_camera`].
    pub(crate) drag_camera: Option<Entity>,
    pub(crate) drag_viewport: Option<Entity>,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ClipMode {
    #[default]
    KeepFront,
    KeepBack,
    Split,
}

#[derive(Resource, Default)]
pub(crate) struct ClipState {
    pub points: Vec<Vec3>,
    pub preview_plane: Option<BrushPlane>,
    pub mode: ClipMode,
}

/// Recompute the clip preview plane from `ClipState.points` and draw
/// the clip overlay (points + front/back wireframes). Mutations
/// (placing points, cycling mode, applying, clearing) live in the
/// `brush.clip.*` operators in [`crate::clip_ops`].
pub(super) fn handle_clip_mode(
    edit_mode: Res<EditMode>,
    camera_query: Query<(Entity, &Camera, &GlobalTransform), With<MainViewportCamera>>,
    active: Res<crate::viewport::ActiveViewport>,
    brush_selection: Res<BrushSelection>,
    brushes: Query<&Brush>,
    brush_transforms: Query<&GlobalTransform>,
    mut clip_state: ResMut<ClipState>,
    mut gizmos: Gizmos,
) {
    let EditMode::BrushEdit(BrushEditMode::Clip) = *edit_mode else {
        // Clear clip state when not in clip mode
        if !clip_state.points.is_empty() || clip_state.mode != ClipMode::KeepFront {
            *clip_state = ClipState::default();
        }
        return;
    };

    let Some(brush_entity) = brush_selection.entity else {
        return;
    };
    let Ok(brush_global) = brush_transforms.get(brush_entity) else {
        return;
    };
    // Multi-viewport: use the hovered viewport's camera for clip-plane
    // orientation; fall back to any camera so the preview keeps working
    // when no viewport is focused.
    let cam_tf = active
        .camera
        .and_then(|e| camera_query.get(e).ok())
        .or_else(|| camera_query.iter().next())
        .map(|(_, _, tf)| tf);
    let Some(cam_tf) = cam_tf else {
        return;
    };

    // Compute preview plane from collected points
    clip_state.preview_plane = match clip_state.points.len() {
        2 => {
            // Two points + camera forward for orientation
            let dir = clip_state.points[1] - clip_state.points[0];
            let (_, brush_rot, _) = brush_global.to_scale_rotation_translation();
            let local_cam_fwd = brush_rot.inverse() * cam_tf.forward().as_vec3();
            let normal = dir.cross(local_cam_fwd).normalize_or_zero();
            if normal.length_squared() > 0.5 {
                let distance = normal.dot(clip_state.points[0]);
                Some(BrushPlane { normal, distance })
            } else {
                None
            }
        }
        3 => {
            let a = clip_state.points[0];
            let b = clip_state.points[1];
            let c = clip_state.points[2];
            let normal = (b - a).cross(c - a).normalize_or_zero();
            if normal.length_squared() > 0.5 {
                let distance = normal.dot(a);
                Some(BrushPlane { normal, distance })
            } else {
                None
            }
        }
        _ => None,
    };

    let Ok(brush_ref) = brushes.get(brush_entity) else {
        return;
    };

    // Draw clip points and preview
    for (i, point) in clip_state.points.iter().enumerate() {
        let world_pos = brush_global.transform_point(*point);
        let color = default_style::CLIP_POINT;
        gizmos.sphere(Isometry3d::from_translation(world_pos), 0.06, color);
        // Draw connecting lines between points
        if i > 0 {
            let prev_world = brush_global.transform_point(clip_state.points[i - 1]);
            gizmos.line(prev_world, world_pos, color);
        }
    }

    // Draw clipped geometry preview
    if let Some(ref plane) = clip_state.preview_plane {
        let (_, brush_rot, brush_trans) = brush_global.to_scale_rotation_translation();

        let world_faces = brush_planes_to_world(&brush_ref.faces, brush_rot, brush_trans);

        // Transform clip plane to world space (same formula as brush_planes_to_world)
        let world_clip_normal = (brush_rot * plane.normal).normalize();
        let world_clip_distance = plane.distance + world_clip_normal.dot(brush_trans);

        // Front half faces (brush + clip plane)
        let front_clip = BrushFaceData {
            plane: BrushPlane {
                normal: world_clip_normal,
                distance: world_clip_distance,
            },
            uv_scale: Vec2::ONE,
            ..default()
        };
        let mut front_faces = world_faces.clone();
        front_faces.push(front_clip);

        // Back half faces (brush + flipped clip plane)
        let back_clip = BrushFaceData {
            plane: BrushPlane {
                normal: -world_clip_normal,
                distance: -world_clip_distance,
            },
            uv_scale: Vec2::ONE,
            ..default()
        };
        let mut back_faces = world_faces;
        back_faces.push(back_clip);

        let (front_color, back_color) = match clip_state.mode {
            ClipMode::KeepFront => (default_style::CLIP_KEEP, default_style::CLIP_DISCARD),
            ClipMode::KeepBack => (default_style::CLIP_DISCARD, default_style::CLIP_KEEP),
            ClipMode::Split => (default_style::CLIP_KEEP, default_style::CLIP_SPLIT_BACK),
        };

        // Draw front half wireframe
        let (verts, polys) = compute_brush_geometry(&front_faces);
        if verts.len() >= 4 {
            for polygon in &polys {
                for i in 0..polygon.len() {
                    let a = verts[polygon[i]];
                    let b = verts[polygon[(i + 1) % polygon.len()]];
                    gizmos.line(a, b, front_color);
                }
            }
        }

        // Draw back half wireframe
        let (verts, polys) = compute_brush_geometry(&back_faces);
        if verts.len() >= 4 {
            for polygon in &polys {
                for i in 0..polygon.len() {
                    let a = verts[polygon[i]];
                    let b = verts[polygon[(i + 1) % polygon.len()]];
                    gizmos.line(a, b, back_color);
                }
            }
        }
    }
}

/// Pick the closest face under the cursor on a given brush entity.
fn pick_face_under_cursor(
    viewport_cursor: Vec2,
    brush_entity: Entity,
    camera: &Camera,
    cam_tf: &GlobalTransform,
    cache: &BrushMeshCache,
    face_entities: &Query<(Entity, &super::BrushFaceEntity, &GlobalTransform)>,
) -> Option<usize> {
    let mut best_face = None;
    let mut best_depth = f32::MAX;

    for (_, face_ent, face_global) in face_entities {
        if face_ent.brush_entity != brush_entity {
            continue;
        }
        let face_idx = face_ent.face_index;
        let polygon = &cache.face_polygons[face_idx];
        if polygon.len() < 3 {
            continue;
        }
        let screen_verts: Vec<Vec2> = polygon
            .iter()
            .filter_map(|&vi| {
                let world = face_global.transform_point(cache.vertices[vi]);
                camera.world_to_viewport(cam_tf, world).ok()
            })
            .collect();
        if screen_verts.len() < 3 {
            continue;
        }
        if point_in_polygon_2d(viewport_cursor, &screen_verts) {
            let centroid: Vec3 =
                polygon.iter().map(|&vi| cache.vertices[vi]).sum::<Vec3>() / polygon.len() as f32;
            let world_centroid = face_global.transform_point(centroid);
            let depth = (cam_tf.translation() - world_centroid).length_squared();
            if depth < best_depth {
                best_depth = depth;
                best_face = Some(face_idx);
            }
        }
    }
    best_face
}

/// Updates the hover resource each frame to track which face the cursor is over.
pub(super) fn brush_face_hover(
    edit_mode: Res<EditMode>,
    keyboard: Res<ButtonInput<KeyCode>>,
    vp: crate::viewport::ViewportCursor,
    face_entities: Query<(Entity, &super::BrushFaceEntity, &GlobalTransform)>,
    brush_selection: Res<BrushSelection>,
    brush_caches: Query<&BrushMeshCache>,
    selection: Res<Selection>,
    drag_state: Res<BrushDragState>,
    mut hover: ResMut<super::BrushFaceHover>,
    brushes: Query<(), With<Brush>>,
) {
    let in_face_edit = matches!(*edit_mode, EditMode::BrushEdit(BrushEditMode::Face));
    let shift = keyboard.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
    let alt = keyboard.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);

    // Clear hover during active drag
    if drag_state.active {
        hover.entity = None;
        hover.face_index = None;
        return;
    }

    // Determine if we should show hover
    let should_hover = in_face_edit || (*edit_mode == EditMode::Object && (shift || alt));

    if !should_hover {
        hover.entity = None;
        hover.face_index = None;
        return;
    }

    let intent = if alt {
        super::HoverIntent::Extend
    } else {
        super::HoverIntent::PushPull
    };

    let Ok(window) = vp.windows.single() else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };
    let Some(cursor_pos) = window.cursor_position() else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };
    let Some(camera_entity) = vp.camera_entity() else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };
    let Some(viewport_entity) = vp.viewport_entity() else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };
    let Some((camera, cam_tf)) = vp.camera_for(camera_entity) else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };
    let Some(viewport_cursor) = vp.viewport_cursor_for(camera, viewport_entity, cursor_pos) else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };

    let brush_entity = if in_face_edit {
        brush_selection.entity
    } else {
        selection.primary().filter(|&e| brushes.contains(e))
    };

    let Some(brush_entity) = brush_entity else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };

    let Ok(cache) = brush_caches.get(brush_entity) else {
        hover.entity = None;
        hover.face_index = None;
        return;
    };

    if let Some(face_idx) = pick_face_under_cursor(
        viewport_cursor,
        brush_entity,
        camera,
        cam_tf,
        cache,
        &face_entities,
    ) {
        hover.entity = Some(brush_entity);
        hover.face_index = Some(face_idx);
        hover.intent = intent;
    } else {
        hover.entity = None;
        hover.face_index = None;
    }
}