diff --git a/crates/bone-app/src/chrome.rs b/crates/bone-app/src/chrome.rs index 93aff44..9fe3f0c 100644 --- a/crates/bone-app/src/chrome.rs +++ b/crates/bone-app/src/chrome.rs @@ -1,16 +1,21 @@ use std::borrow::Cow; -use bone_render::{ChromeInstance, SdfGlyphInstance}; +use bone_render::{ChromeInstance, ConvexInstance, SdfGlyphInstance, StrokeInstance}; use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, ShapedText, Shaper}; use bone_ui::layout::LayoutRect; use bone_ui::strings::StringTable; use bone_ui::text::{MaskAtlas, MaskAtlasKey}; -use bone_ui::theme::{Color, Theme}; -use bone_ui::widgets::{HorizontalAlign, PaintPrim, WidgetPaint, lower_paint}; +use bone_ui::theme::{Border, Color, StrokeWidth, Theme}; +use bone_ui::widgets::{ + ConvexPoly, HorizontalAlign, PaintPrim, PolyPath, WidgetPaint, lower_paint, +}; use swash::FontRef; const MARK_FONT_SCALE: f32 = 0.7; +const _: () = assert!(bone_ui::widgets::MAX_CONVEX_VERTS == bone_render::MAX_PLANES); +const _: () = assert!(bone_ui::widgets::MAX_PATH_POINTS < bone_render::MAX_STROKE_POINTS); + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SpanVertical { Baseline, @@ -39,12 +44,74 @@ pub fn paint_to_instances(theme: &Theme, paints: &[WidgetPaint]) -> Vec Vec { + paints + .iter() + .filter_map(|p| match p { + WidgetPaint::ConvexFill { poly, fill, border } => { + convex_to_instance(poly, *fill, *border) + } + _ => None, + }) + .collect() +} + +#[must_use] +pub fn paint_to_stroke_instances(paints: &[WidgetPaint]) -> Vec { + paints + .iter() + .filter_map(|p| match p { + WidgetPaint::Stroke { path, width, color } => stroke_to_instance(path, *width, *color), + _ => None, + }) + .collect() +} + +fn convex_to_instance( + poly: &ConvexPoly, + fill: Color, + border: Option, +) -> Option { + let bounds = poly.bounds(); + let rect = [ + bounds.origin.x.value() - 1.0, + bounds.origin.y.value() - 1.0, + bounds.size.width.value() + 2.0, + bounds.size.height.value() + 2.0, + ]; + let (border_premul, border_width) = border.map_or(([0.0; 4], 0.0), |b| { + (b.color.linear_rgba_premul(), b.width.value_px()) + }); + ConvexInstance::new( + rect, + fill.linear_rgba_premul(), + border_premul, + border_width, + &poly.edge_planes(), + ) +} + +fn stroke_to_instance(path: &PolyPath, width: StrokeWidth, color: Color) -> Option { + let half = width.value_px().max(1.0) * 0.5; + let open: Vec<[f32; 2]> = path + .points() + .iter() + .map(|p| [p.x.value(), p.y.value()]) + .collect(); + let closing = path.is_closed().then(|| open.first().copied()).flatten(); + let points: Vec<[f32; 2]> = open.into_iter().chain(closing).collect(); + StrokeInstance::new(&points, color.linear_rgba_premul(), half) +} + #[must_use] pub fn paint_to_text_spans<'a>( paints: &'a [WidgetPaint], diff --git a/crates/bone-app/src/event.rs b/crates/bone-app/src/event.rs index 1a91640..37b769f 100644 --- a/crates/bone-app/src/event.rs +++ b/crates/bone-app/src/event.rs @@ -27,6 +27,7 @@ pub enum InputEvent { physical_key: PhysicalKey, logical_key: Key, text: Option, + repeat: bool, }, } @@ -54,6 +55,7 @@ impl AppEvent { physical_key, logical_key, text, + repeat, .. }, .. @@ -61,6 +63,7 @@ impl AppEvent { physical_key, logical_key, text, + repeat, }), WindowEvent::KeyboardInput { .. } | WindowEvent::ActivationTokenDone { .. } diff --git a/crates/bone-app/src/main.rs b/crates/bone-app/src/main.rs index b542cd4..6991785 100644 --- a/crates/bone-app/src/main.rs +++ b/crates/bone-app/src/main.rs @@ -9,16 +9,18 @@ use bone_document::{ SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, }; use bone_render::{ - Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, DragModifiers, EdgeScene, - NavGesture, PickQuery, PickedItem, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, - SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, Style, SurfaceContext, + Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, + ConvexPolyPipeline, DragModifiers, EdgeScene, NavGesture, PickQuery, PickedItem, PixelsPerMm, + RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, + SolidRenderer, SolidScene, StrokeInstance, StrokePipeline, Style, SurfaceContext, ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, frame_current, - frame_standard_view, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, + frame_standard_view, frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, + zoom_about_pixel, }; use bone_types::{ - Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, DisplayMode, - DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Point2, SketchId, SketchItemId, - StandardView, Vec2, ZoomFactor, + Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, + DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, + SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, }; use bone_ui::a11y::AccessTreeBuilder; use bone_ui::focus::FocusManager; @@ -66,6 +68,7 @@ mod snap; mod status_badge; mod strings; mod tools; +mod view_cube; use dimension_editor::{DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState}; use selection::Selection; @@ -123,6 +126,8 @@ struct RenderState { surface: SurfaceContext, renderer: SketchRenderer, chrome_pipeline: ChromePipeline, + convex_pipeline: ConvexPolyPipeline, + stroke_pipeline: StrokePipeline, text_pipeline: ChromeTextPipeline, sdf_atlas: MaskAtlas, chrome_shaper: Shaper, @@ -143,6 +148,7 @@ struct RenderState { camera3: Option, framed_extrude: Option, navigator: ViewportNavigator, + view: view_cube::ViewUi, focus: FocusManager, hit_state: HitState, hotkeys: HotkeyTable, @@ -716,12 +722,16 @@ fn sync_solid_view(state: &mut RenderState) { state.extrude_preview = None; state.solid_view = None; state.camera3 = None; + state.view.home = None; + state.view.tween = None; return; }; let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { state.extrude_preview = None; state.solid_view = None; state.camera3 = None; + state.view.home = None; + state.view.tween = None; return; }; if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { @@ -812,8 +822,123 @@ fn sync_solid_camera(state: &mut RenderState, region: Option) { && let Some(view) = state.solid_view.as_ref() && state.camera3.is_none() { - state.camera3 = + let framed = frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); + state.camera3 = framed; + if state.view.home.is_none() { + state.view.home = framed; + } + } +} + +const VIEW_TWEEN_MS: u64 = 220; + +fn step_view_tween(state: &mut RenderState, now: FrameInstant) { + let Some(active) = state.view.tween else { + return; + }; + let elapsed = now.since(active.started); + if let Ok(camera) = active.tween.sample(elapsed) { + state.camera3 = Some(camera); + } + if active.tween.is_done(elapsed) { + state.camera3 = Some(active.tween.to()); + state.view.tween = None; + } +} + +fn start_view_tween(state: &mut RenderState, target: Camera3, now: FrameInstant) { + let tween = if state.settings.reduce_motion { + CameraTween::immediate(target) + } else { + CameraTween::eased( + state.camera3.unwrap_or(target), + target, + std::time::Duration::from_millis(VIEW_TWEEN_MS), + CubicEasing::STANDARD, + ) + }; + state.view.tween = Some(view_cube::ActiveTween { + tween, + started: now, + }); +} + +fn apply_nav_camera(state: &mut RenderState, camera: Camera3) { + state.camera3 = Some(camera); + state.view.tween = None; +} + +fn solid_aabb_and_extent(state: &RenderState) -> Option<(Aabb3, ViewportExtent)> { + let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; + let view = state.solid_view.as_ref()?; + Some((view.aabb, region.extent())) +} + +fn normal_to_plane(state: &RenderState) -> Option { + let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; + let plane = state + .plane_sketches + .iter() + .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; + Some(Plane3::from(plane.basis())) +} + +fn frame_target_camera(state: &RenderState, pick: view_cube::ViewPick) -> Option { + match pick { + view_cube::ViewPick::Home => state.view.home, + view_cube::ViewPick::Standard(view) => { + let (aabb, extent) = solid_aabb_and_extent(state)?; + frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok() + } + view_cube::ViewPick::Direction(direction) => { + let (aabb, extent) = solid_aabb_and_extent(state)?; + frame_view_direction(aabb, extent, direction).ok() + } + } +} + +fn apply_view_pick(state: &mut RenderState, pick: Option, now: FrameInstant) { + let Some(pick) = pick else { + return; + }; + if let Some(target) = frame_target_camera(state, pick) { + start_view_tween(state, target, now); + } +} + +fn view_nav_enabled(state: &RenderState) -> bool { + state.solid_view.is_some() + && !modal_active(state) + && state.focus.focused().is_none() + && !dim_flow_active(&state.mode) +} + +fn apply_view_menu( + state: &mut RenderState, + action: Option, + now: FrameInstant, +) { + match action { + Some(view_cube::ViewCubeMenuAction::SetAsHome) => { + state.view.home = state.camera3; + } + Some(view_cube::ViewCubeMenuAction::FitToWindow) => { + if let (Some(camera), Some((aabb, extent))) = + (state.camera3, solid_aabb_and_extent(state)) + && let Ok(target) = frame_current(camera, aabb, extent) + { + start_view_tween(state, target, now); + } + } + Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => { + apply_view_pick( + state, + Some(view_cube::ViewPick::Standard(StandardView::NormalTo)), + now, + ); + } + None => {} } } @@ -1202,6 +1327,8 @@ impl ApplicationHandler for App { let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); let solid_renderer = SolidRenderer::new(surface.gpu(), surface.color_format()); let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); + let convex_pipeline = ConvexPolyPipeline::new(surface.gpu(), surface.color_format()); + let stroke_pipeline = StrokePipeline::new(surface.gpu(), surface.color_format()); let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); let text_pipeline = ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent()); @@ -1243,6 +1370,8 @@ impl ApplicationHandler for App { surface, renderer, chrome_pipeline, + convex_pipeline, + stroke_pipeline, text_pipeline, sdf_atlas, chrome_shaper, @@ -1263,6 +1392,7 @@ impl ApplicationHandler for App { camera3: None, framed_extrude: None, navigator: ViewportNavigator::new(), + view: view_cube::ViewUi::default(), focus: FocusManager::new(), hit_state: HitState::new(), hotkeys: initial_hotkeys, @@ -1348,7 +1478,7 @@ impl App { && let Some(cursor) = viewport_local_point(position, region) && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) { - state.camera3 = Some(next); + apply_nav_camera(state, next); } else if !modal && self.input.panning() && let Some(p) = prev @@ -1372,52 +1502,66 @@ impl App { self.dispatch_pointer_button(button, btn_state); } event::InputEvent::Wheel(delta) => { - if !modal_active(state) && self.input.cursor_in(state.viewport_rect) { - if state.solid_view.is_some() { - if let Some(camera) = state.camera3 - && let Some(region) = - solid_viewport_region(state.viewport_rect, state.surface.extent()) - { - match delta { - MouseScrollDelta::PixelDelta(p) => { - if let Ok(next) = state - .navigator - .orbit_pixels(camera, region.extent(), p.x, p.y) - { - state.camera3 = Some(next); - } - } - MouseScrollDelta::LineDelta(..) => { - if let Some(cursor) = self - .input - .cursor_px - .and_then(|p| viewport_local_point(p, region)) - && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) - && let Ok(next) = - zoom_about_pixel(camera, region.extent(), cursor, factor) - { - state.camera3 = Some(next); - } - } - } - } - } else { - state.camera = - zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); - } - } + self.dispatch_wheel(delta); } event::InputEvent::KeyDown { physical_key, logical_key, text, + repeat, } => { - self.dispatch_keydown(event_loop, physical_key, &logical_key, text.as_ref()); + self.dispatch_keydown( + event_loop, + physical_key, + &logical_key, + text.as_ref(), + repeat, + ); } } ack } + fn dispatch_wheel(&mut self, delta: MouseScrollDelta) { + let Some(state) = self.render.as_mut() else { + return; + }; + if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { + return; + } + if state.solid_view.is_none() { + state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); + return; + } + let (Some(camera), Some(region)) = ( + state.camera3, + solid_viewport_region(state.viewport_rect, state.surface.extent()), + ) else { + return; + }; + match delta { + MouseScrollDelta::PixelDelta(p) => { + if let Ok(next) = state + .navigator + .orbit_pixels(camera, region.extent(), p.x, p.y) + { + apply_nav_camera(state, next); + } + } + MouseScrollDelta::LineDelta(..) => { + if let Some(cursor) = self + .input + .cursor_px + .and_then(|p| viewport_local_point(p, region)) + && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) + && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor) + { + apply_nav_camera(state, next); + } + } + } + } + fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { let Some(state) = self.render.as_mut() else { return; @@ -1507,6 +1651,7 @@ impl App { physical_key: PhysicalKey, logical_key: &Key, text: Option<&winit::keyboard::SmolStr>, + repeat: bool, ) { let Some(state) = self.render.as_mut() else { return; @@ -1522,6 +1667,17 @@ impl App { let physical_named = physical_code.and_then(keycode_to_named); let named = logical_named.or(physical_named); let mods = self.input.modifier_mask(); + if named == Some(NamedKey::Space) && view_nav_enabled(state) { + if repeat { + return; + } + if self.input.modifiers.control_key() || self.input.modifiers.super_key() { + state.view.toggle_cube(); + } else { + state.view.toggle_selector(); + } + return; + } if let Some(named) = named { self.input .pending_keys @@ -1547,7 +1703,7 @@ impl App { { if state.solid_view.is_some() { if let Some(next) = keyboard_camera3(code, &self.input, state) { - state.camera3 = Some(next); + apply_nav_camera(state, next); } } else if let Some(next) = keyboard_camera(code, &self.input, state) { state.camera = next; @@ -1589,7 +1745,15 @@ fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option { struct ChromeStage<'a> { chrome: &'a mut ChromePipeline, + convex: &'a mut ConvexPolyPipeline, + stroke: &'a mut StrokePipeline, text: &'a mut ChromeTextPipeline, atlas_pixels: &'a [u8], atlas_version: u64, viewport_px: [f32; 2], } +fn merge_layers(main: &[T], overlay: &[T]) -> Vec { + main.iter().chain(overlay.iter()).copied().collect() +} + +fn count_u32(len: usize) -> u32 { + let Ok(count) = u32::try_from(len) else { + unreachable!("instance counts fit in u32"); + }; + count +} + impl ChromeStage<'_> { fn encode_layered( &mut self, @@ -1816,43 +1998,31 @@ impl ChromeStage<'_> { main: &ChromeLayer, overlay: &ChromeLayer, ) { - let combined_chrome: Vec = main - .chrome - .iter() - .chain(overlay.chrome.iter()) - .copied() - .collect(); - let combined_glyphs: Vec = main - .glyphs - .iter() - .chain(overlay.glyphs.iter()) - .copied() - .collect(); - let Ok(main_chrome) = u32::try_from(main.chrome.len()) else { - unreachable!("chrome instance count fits in u32") - }; - let Ok(total_chrome) = u32::try_from(combined_chrome.len()) else { - unreachable!("chrome instance count fits in u32") - }; - let Ok(main_glyphs) = u32::try_from(main.glyphs.len()) else { - unreachable!("glyph instance count fits in u32") - }; - let Ok(total_glyphs) = u32::try_from(combined_glyphs.len()) else { - unreachable!("glyph instance count fits in u32") - }; - self.chrome.upload(self.viewport_px, &combined_chrome); + let chrome = merge_layers(&main.chrome, &overlay.chrome); + let convex = merge_layers(&main.convex, &overlay.convex); + let stroke = merge_layers(&main.stroke, &overlay.stroke); + let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); + self.chrome.upload(self.viewport_px, &chrome); + self.convex.upload(self.viewport_px, &convex); + self.stroke.upload(self.viewport_px, &stroke); self.text.upload( self.viewport_px, self.atlas_pixels, self.atlas_version, - &combined_glyphs, + &glyphs, ); - self.chrome.draw_range(encoder, color, 0..main_chrome); - self.text.draw_range(encoder, color, 0..main_glyphs); - self.chrome - .draw_range(encoder, color, main_chrome..total_chrome); - self.text - .draw_range(encoder, color, main_glyphs..total_glyphs); + let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); + let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); + let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); + let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); + self.chrome.draw_range(encoder, color, 0..mc); + self.convex.draw_range(encoder, color, 0..mv); + self.stroke.draw_range(encoder, color, 0..ms); + self.text.draw_range(encoder, color, 0..mg); + self.chrome.draw_range(encoder, color, mc..tc); + self.convex.draw_range(encoder, color, mv..tv); + self.stroke.draw_range(encoder, color, ms..ts); + self.text.draw_range(encoder, color, mg..tg); } } @@ -1880,6 +2050,8 @@ fn any_actionable_interaction(hit_state: &HitState) -> bool { struct ChromeLayer { chrome: Vec, + convex: Vec, + stroke: Vec, glyphs: Vec, } @@ -1888,6 +2060,8 @@ fn build_chrome_layer( paints: &[bone_ui::widgets::WidgetPaint], ) -> ChromeLayer { let chrome = chrome::paint_to_instances(&state.theme, paints); + let convex = chrome::paint_to_convex_instances(paints); + let stroke = chrome::paint_to_stroke_instances(paints); let spans = chrome::paint_to_text_spans(paints, &state.strings); let glyphs = chrome::build_glyph_instances( &spans, @@ -1896,7 +2070,12 @@ fn build_chrome_layer( &state.sans_font, &state.mono_font, ); - ChromeLayer { chrome, glyphs } + ChromeLayer { + chrome, + convex, + stroke, + glyphs, + } } fn apply_hotkey_actions( @@ -2466,6 +2645,8 @@ fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { confirm_action: None, menu_action: None, settings_change: None, + view_pick: None, + view_menu: None, } } @@ -2605,6 +2786,8 @@ fn run_frame_ui( &state.settings, layout_size, cursor_world, + state.camera3.filter(|_| state.solid_view.is_some()), + &mut state.view, ); let dim_outcome = pending_dim(&state.mode).map(|pending| { let live_anchor = state @@ -3171,12 +3354,17 @@ fn apply_menu_action(state: &mut RenderState, action: Option) refresh_active_scene(state); } Some(shell::MenuAction::ZoomFit) => { - let solid_fit = state.solid_view.as_ref().map(|view| view.aabb).and_then(|aabb| { - let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; - frame_current(state.camera3?, aabb, region.extent()).ok() - }); + let solid_fit = state + .solid_view + .as_ref() + .map(|view| view.aabb) + .and_then(|aabb| { + let region = + solid_viewport_region(state.viewport_rect, state.surface.extent())?; + frame_current(state.camera3?, aabb, region.extent()).ok() + }); if let Some(next) = solid_fit { - state.camera3 = Some(next); + apply_nav_camera(state, next); } else { state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); } @@ -3838,6 +4026,8 @@ mod tests { confirm_action: None, menu_action: None, settings_change: None, + view_pick: None, + view_menu: None, } } @@ -4073,7 +4263,10 @@ mod tests { super::drag_gesture(ModifiersState::empty()), NavGesture::Orbit ); - assert_eq!(super::drag_gesture(ModifiersState::CONTROL), NavGesture::Pan); + assert_eq!( + super::drag_gesture(ModifiersState::CONTROL), + NavGesture::Pan + ); assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Zoom); assert_eq!(super::drag_gesture(ModifiersState::ALT), NavGesture::Roll); assert_eq!( diff --git a/crates/bone-app/src/settings.rs b/crates/bone-app/src/settings.rs index cdc908a..f585374 100644 --- a/crates/bone-app/src/settings.rs +++ b/crates/bone-app/src/settings.rs @@ -9,6 +9,7 @@ use crate::hotkeys::HotkeyOverrides; pub struct Settings { pub pick_aperture: PickAperture, pub hotkey_overrides: HotkeyOverrides, + pub reduce_motion: bool, } impl Default for Settings { @@ -16,6 +17,7 @@ impl Default for Settings { Self { pick_aperture: PickAperture::DEFAULT, hotkey_overrides: HotkeyOverrides::default(), + reduce_motion: false, } } } @@ -24,6 +26,8 @@ impl Default for Settings { struct SettingsOnDisk { aperture_px: u32, hotkey_overrides: HotkeyOverrides, + #[serde(default)] + reduce_motion: bool, } impl From<&Settings> for SettingsOnDisk { @@ -31,6 +35,7 @@ impl From<&Settings> for SettingsOnDisk { Self { aperture_px: value.pick_aperture.radius_px(), hotkey_overrides: value.hotkey_overrides.clone(), + reduce_motion: value.reduce_motion, } } } @@ -40,6 +45,7 @@ impl SettingsOnDisk { Settings { pick_aperture: PickAperture::new(self.aperture_px), hotkey_overrides: self.hotkey_overrides, + reduce_motion: self.reduce_motion, } } } diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index 33baa05..889fa9a 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -8,7 +8,8 @@ use bone_document::{ SketchVersion, }; use bone_types::{ - Angle, ExtrudeId, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, SketchId, + Angle, Camera3, ExtrudeId, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, + SketchId, }; use bone_ui::a11y::{AccessNode, Role}; use bone_ui::frame::{FrameCtx, InteractDeclaration}; @@ -22,14 +23,14 @@ use bone_ui::strings::{StringKey, StringTable}; use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; use bone_ui::widgets::GlyphMark; use bone_ui::widgets::{ - AngleEditor, BoolEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, - LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, - PanelState, PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyRow, - RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, SelectionEditor, Slider, - SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, TabsOrientation, - ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, show_hotkey_capture, - show_menu_bar, show_property_grid, show_ribbon, show_slider, show_status_bar, show_tabs, - show_tree_view, + AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, Dialog, DialogButton, + HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, + MenuBarEntry, MenuBarState, MenuItem, PanelState, PropertyCell, PropertyEditor, PropertyGrid, + PropertyOption, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, + SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, + Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, + show_checkbox, show_dialog, show_hotkey_capture, show_menu_bar, show_property_grid, + show_ribbon, show_slider, show_status_bar, show_tabs, show_tree_view, }; use bone_ui::{WidgetId, WidgetKey}; use uom::si::angle::degree; @@ -51,6 +52,9 @@ use crate::status_badge::{ status_panel_widget_id, }; use crate::strings; +use crate::view_cube::{ + ViewCubeInputs, ViewCubeMenuAction, ViewPick, ViewUi, render_view_cube, render_view_selector, +}; const RIBBON_GROUP_PADDING_PX: f32 = 8.0; const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0; @@ -92,6 +96,9 @@ struct ShellIds { viewport: WidgetId, confirm_accept: WidgetId, confirm_cancel: WidgetId, + view_cube: WidgetId, + view_cube_menu: WidgetId, + view_selector: WidgetId, status_bar: WidgetId, doc_tabs: WidgetId, doc_tab_model: WidgetId, @@ -121,6 +128,7 @@ struct ShellIds { menu_sketch_exit: WidgetId, settings_dialog: WidgetId, settings_aperture_slider: WidgetId, + settings_reduce_motion: WidgetId, settings_reset: WidgetId, settings_close: WidgetId, keyboard_dialog: WidgetId, @@ -159,6 +167,9 @@ impl ShellIds { viewport, confirm_accept: viewport.child(WidgetKey::new("confirm.accept")), confirm_cancel: viewport.child(WidgetKey::new("confirm.cancel")), + view_cube: viewport.child(WidgetKey::new("view_cube")), + view_cube_menu: viewport.child(WidgetKey::new("view_cube.menu")), + view_selector: viewport.child(WidgetKey::new("view_selector")), status_bar: root.child(WidgetKey::new("status")), doc_tabs: root.child(WidgetKey::new("doc_tabs")), doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")), @@ -188,6 +199,7 @@ impl ShellIds { menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), settings_dialog, settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), + settings_reduce_motion: settings_dialog.child(WidgetKey::new("reduce_motion.checkbox")), settings_reset: settings_dialog.child(WidgetKey::new("button.reset")), settings_close: settings_dialog.child(WidgetKey::new("button.close")), keyboard_dialog, @@ -437,6 +449,8 @@ pub struct ShellFrame { pub confirm_action: Option, pub menu_action: Option, pub settings_change: Option, + pub view_pick: Option, + pub view_menu: Option, } #[derive(Copy, Clone, Debug, PartialEq)] @@ -466,6 +480,8 @@ impl ShellFrame { confirm_action: None, menu_action: None, settings_change: None, + view_pick: None, + view_menu: None, } } } @@ -532,6 +548,8 @@ impl Shell { settings: &Settings, viewport_size: LayoutSize, cursor_world: Option, + camera3: Option, + view: &mut ViewUi, ) -> ShellFrame { let theme = ctx.theme(); let direction = ctx.direction(); @@ -684,6 +702,20 @@ impl Shell { let confirm = render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints); let confirm_action = confirm; + let normal_to_available = active_sketch.is_some(); + let (view_pick, view_menu) = render_view_controls( + ctx, + ViewControlInputs { + viewport: viewport_rect, + camera3, + ids: &self.ids, + view, + normal_to_available, + confirm_visible, + }, + &mut paints, + &mut popover_paints, + ); let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); let activated_feature_tool = @@ -747,18 +779,74 @@ impl Shell { confirm_action, menu_action, settings_change, + view_pick, + view_menu, } } } +struct ViewControlInputs<'a> { + viewport: LayoutRect, + camera3: Option, + ids: &'a ShellIds, + view: &'a mut ViewUi, + normal_to_available: bool, + confirm_visible: bool, +} + +fn render_view_controls( + ctx: &mut FrameCtx<'_>, + inputs: ViewControlInputs<'_>, + paints: &mut Vec, + popover_paints: &mut Vec, +) -> (Option, Option) { + let ViewControlInputs { + viewport, + camera3, + ids, + view, + normal_to_available, + confirm_visible, + } = inputs; + let Some(camera) = camera3 else { + return (None, None); + }; + let outcome = render_view_cube( + ctx, + ViewCubeInputs { + viewport, + camera, + base: ids.view_cube, + menu_id: ids.view_cube_menu, + view, + normal_to_available, + confirm_visible, + }, + paints, + popover_paints, + ); + let selected = render_view_selector( + ctx, + viewport, + ids.view_selector, + view, + normal_to_available, + popover_paints, + ); + let pick = outcome.pick.or_else(|| selected.map(ViewPick::Standard)); + (pick, outcome.menu) +} + const SETTINGS_DIALOG_WIDTH: f32 = 420.0; -const SETTINGS_DIALOG_HEIGHT: f32 = 220.0; +const SETTINGS_DIALOG_HEIGHT: f32 = 256.0; const SETTINGS_DIALOG_GUTTER: f32 = 16.0; const SETTINGS_LABEL_HEIGHT: f32 = 20.0; const SETTINGS_HINT_HEIGHT: f32 = 36.0; const SETTINGS_SLIDER_HEIGHT: f32 = 28.0; +const SETTINGS_CHECKBOX_HEIGHT: f32 = 24.0; const SETTINGS_LABEL_TO_HINT_GAP: f32 = 6.0; const SETTINGS_HINT_TO_SLIDER_GAP: f32 = 12.0; +const SETTINGS_SLIDER_TO_CHECKBOX_GAP: f32 = 12.0; const PICK_APERTURE_MIN_PX: i32 = 1; const PICK_APERTURE_MAX_PX: i32 = 30; @@ -791,7 +879,8 @@ fn render_settings_dialog( settings.pick_aperture.radius_px(), ); let aperture_slider_id = ids.settings_aperture_slider; - let (response, slider_change) = show_dialog( + let reduce_motion_id = ids.settings_reduce_motion; + let (response, body_change) = show_dialog( ctx, Dialog::new( ids.settings_dialog, @@ -805,6 +894,7 @@ fn render_settings_dialog( ctx, body_rect, aperture_slider_id, + reduce_motion_id, settings, aperture_label_text, paint, @@ -817,17 +907,18 @@ fn render_settings_dialog( } if response.activated == Some(ids.settings_reset) { return Some(Settings { - pick_aperture: PickAperture::DEFAULT, hotkey_overrides: settings.hotkey_overrides.clone(), + ..Settings::default() }); } - slider_change + body_change } fn settings_dialog_body( ctx: &mut FrameCtx<'_>, body_rect: LayoutRect, aperture_slider_id: WidgetId, + reduce_motion_id: WidgetId, settings: &Settings, aperture_label_text: String, paint: &mut Vec, @@ -865,7 +956,7 @@ fn settings_dialog_body( ), ); paint.extend(response.paint); - response.changed.then(|| { + let slider_change = response.changed.then(|| { let clamped = response .value .clamp(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX); @@ -876,60 +967,64 @@ fn settings_dialog_body( let radius = clamped as u32; Settings { pick_aperture: PickAperture::new(radius), - hotkey_overrides: settings.hotkey_overrides.clone(), + ..settings.clone() } - }) + }); + let checkbox_state = if settings.reduce_motion { + CheckboxState::Checked + } else { + CheckboxState::Unchecked + }; + let checkbox = show_checkbox( + ctx, + Checkbox::new( + reduce_motion_id, + settings_checkbox_rect(body_rect), + strings::SETTINGS_REDUCE_MOTION_LABEL, + checkbox_state, + ), + ); + paint.extend(checkbox.paint); + let checkbox_change = checkbox.toggled.then(|| Settings { + reduce_motion: !settings.reduce_motion, + ..settings.clone() + }); + slider_change.or(checkbox_change) } -fn settings_label_rect(body: LayoutRect) -> LayoutRect { +fn settings_row_rect(body: LayoutRect, top_offset: f32, height: f32) -> LayoutRect { LayoutRect::new( LayoutPos::new( LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), - LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER), + LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER + top_offset), ), LayoutSize::new( LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), - LayoutPx::new(SETTINGS_LABEL_HEIGHT), + LayoutPx::new(height), ), ) } +const SETTINGS_HINT_TOP: f32 = SETTINGS_LABEL_HEIGHT + SETTINGS_LABEL_TO_HINT_GAP; +const SETTINGS_SLIDER_TOP: f32 = + SETTINGS_HINT_TOP + SETTINGS_HINT_HEIGHT + SETTINGS_HINT_TO_SLIDER_GAP; +const SETTINGS_CHECKBOX_TOP: f32 = + SETTINGS_SLIDER_TOP + SETTINGS_SLIDER_HEIGHT + SETTINGS_SLIDER_TO_CHECKBOX_GAP; + +fn settings_label_rect(body: LayoutRect) -> LayoutRect { + settings_row_rect(body, 0.0, SETTINGS_LABEL_HEIGHT) +} + fn settings_hint_rect(body: LayoutRect) -> LayoutRect { - LayoutRect::new( - LayoutPos::new( - LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), - LayoutPx::new( - body.origin.y.value() - + SETTINGS_DIALOG_GUTTER - + SETTINGS_LABEL_HEIGHT - + SETTINGS_LABEL_TO_HINT_GAP, - ), - ), - LayoutSize::new( - LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), - LayoutPx::new(SETTINGS_HINT_HEIGHT), - ), - ) + settings_row_rect(body, SETTINGS_HINT_TOP, SETTINGS_HINT_HEIGHT) } fn settings_slider_rect(body: LayoutRect) -> LayoutRect { - LayoutRect::new( - LayoutPos::new( - LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), - LayoutPx::new( - body.origin.y.value() - + SETTINGS_DIALOG_GUTTER - + SETTINGS_LABEL_HEIGHT - + SETTINGS_LABEL_TO_HINT_GAP - + SETTINGS_HINT_HEIGHT - + SETTINGS_HINT_TO_SLIDER_GAP, - ), - ), - LayoutSize::new( - LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), - LayoutPx::new(SETTINGS_SLIDER_HEIGHT), - ), - ) + settings_row_rect(body, SETTINGS_SLIDER_TOP, SETTINGS_SLIDER_HEIGHT) +} + +fn settings_checkbox_rect(body: LayoutRect) -> LayoutRect { + settings_row_rect(body, SETTINGS_CHECKBOX_TOP, SETTINGS_CHECKBOX_HEIGHT) } const KEYBOARD_DIALOG_WIDTH: f32 = 460.0; @@ -992,13 +1087,13 @@ fn render_keyboard_dialog( } if response.activated == Some(ids.keyboard_dialog_reset) { return Some(Settings { - pick_aperture: settings.pick_aperture, hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(), + ..settings.clone() }); } next_overrides.map(|overrides| Settings { - pick_aperture: settings.pick_aperture, hotkey_overrides: overrides, + ..settings.clone() }) } @@ -3253,6 +3348,8 @@ mod tests { &Settings::default(), size, None, + None, + &mut ViewUi::default(), ) } @@ -3912,6 +4009,8 @@ mod tests { &Settings::default(), layout_size(1600.0, 900.0), None, + None, + &mut ViewUi::default(), ); let any_smart_dim_label = frame.paints.iter().any(|p| { matches!( @@ -4348,6 +4447,8 @@ mod tests { &Settings::default(), layout_size(1280.0, 800.0), None, + None, + &mut ViewUi::default(), ) }; *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); @@ -4959,6 +5060,8 @@ mod tests { &Settings::default(), canvas, None, + None, + &mut ViewUi::default(), ); } (shell, a11y, focus) diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index b2c86f6..481b42e 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -14,6 +14,23 @@ pub const RIBBON_GROUP_EXTRUDE: StringKey = StringKey::new("shell.ribbon.group.e pub const CONFIRM_ACCEPT: StringKey = StringKey::new("confirm.accept"); pub const CONFIRM_CANCEL: StringKey = StringKey::new("confirm.cancel"); +pub const VIEW_FRONT: StringKey = StringKey::new("view.front"); +pub const VIEW_BACK: StringKey = StringKey::new("view.back"); +pub const VIEW_LEFT: StringKey = StringKey::new("view.left"); +pub const VIEW_RIGHT: StringKey = StringKey::new("view.right"); +pub const VIEW_TOP: StringKey = StringKey::new("view.top"); +pub const VIEW_BOTTOM: StringKey = StringKey::new("view.bottom"); +pub const VIEW_ISOMETRIC: StringKey = StringKey::new("view.isometric"); +pub const VIEW_NORMAL_TO: StringKey = StringKey::new("view.normal_to"); +pub const VIEW_CUBE: StringKey = StringKey::new("view.cube"); +pub const VIEW_HOME: StringKey = StringKey::new("view.home"); +pub const VIEW_CUBE_EDGE: StringKey = StringKey::new("view.cube.edge"); +pub const VIEW_CUBE_CORNER: StringKey = StringKey::new("view.cube.corner"); +pub const VIEW_SELECTOR: StringKey = StringKey::new("view.selector"); +pub const VIEW_CUBE_SET_HOME: StringKey = StringKey::new("view.cube.set_home"); +pub const VIEW_CUBE_FIT: StringKey = StringKey::new("view.cube.fit"); +pub const VIEW_CUBE_NORMAL_TO: StringKey = StringKey::new("view.cube.normal_to"); + pub const TOOL_POINT: StringKey = StringKey::new("tool.point"); pub const TOOL_LINE: StringKey = StringKey::new("tool.line"); pub const TOOL_CENTERPOINT_ARC: StringKey = StringKey::new("tool.centerpoint_arc"); @@ -162,6 +179,7 @@ pub const KEYBOARD_DIALOG_TITLE: StringKey = StringKey::new("keyboard.dialog.tit pub const SETTINGS_DIALOG_TITLE: StringKey = StringKey::new("settings.dialog.title"); pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = StringKey::new("settings.pick_aperture.label"); pub const SETTINGS_PICK_APERTURE_HINT: StringKey = StringKey::new("settings.pick_aperture.hint"); +pub const SETTINGS_REDUCE_MOTION_LABEL: StringKey = StringKey::new("settings.reduce_motion.label"); pub const SETTINGS_RESET: StringKey = StringKey::new("settings.reset"); pub const SETTINGS_CLOSE: StringKey = StringKey::new("settings.close"); pub const HOTKEY_SECTION_HEADING: StringKey = StringKey::new("hotkey.section.heading"); @@ -269,6 +287,22 @@ const EN_US: &[(StringKey, &str)] = &[ (RIBBON_GROUP_EXTRUDE, "Extrude"), (CONFIRM_ACCEPT, "Accept"), (CONFIRM_CANCEL, "Cancel"), + (VIEW_FRONT, "Front"), + (VIEW_BACK, "Back"), + (VIEW_LEFT, "Left"), + (VIEW_RIGHT, "Right"), + (VIEW_TOP, "Top"), + (VIEW_BOTTOM, "Bottom"), + (VIEW_ISOMETRIC, "Isometric"), + (VIEW_NORMAL_TO, "Normal To"), + (VIEW_CUBE, "View Cube"), + (VIEW_HOME, "Home"), + (VIEW_CUBE_EDGE, "Edge View"), + (VIEW_CUBE_CORNER, "Corner View"), + (VIEW_SELECTOR, "View Orientation"), + (VIEW_CUBE_SET_HOME, "Set as Home"), + (VIEW_CUBE_FIT, "Fit to Window"), + (VIEW_CUBE_NORMAL_TO, "View Normal To"), (TOOL_POINT, "Point"), (TOOL_LINE, "Line"), (TOOL_CENTERPOINT_ARC, "Centerpoint Arc"), @@ -423,6 +457,7 @@ const EN_US: &[(StringKey, &str)] = &[ SETTINGS_PICK_APERTURE_HINT, "How many pixels of slack the picker allows when clicking thin lines.", ), + (SETTINGS_REDUCE_MOTION_LABEL, "Reduce motion"), (SETTINGS_RESET, "Reset"), (SETTINGS_CLOSE, "Close"), (HOTKEY_SECTION_HEADING, "Keyboard shortcuts"), @@ -518,6 +553,22 @@ const AR_XB: &[(StringKey, &str)] = &[ (RIBBON_GROUP_EXTRUDE, "[!! Êxtrude !!]"), (CONFIRM_ACCEPT, "[!! Accêpt !!]"), (CONFIRM_CANCEL, "[!! Cancêl !!]"), + (VIEW_FRONT, "[!! Frônt !!]"), + (VIEW_BACK, "[!! Bâck !!]"), + (VIEW_LEFT, "[!! Lêft !!]"), + (VIEW_RIGHT, "[!! Rîght !!]"), + (VIEW_TOP, "[!! Tôp !!]"), + (VIEW_BOTTOM, "[!! Bôttom !!]"), + (VIEW_ISOMETRIC, "[!! Isômetric !!]"), + (VIEW_NORMAL_TO, "[!! Nôrmal Tô !!]"), + (VIEW_CUBE, "[!! Vîew Cûbe !!]"), + (VIEW_HOME, "[!! Hôme !!]"), + (VIEW_CUBE_EDGE, "[!! Êdge Vîew !!]"), + (VIEW_CUBE_CORNER, "[!! Côrner Vîew !!]"), + (VIEW_SELECTOR, "[!! Vîew Orientâtion !!]"), + (VIEW_CUBE_SET_HOME, "[!! Sêt as Hôme !!]"), + (VIEW_CUBE_FIT, "[!! Fît to Wîndow !!]"), + (VIEW_CUBE_NORMAL_TO, "[!! Vîew Nôrmal Tô !!]"), (TOOL_POINT, "[!! Pôint !!]"), (TOOL_LINE, "[!! Lîne !!]"), (TOOL_CENTERPOINT_ARC, "[!! Cêntrepoint Arc !!]"), @@ -681,6 +732,7 @@ const AR_XB: &[(StringKey, &str)] = &[ SETTINGS_PICK_APERTURE_HINT, "[!! Hôw mâny pîxels ôf slâck thê pîcker âllows whên clîcking thîn lînes. !!]", ), + (SETTINGS_REDUCE_MOTION_LABEL, "[!! Redûce môtion !!]"), (SETTINGS_RESET, "[!! Resêt !!]"), (SETTINGS_CLOSE, "[!! Clôse !!]"), (HOTKEY_SECTION_HEADING, "[!! Kêyboard shortcûts !!]"),