diff --git a/src/app/automation.rs b/src/app/automation.rs index e0b11eb66..6d7b4b05e 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1265,4 +1265,63 @@ mod tests { let _ = std::fs::remove_file(sidecar); let _ = std::fs::remove_file(&path); } + + #[test] + fn test_pline_line_then_arc() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start PLINE + let _ = app.update(Message::CommandInput("PLINE".to_string())); + let _ = app.update(Message::CommandSubmit); + + // Click first point (100, 100) + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move to (200, 100) and click second point (draw line) + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + let wid = app.main_window.unwrap_or_else(iced::window::Id::unique); + + // Switch to arc: Option 1 - CommandOptionPick("A") + let _ = app.update(Message::CommandOptionPick("A".to_string())); + let _ = app.view(wid); + println!("ENTITIES: {}", app.tabs[0].scene.document.entities().count()); + println!("CMD: {:?}", app.tabs[0].active_cmd.as_ref().map(|c| c.name())); + + // Move mouse! + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(201.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 150.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 150.0))); + let _ = app.view(wid); + + // Click arc point + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 160.0))); + + // Switch to line + let _ = app.update(Message::CommandOptionPick("L".to_string())); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 150.0))); + + // Finish + let _ = app.update(Message::CommandOptionPick(String::new())); + } } diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 61542c690..852c42400 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -17,7 +17,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; // ── Ribbon definition ────────────────────────────────────────────────────── @@ -443,6 +443,7 @@ impl CadCommand for PlineCommand { let pt = self.plane.to_local(pt_world).as_vec3(); let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut tangent_geom = None; match self.mode { SegMode::Line => { pts.push([last.x, last.y, last.z]); @@ -456,8 +457,44 @@ impl CadCommand for PlineCommand { .map(|t| t.as_dvec2()) .unwrap_or(DVec2::new(1.0, 0.0)); let bulge = compute_bulge(a, tangent, b); - let arc_pts = arc_sample_points(last, bulge, pt, 16); - pts.extend_from_slice(&arc_pts); + if bulge.abs() >= 1e-9 && (b - a).length_squared() >= 1e-12 { + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], bulge) + { + for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) { + pts.push([s[0] as f32, s[1] as f32, last.z]); + } + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + let world_center = self + .plane + .to_world(DVec3::new(arc.center[0], arc.center[1], last.z as f64)); + if arc.radius > 0.0 + && arc.radius.is_finite() + && arc.radius <= 1e6 + && sa.is_finite() + && ea.is_finite() + { + tangent_geom = Some(TangentGeom::Arc { + center: world_center.to_array(), + axis_x: self.plane.x.to_array(), + axis_y: self.plane.y.to_array(), + radius: arc.radius, + start_angle: sa, + end_angle: ea, + }); + } + } else { + let arc_pts = arc_sample_points(last, bulge, pt, 64); + pts.extend_from_slice(&arc_pts); + } + } else { + pts.push([last.x, last.y, last.z]); + pts.push([pt.x, pt.y, pt.z]); + } } } @@ -465,12 +502,16 @@ impl CadCommand for PlineCommand { .into_iter() .map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array()) .collect(); - Some(WireModel::solid( + let mut wire = WireModel::solid( "rubber_band".into(), world_points, WireModel::CYAN, false, - )) + ); + if let Some(tg) = tangent_geom { + wire.tangent_geoms.push(tg); + } + Some(wire) } } @@ -548,4 +589,20 @@ mod tests { CmdResult::FinalizeLiveEntity(h) if h == handle )); } + + #[test] + fn test_draw_line_then_arc() { + let mut cmd = PlineCommand::new(); + cmd.on_point(DVec3::new(0.0, 0.0, 0.0)); + let res = cmd.on_point(DVec3::new(10.0, 0.0, 0.0)); + assert!(matches!(res, CmdResult::CommitLiveEntity(_))); + cmd.set_live_handle(Handle::new(1)); + cmd.on_text_input("A"); + let wire = cmd.on_mouse_move(DVec3::new(10.0, 5.0, 0.0)).expect("arc wire"); + assert_eq!(wire.tangent_geoms.len(), 1); + assert!(matches!(wire.tangent_geoms[0], TangentGeom::Arc { .. })); + assert!(wire.points.len() >= 16); + let res2 = cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + assert!(matches!(res2, CmdResult::UpdateLiveEntity { .. })); + } } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 31762e586..cbbfacd7a 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -219,7 +219,33 @@ fn split_mixed_polyline( // Emit each arc segment as an analytical wire (CircleGpu target) for tg in tangent_geoms { - if matches!(tg, TangentGeom::Arc { .. }) { + if let TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } = *tg + { + let mut arc_pts = Vec::with_capacity(17); + let n = 16; + let sweep = if end_angle >= start_angle { + end_angle - start_angle + } else { + end_angle - start_angle + std::f64::consts::TAU + }; + for s in 0..=n { + let frac = s as f64 / n as f64; + let ang = start_angle + frac * sweep; + let p = [ + center[0] + radius * (ang.cos() * axis_x[0] + ang.sin() * axis_y[0]), + center[1] + radius * (ang.cos() * axis_x[1] + ang.sin() * axis_y[1]), + center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), + ]; + arc_pts.push(p); + } + let (points, points_low) = points_to_ds(arc_pts); out.push(WireModel { bg_adapt: None, point_marker: None, @@ -238,8 +264,8 @@ fn split_mixed_polyline( dash_align_end: None, text_verts: Vec::new(), name: name.to_string(), - points: Vec::new(), - points_low: Vec::new(), + points, + points_low, color, selected, pattern_length, diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 90064d106..0660c95e5 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -408,3 +408,76 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() { "the same error must not invalidate twice" ); } + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_pline_arc_switch_preview_and_render() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + use crate::command::CadCommand; + + let mut cmd = crate::modules::draw::draw::polyline::PlineCommand::new(); + cmd.on_point(glam::DVec3::new(0.0, 0.0, 0.0)); + let _res = cmd.on_point(glam::DVec3::new(10.0, 0.0, 0.0)); + cmd.set_live_handle(acadrust::Handle::new(1)); + cmd.on_text_input("A"); + + let test_points = [ + glam::DVec3::new(10.0, 0.0, 0.0), + glam::DVec3::new(10.0000001, 0.0, 0.0), + glam::DVec3::new(10.0, 0.0000001, 0.0), + glam::DVec3::new(10.0, -0.0000001, 0.0), + glam::DVec3::new(9.9999999, 0.0, 0.0), + glam::DVec3::new(15.0, 5.0, 0.0), + glam::DVec3::new(10.0, 10.0, 0.0), + glam::DVec3::new(5.0, 5.0, 0.0), + glam::DVec3::new(0.0, 0.0, 0.0), + ]; + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + for pt in test_points { + let wire = cmd.on_mouse_move(pt); + if let Some(w) = wire { + pipeline.upload_preview_wires(&device, &queue, &[w], &depth); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + } + } +}