From 059e219cb6723df1fb8aabe6626a7539a502f476 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:16:29 +0300 Subject: [PATCH 1/9] Fix sphere edge rendering --- src/scene/pipeline/mesh_gpu.rs | 62 +++++++++++++++++++++------------- src/scene/pipeline/mod.rs | 38 ++++----------------- 2 files changed, 45 insertions(+), 55 deletions(-) diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 93a696b07..f1c55b1ea 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -343,11 +343,11 @@ pub struct MeshBatchChunk { /// erase — the geometry behind them. pub transp_index_buffer: wgpu::Buffer, pub transp_index_count: u32, - /// Triangle-edge line list for plain meshes that carry no B-rep edges. - /// The dedicated vertex buffer matches the edge pipeline layout. + /// Expanded triangle-edge line list for plain meshes that carry no B-rep + /// edges. Keeping the edge endpoints together avoids coupling this buffer + /// to the surface mesh's indexing and vertex representation. pub wire_vertex_buffer: wgpu::Buffer, - pub wire_index_buffer: wgpu::Buffer, - pub wire_index_count: u32, + pub wire_vertex_count: u32, /// B-rep feature edges of ACIS solids, as a standalone LineList vertex /// buffer (pairs of endpoints), drawn non-indexed. Empty for plain meshes. pub edge_vertex_buffer: wgpu::Buffer, @@ -438,7 +438,7 @@ fn make_chunk( queue.write_buffer(&buffer, 0, bytemuck::cast_slice(data)); buffer }; - let compact_vertices = !material_has_textures(material) && wire_indices.is_empty(); + let compact_vertices = !material_has_textures(material); let mk_plain_vertex = |data: &[MeshVertex]| { if data.is_empty() { return stubs.vertex.clone(); @@ -479,18 +479,22 @@ fn make_chunk( } else { instances }; - let wire_vertices: Vec = if wire_indices.is_empty() { - Vec::new() - } else { - verts - .iter() - .map(|vertex| MeshEdgeVertex { + let mut wire_vertices = Vec::with_capacity(wire_indices.len()); + for line in wire_indices.chunks_exact(2) { + let (Some(start), Some(end)) = ( + verts.get(line[0] as usize), + verts.get(line[1] as usize), + ) else { + continue; + }; + for vertex in [start, end] { + wire_vertices.push(MeshEdgeVertex { position: vertex.position, color: face_color, position_low: vertex.position_low, - }) - .collect() - }; + }); + } + } MeshBatchChunk { vertex_buffer: vertex_buffer_override.unwrap_or_else(|| { if compact_vertices { @@ -505,8 +509,7 @@ fn make_chunk( transp_index_buffer: mk_index(transp_indices, "mesh.batch.transp_ibuf"), transp_index_count: transp_indices.len() as u32, wire_vertex_buffer: mk_edge_vertex(&wire_vertices, "mesh.batch.wire_vbuf"), - wire_index_buffer: mk_index(wire_indices, "mesh.batch.wire_ibuf"), - wire_index_count: wire_indices.len() as u32, + wire_vertex_count: wire_vertices.len() as u32, edge_vertex_buffer: edge_buffer_override .unwrap_or_else(|| mk_edge_vertex(edge_verts, "mesh.batch.edge_vbuf")), edge_vertex_count: edge_verts.len() as u32, @@ -1169,8 +1172,7 @@ fn build_instanced_chunk( let material = first.material; let color = first.color; let source_handle = source.handle.value(); - let compact_vertices = !material_has_textures(material) - && (!first.include_edges || !source.edge_verts.is_empty()); + let compact_vertices = !material_has_textures(material); let material_identity = if material_has_textures(material) { material.map_or(0, |material| { material @@ -1582,7 +1584,10 @@ pub fn build_mesh_batch_filtered( let budget = hard_budget.min(32 * 1024 * 1024); let vsize = std::mem::size_of::(); let max_verts = (budget / vsize).max(3); - let max_tris = (budget / (6 * 4)).max(1); // wire-index buffer: 6 u32 per tri + // A triangle contributes three line segments (six standalone edge + // vertices). Bound chunks by the largest buffer produced for wire meshes. + let max_tris = + (budget / (6 * std::mem::size_of::())).max(1); let stubs = MeshBatchStubs::new(device); let mut chunks = Vec::new(); @@ -1796,18 +1801,27 @@ pub fn build_mesh_batch_filtered( .len() .saturating_mul(std::mem::size_of::()) <= hard_budget - && part.indices.len().saturating_mul(2 * std::mem::size_of::()) + && part.indices.len().saturating_mul(std::mem::size_of::()) <= hard_budget && part .set .instance_source .as_ref() .is_some_and(|source| { - source + let feature_edges_fit = source .edge_verts .len() .saturating_mul(std::mem::size_of::()) - <= hard_budget + <= hard_budget; + let triangle_edges_fit = !part.include_edges + || !source.edge_verts.is_empty() + || part + .indices + .len() + .saturating_mul(2) + .saturating_mul(std::mem::size_of::()) + <= hard_budget; + feature_edges_fit && triangle_edges_fit }) && part .indices @@ -2152,8 +2166,8 @@ pub fn build_mesh_batch_filtered( continue; } - // Flush when adding this mesh would overflow either the vertex buffer - // or the wire-index buffer. + // Flush when adding this mesh would overflow either the surface vertex + // buffer or the expanded wire-vertex buffer. if !verts.is_empty() && (verts.len() + mesh.verts.len() > max_verts || wire_indices.len() / 6 + mesh_tris > max_tris) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 034d057ca..969f9de35 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3750,7 +3750,7 @@ impl Pipeline { // Four draw paths share this pass: // - Solid: `mesh_pipeline` + triangle index buf. // - Wireframe: `mesh_wireframe_pipeline` + the - // pre-built `wire_index_buffer`. + // pre-built expanded wire vertex buffer. // - HiddenLine: depth prepass (`mesh_depth_pipeline`, // writes Z, no colour) → wire overlay. // - Solid+Edges: `mesh_pipeline` shaded fill → wire @@ -3809,17 +3809,9 @@ impl Pipeline { ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); // Plain-mesh triangulation edges. - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } // ACIS solid B-rep feature edges (LineList, non-indexed). if c.edge_vertex_count != 0 { @@ -3846,17 +3838,9 @@ impl Pipeline { &[], ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } if c.edge_vertex_count != 0 { pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..)); @@ -4002,17 +3986,9 @@ impl Pipeline { &[], ); pass.set_vertex_buffer(1, c.instance_buffer.slice(..)); - if c.wire_index_count != 0 { + if c.wire_vertex_count != 0 { pass.set_vertex_buffer(0, c.wire_vertex_buffer.slice(..)); - pass.set_index_buffer( - c.wire_index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - pass.draw_indexed( - 0..c.wire_index_count, - 0, - 0..c.instance_count, - ); + pass.draw(0..c.wire_vertex_count, 0..c.instance_count); } if c.edge_vertex_count != 0 { pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..)); From 0bfa48909f5e0b984c07c70cd0887384c3d274e4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:38:05 +0300 Subject: [PATCH 2/9] Match pyramid creation and properties behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/model_ops.rs | 2 + src/modules/model/primitive_cmd.rs | 710 ++++++++++++++++++++++++++++- src/scene/model/solid_history.rs | 282 +++++++++++- src/scene/model/solid_model.rs | 11 + 6 files changed, 987 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bcdc4b0d..8736aab97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=48c634995fa60d3928c37bae49b12b421c56c886#48c634995fa60d3928c37bae49b12b421c56c886" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=67ff1f59f380afcfd939fa98883012a0c7b109f7#67ff1f59f380afcfd939fa98883012a0c7b109f7" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1536791ee..cf6913615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a0f7d444f1607bc4b2c881060cbe7ea1014253cb", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "48c634995fa60d3928c37bae49b12b421c56c886", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67ff1f59f380afcfd939fa98883012a0c7b109f7", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 2fe32100d..a7204dd4c 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -699,8 +699,10 @@ impl super::OpenCADStudio { let history = solid_history::pyramid_op( glam::DMat4::IDENTITY.to_cols_array(), radius, + 0.0, height, n, + true, ); let handle = self.add_solid_model(entity, solid, history); self.tabs[i].scene.deselect_all(); diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index cd7d95537..c0bfcdc6a 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -99,6 +99,48 @@ enum ConeStep { TopRadius, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyramidType { + Circumscribed, + Inscribed, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyramidStep { + BaseCenter, + Sides, + EdgeFirst, + EdgeSecond, + BaseRadius, + Height, + HeightAfterTopRadius, + HeightFirstPoint, + HeightSecondPoint, + AxisEndpoint, + TopRadius, +} + +#[derive(Clone, Copy)] +struct PyramidDefaults { + displayed_base_radius: f64, + displayed_top_radius: f64, + height: f64, + sides: usize, + kind: PyramidType, +} + +impl Default for PyramidDefaults { + fn default() -> Self { + Self { + displayed_base_radius: 1.0, + displayed_top_radius: 0.0, + height: 1.0, + sides: 4, + kind: PyramidType::Circumscribed, + } + } +} + #[derive(Clone, Copy)] struct ConeDefaults { base_x_radius: f64, @@ -211,6 +253,29 @@ fn cone_defaults() -> &'static std::sync::Mutex { DEFAULTS.get_or_init(|| std::sync::Mutex::new(ConeDefaults::default())) } +fn pyramid_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(PyramidDefaults::default())) +} + +fn pyramid_apothem_factor(sides: usize) -> f64 { + (std::f64::consts::PI / sides.max(3) as f64).cos() +} + +fn pyramid_display_to_circumradius(value: f64, kind: PyramidType, sides: usize) -> f64 { + match kind { + PyramidType::Circumscribed => value / pyramid_apothem_factor(sides), + PyramidType::Inscribed => value, + } +} + +fn pyramid_circumradius_to_display(value: f64, kind: PyramidType, sides: usize) -> f64 { + match kind { + PyramidType::Circumscribed => value * pyramid_apothem_factor(sides), + PyramidType::Inscribed => value, + } +} + impl Shape { fn from_id(id: &str) -> Option { Some(match id { @@ -271,6 +336,15 @@ pub struct PrimitiveCommand { cone_base_y_radius: f64, cone_top_radius: f64, cone_defaults: ConeDefaults, + pyramid_step: PyramidStep, + pyramid_frame: Option, + pyramid_edge_first: Option, + pyramid_height_first: Option, + pyramid_base_radius: f64, + pyramid_top_radius: f64, + pyramid_sides: usize, + pyramid_type: PyramidType, + pyramid_defaults: PyramidDefaults, plane: WorkingPlane, } @@ -280,6 +354,10 @@ impl PrimitiveCommand { .lock() .map(|value| *value) .unwrap_or_default(); + let pyramid_remembered = pyramid_defaults() + .lock() + .map(|value| *value) + .unwrap_or_default(); Self { shape: Shape::from_id(id).unwrap_or(Shape::Box), pts: Vec::new(), @@ -300,6 +378,23 @@ impl PrimitiveCommand { cone_base_y_radius: remembered.base_y_radius, cone_top_radius: remembered.top_radius, cone_defaults: remembered, + pyramid_step: PyramidStep::BaseCenter, + pyramid_frame: None, + pyramid_edge_first: None, + pyramid_height_first: None, + pyramid_base_radius: pyramid_display_to_circumradius( + pyramid_remembered.displayed_base_radius, + pyramid_remembered.kind, + pyramid_remembered.sides, + ), + pyramid_top_radius: pyramid_display_to_circumradius( + pyramid_remembered.displayed_top_radius, + pyramid_remembered.kind, + pyramid_remembered.sides, + ), + pyramid_sides: pyramid_remembered.sides, + pyramid_type: pyramid_remembered.kind, + pyramid_defaults: pyramid_remembered, plane: WorkingPlane::default(), } } @@ -954,6 +1049,481 @@ impl PrimitiveCommand { } } + fn pyramid_frame_at(&self, center: DVec3) -> WorkingPlane { + WorkingPlane { + origin: center, + x: self.plane.x, + y: self.plane.y, + z: self.plane.z, + } + } + + fn pyramid_set_base_from_point(&mut self, point: DVec3) -> bool { + let Some(frame) = self.pyramid_frame else { + return false; + }; + let delta = point - frame.origin; + let planar = delta - frame.z * delta.dot(frame.z); + let displayed_radius = planar.length(); + let Some(direction) = planar.try_normalize() else { + return false; + }; + if !displayed_radius.is_finite() || displayed_radius < 1e-6 { + return false; + } + let x = match self.pyramid_type { + PyramidType::Inscribed => direction, + PyramidType::Circumscribed => { + let half = std::f64::consts::PI / self.pyramid_sides as f64; + direction * half.cos() - frame.z.cross(direction) * half.sin() + } + }; + let Some(x) = x.try_normalize() else { + return false; + }; + let Some(y) = frame.z.cross(x).try_normalize() else { + return false; + }; + self.pyramid_frame = Some(WorkingPlane::new(frame.origin, x, y)); + self.pyramid_base_radius = pyramid_display_to_circumradius( + displayed_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::Height; + true + } + + fn pyramid_set_base_from_edge(&mut self, first: DVec3, second: DVec3) -> bool { + let delta = second - first; + let planar = delta - self.plane.z * delta.dot(self.plane.z); + let length = planar.length(); + let Some(edge) = planar.try_normalize() else { + return false; + }; + if !length.is_finite() || length < 1e-6 { + return false; + } + let radius = length + / (2.0 * (std::f64::consts::PI / self.pyramid_sides as f64).sin()); + let apothem = radius * pyramid_apothem_factor(self.pyramid_sides); + let center = (first + second) * 0.5 + self.plane.z.cross(edge) * apothem; + let Some(x) = (first - center).try_normalize() else { + return false; + }; + let Some(y) = self.plane.z.cross(x).try_normalize() else { + return false; + }; + self.pyramid_frame = Some(WorkingPlane::new(center, x, y)); + self.pyramid_base_radius = radius; + self.pyramid_step = PyramidStep::Height; + true + } + + fn pyramid_axis_frame(&self, axis: DVec3) -> Option<(WorkingPlane, f64)> { + let current = self.pyramid_frame?; + let height = axis.length(); + if !height.is_finite() || height < 1e-6 { + return None; + } + let z = axis / height; + let mut x = current.x - z * current.x.dot(z); + if x.length_squared() < 1e-12 { + x = current.y - z * current.y.dot(z); + } + let x = x.try_normalize()?; + let y = z.cross(x).try_normalize()?; + Some((WorkingPlane::new(current.origin, x, y), height)) + } + + fn pyramid_history_transform(frame: WorkingPlane) -> [f64; 16] { + glam::DMat4::from_cols( + frame.x.extend(0.0), + frame.y.extend(0.0), + frame.z.extend(0.0), + frame.origin.extend(1.0), + ) + .to_cols_array() + } + + fn build_pyramid(&self, axis: DVec3) -> Option<(Body, SolidHistoryOperation, f64)> { + let (frame, height) = self.pyramid_axis_frame(axis)?; + let local = solid_model::pyramid_frustum_solid( + [0.0; 3], + self.pyramid_base_radius, + self.pyramid_top_radius, + height, + self.pyramid_sides, + )?; + let placed = solid_model::placed( + &local, + frame.x.to_array(), + frame.y.to_array(), + frame.z.to_array(), + frame.origin.to_array(), + )?; + Some(( + placed, + crate::scene::model::solid_history::pyramid_op( + Self::pyramid_history_transform(frame), + self.pyramid_base_radius, + self.pyramid_top_radius, + height, + self.pyramid_sides, + self.pyramid_type == PyramidType::Inscribed, + ), + height, + )) + } + + fn commit_pyramid_axis(&self, axis: DVec3) -> CmdResult { + let Some((solid, history, height)) = self.build_pyramid(axis) else { + return CmdResult::NeedPoint; + }; + if let Ok(mut defaults) = pyramid_defaults().lock() { + *defaults = PyramidDefaults { + displayed_base_radius: pyramid_circumradius_to_display( + self.pyramid_base_radius, + self.pyramid_type, + self.pyramid_sides, + ), + displayed_top_radius: pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ), + height, + sides: self.pyramid_sides, + kind: self.pyramid_type, + }; + } + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&solid); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(solid), + history, + } + } + + fn commit_pyramid_height(&self, height: f64) -> CmdResult { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + if !height.is_finite() || height.abs() < 1e-6 { + return CmdResult::NeedPoint; + } + self.commit_pyramid_axis(frame.z * height) + } + + fn pyramid_height_at(&self, point: DVec3) -> Option { + let frame = self.pyramid_frame?; + Some((point - frame.origin).dot(frame.z)) + } + + fn pyramid_polygon(frame: WorkingPlane, radius: f64, sides: usize, z: f64) -> Vec { + (0..sides) + .map(|index| { + let angle = std::f64::consts::TAU * index as f64 / sides as f64; + frame.origin + + frame.x * (radius * angle.cos()) + + frame.y * (radius * angle.sin()) + + frame.z * z + }) + .collect() + } + + fn pyramid_preview(&self, axis: DVec3) -> Option { + let (frame, height) = self.pyramid_axis_frame(axis)?; + let base = Self::pyramid_polygon(frame, self.pyramid_base_radius, self.pyramid_sides, 0.0); + let top = Self::pyramid_polygon(frame, self.pyramid_top_radius, self.pyramid_sides, height); + let apex = frame.origin + frame.z * height; + let mut points = Vec::new(); + push_path_loop(&mut points, &base); + if self.pyramid_top_radius > 1e-9 { + push_path_loop(&mut points, &top); + } + for index in 0..self.pyramid_sides { + push_segment( + &mut points, + base[index], + if self.pyramid_top_radius > 1e-9 { top[index] } else { apex }, + ); + } + Some(wire("primitive_height_preview", points)) + } + + fn pyramid_base_preview(&self, frame: WorkingPlane, displayed_radius: f64) -> Option { + if !displayed_radius.is_finite() || displayed_radius < 1e-9 { + return None; + } + let radius = pyramid_display_to_circumradius( + displayed_radius, + self.pyramid_type, + self.pyramid_sides, + ); + let points = Self::pyramid_polygon(frame, radius, self.pyramid_sides, 0.0); + let mut wire_points = Vec::new(); + push_path_loop(&mut wire_points, &points); + Some(wire("primitive_preview", wire_points)) + } + + fn pyramid_prompt(&self) -> String { + let base_default = self.pyramid_defaults.displayed_base_radius; + let top_default = self.pyramid_defaults.displayed_top_radius; + match self.pyramid_step { + PyramidStep::BaseCenter => crate::tf!( + "PYRAMID Specify center point of base or [Edge/Sides]:" + ).into_owned(), + PyramidStep::Sides => crate::tf!( + "PYRAMID Enter number of sides <{}>:", + self.pyramid_sides + ).into_owned(), + PyramidStep::EdgeFirst => t!("PYRAMID Specify first endpoint of edge:").into_owned(), + PyramidStep::EdgeSecond => t!("PYRAMID Specify second endpoint of edge:").into_owned(), + PyramidStep::BaseRadius => match self.pyramid_type { + PyramidType::Circumscribed => crate::tf!( + "PYRAMID Specify base radius or [Inscribed] <{:.4}>:", + base_default + ).into_owned(), + PyramidType::Inscribed => crate::tf!( + "PYRAMID Specify base radius or [Circumscribed] <{:.4}>:", + base_default + ).into_owned(), + }, + PyramidStep::Height => crate::tf!( + "PYRAMID Specify height or [2Point/Axis endpoint/Top radius] <{:.4}>:", + self.pyramid_defaults.height + ).into_owned(), + PyramidStep::HeightAfterTopRadius => crate::tf!( + "PYRAMID Specify height or [2Point/Axis endpoint] <{:.4}>:", + self.pyramid_defaults.height + ).into_owned(), + PyramidStep::HeightFirstPoint => t!("PYRAMID Specify first point for height:").into_owned(), + PyramidStep::HeightSecondPoint => t!("PYRAMID Specify second point for height:").into_owned(), + PyramidStep::AxisEndpoint => t!("PYRAMID Specify axis endpoint:").into_owned(), + PyramidStep::TopRadius => crate::tf!( + "PYRAMID Specify top radius <{:.4}>:", + top_default + ).into_owned(), + } + } + + fn pyramid_options(&self) -> Vec { + match self.pyramid_step { + PyramidStep::BaseCenter => vec![ + CmdOption::new(t!("Edge").as_ref(), "E"), + CmdOption::new(t!("Sides").as_ref(), "S"), + ], + PyramidStep::BaseRadius => vec![match self.pyramid_type { + PyramidType::Circumscribed => CmdOption::new(t!("Inscribed").as_ref(), "I"), + PyramidType::Inscribed => CmdOption::new(t!("Circumscribed").as_ref(), "C"), + }], + PyramidStep::Height => vec![ + CmdOption::new("2Point", "2P"), + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + CmdOption::new(t!("Top radius").as_ref(), "T"), + ], + PyramidStep::HeightAfterTopRadius => vec![ + CmdOption::new("2Point", "2P"), + CmdOption::new(t!("Axis endpoint").as_ref(), "A"), + ], + _ => Vec::new(), + } + } + + fn on_pyramid_point(&mut self, point: DVec3) -> CmdResult { + match self.pyramid_step { + PyramidStep::BaseCenter => { + self.pyramid_frame = Some(self.pyramid_frame_at(point)); + self.pyramid_step = PyramidStep::BaseRadius; + CmdResult::NeedPoint + } + PyramidStep::EdgeFirst => { + self.pyramid_edge_first = Some(point); + self.pyramid_step = PyramidStep::EdgeSecond; + CmdResult::NeedPoint + } + PyramidStep::EdgeSecond => { + if let Some(first) = self.pyramid_edge_first { + self.pyramid_set_base_from_edge(first, point); + } + CmdResult::NeedPoint + } + PyramidStep::BaseRadius => { + self.pyramid_set_base_from_point(point); + CmdResult::NeedPoint + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => self + .pyramid_height_at(point) + .map(|height| self.commit_pyramid_height(height)) + .unwrap_or(CmdResult::NeedPoint), + PyramidStep::HeightFirstPoint => { + self.pyramid_height_first = Some(point); + self.pyramid_step = PyramidStep::HeightSecondPoint; + CmdResult::NeedPoint + } + PyramidStep::HeightSecondPoint => self + .pyramid_height_first + .map(|first| self.commit_pyramid_height(first.distance(point))) + .unwrap_or(CmdResult::NeedPoint), + PyramidStep::AxisEndpoint => { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + self.commit_pyramid_axis(point - frame.origin) + } + PyramidStep::TopRadius => { + let Some(frame) = self.pyramid_frame else { + return CmdResult::NeedPoint; + }; + let delta = point - frame.origin; + let displayed = (delta - frame.z * delta.dot(frame.z)).length(); + if displayed.is_finite() { + self.pyramid_top_radius = pyramid_display_to_circumradius( + displayed, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + } + CmdResult::NeedPoint + } + PyramidStep::Sides => CmdResult::NeedPoint, + } + } + + fn on_pyramid_text(&mut self, raw: &str) -> Option { + let token = raw.trim(); + let upper = token.to_uppercase(); + match self.pyramid_step { + PyramidStep::BaseCenter => { + self.pyramid_step = match upper.as_str() { + "E" | "EDGE" => PyramidStep::EdgeFirst, + "S" | "SIDES" => PyramidStep::Sides, + _ => return None, + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::BaseRadius if matches!(upper.as_str(), "I" | "INSCRIBED") => { + self.pyramid_type = PyramidType::Inscribed; + return Some(CmdResult::NeedPoint); + } + PyramidStep::BaseRadius if matches!(upper.as_str(), "C" | "CIRCUMSCRIBED") => { + self.pyramid_type = PyramidType::Circumscribed; + return Some(CmdResult::NeedPoint); + } + PyramidStep::Height => { + self.pyramid_step = match upper.as_str() { + "2P" | "2POINT" => PyramidStep::HeightFirstPoint, + "A" | "AXIS" | "AXIS ENDPOINT" => PyramidStep::AxisEndpoint, + "T" | "TOP" | "TOP RADIUS" => PyramidStep::TopRadius, + _ => { + let height = crate::entities::common::parse_typed_length(token)?; + return Some(self.commit_pyramid_height(height)); + } + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::HeightAfterTopRadius => { + self.pyramid_step = match upper.as_str() { + "2P" | "2POINT" => PyramidStep::HeightFirstPoint, + "A" | "AXIS" | "AXIS ENDPOINT" => PyramidStep::AxisEndpoint, + _ => { + let height = crate::entities::common::parse_typed_length(token)?; + return Some(self.commit_pyramid_height(height)); + } + }; + return Some(CmdResult::NeedPoint); + } + PyramidStep::Sides => { + let sides = token.parse::().ok()?; + if !(3..=32).contains(&sides) { + return None; + } + self.pyramid_sides = sides; + self.pyramid_defaults.sides = sides; + self.pyramid_step = PyramidStep::BaseCenter; + return Some(CmdResult::NeedPoint); + } + _ => {} + } + let number = crate::entities::common::parse_typed_length(token)?; + match self.pyramid_step { + PyramidStep::BaseRadius if number > 0.0 => { + let frame = self.pyramid_frame?; + self.pyramid_set_base_from_point(frame.origin + frame.x * number) + .then_some(CmdResult::NeedPoint) + } + PyramidStep::TopRadius if number >= 0.0 => { + self.pyramid_top_radius = pyramid_display_to_circumradius( + number, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn pyramid_mouse_move(&self, point: DVec3) -> Option { + match self.pyramid_step { + PyramidStep::EdgeSecond => { + let first = self.pyramid_edge_first?; + let mut preview = PrimitiveCommand::new("PYRAMID"); + preview.plane = self.plane; + preview.pyramid_sides = self.pyramid_sides; + preview.pyramid_type = self.pyramid_type; + preview.pyramid_set_base_from_edge(first, point); + let frame = preview.pyramid_frame?; + preview.pyramid_base_preview( + frame, + pyramid_circumradius_to_display( + preview.pyramid_base_radius, + preview.pyramid_type, + preview.pyramid_sides, + ), + ) + } + PyramidStep::BaseRadius => { + let frame = self.pyramid_frame?; + let delta = point - frame.origin; + let planar = delta - frame.z * delta.dot(frame.z); + let displayed = planar.length(); + let direction = planar.try_normalize()?; + let x = match self.pyramid_type { + PyramidType::Inscribed => direction, + PyramidType::Circumscribed => { + let half = std::f64::consts::PI / self.pyramid_sides as f64; + direction * half.cos() - frame.z.cross(direction) * half.sin() + } + } + .try_normalize()?; + let y = frame.z.cross(x).try_normalize()?; + self.pyramid_base_preview(WorkingPlane::new(frame.origin, x, y), displayed) + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + let frame = self.pyramid_frame?; + self.pyramid_preview(frame.z * self.pyramid_height_at(point)?) + } + PyramidStep::HeightSecondPoint => { + let frame = self.pyramid_frame?; + self.pyramid_preview(frame.z * self.pyramid_height_first?.distance(point)) + } + PyramidStep::AxisEndpoint => { + let frame = self.pyramid_frame?; + self.pyramid_preview(point - frame.origin) + } + _ => None, + } + } + /// Number of footprint points the shape needs before the height step. fn footprint_pts(&self) -> usize { match self.shape { @@ -1273,17 +1843,7 @@ impl PrimitiveCommand { solid_history::sphere_op(self.history_transform(c), r), ) } - Shape::Pyramid => { - let c = self.pts[0]; - let r = (self.pts[1] - c).length(); - if r < 1e-6 || height < 1e-6 { - return None; - } - ( - solid_model::pyramid_solid([c.x, c.y, c.z], r, height, 4), - solid_history::pyramid_op(self.history_transform(c), r, height, 4), - ) - } + Shape::Pyramid => return None, Shape::Torus => { let c = self.pts[0]; let first = (self.pts[1] - c).length(); @@ -1365,6 +1925,17 @@ impl CadCommand for PrimitiveCommand { (frame.origin, frame.z.normalize_or_zero()) }); } + + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::Height | PyramidStep::HeightAfterTopRadius + ) + .then(|| { + let frame = self.pyramid_frame.unwrap_or(self.plane); + (frame.origin, frame.z.normalize_or_zero()) + }); + } let constrained = if self.shape.rectangular() { self.box_step == BoxStep::Height } else { @@ -1430,6 +2001,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.cone_prompt(); } + if self.shape == Shape::Pyramid { + return self.pyramid_prompt(); + } if self.shape.rectangular() { return match self.box_step { BoxStep::FirstCorner => t!("%{n} Specify first corner or [Center]:", n = n), @@ -1531,6 +2105,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.cone_options(); } + if self.shape == Shape::Pyramid { + return self.pyramid_options(); + } if !self.shape.rectangular() { return Vec::new(); } @@ -1592,6 +2169,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.on_cone_point(pt); } + if self.shape == Shape::Pyramid { + return self.on_pyramid_point(pt); + } if self.shape.rectangular() { let local = self.plane.to_local(pt); if !local.is_finite() { @@ -1735,6 +2315,35 @@ impl CadCommand for PrimitiveCommand { _ => CmdResult::Cancel, }; } + if self.shape == Shape::Pyramid { + return match self.pyramid_step { + PyramidStep::Sides => { + self.pyramid_step = PyramidStep::BaseCenter; + CmdResult::NeedPoint + } + PyramidStep::BaseRadius => { + let displayed = self.pyramid_defaults.displayed_base_radius; + let Some(frame) = self.pyramid_frame else { + return CmdResult::Cancel; + }; + self.pyramid_set_base_from_point(frame.origin + frame.x * displayed); + CmdResult::NeedPoint + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + self.commit_pyramid_height(self.pyramid_defaults.height) + } + PyramidStep::TopRadius => { + self.pyramid_top_radius = pyramid_display_to_circumradius( + self.pyramid_defaults.displayed_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_step = PyramidStep::HeightAfterTopRadius; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + }; + } if self.shape.rectangular() { return if self.shape == Shape::Wedge && self.box_step == BoxStep::Height { self.commit(self.default_height()) @@ -1777,6 +2386,17 @@ impl CadCommand for PrimitiveCommand { | ConeStep::TopRadius ); } + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::BaseCenter + | PyramidStep::Sides + | PyramidStep::BaseRadius + | PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + | PyramidStep::TopRadius + ); + } if self.shape.rectangular() { matches!( self.box_step, @@ -1806,6 +2426,15 @@ impl CadCommand for PrimitiveCommand { | ConeStep::HeightAfterTopRadius ); } + if self.shape == Shape::Pyramid { + return matches!( + self.pyramid_step, + PyramidStep::BaseCenter + | PyramidStep::BaseRadius + | PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + ); + } self.shape.rectangular() && matches!( self.box_step, @@ -1855,6 +2484,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.on_cone_text(raw); } + if self.shape == Shape::Pyramid { + return self.on_pyramid_text(raw); + } if self.shape.rectangular() { let token = raw.trim(); let upper = token.to_uppercase(); @@ -1943,6 +2575,9 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Cone { return self.cone_mouse_move(pt); } + if self.shape == Shape::Pyramid { + return self.pyramid_mouse_move(pt); + } if self.pts.is_empty() { return None; } @@ -2096,6 +2731,32 @@ impl CadCommand for PrimitiveCommand { }); } + if self.shape == Shape::Pyramid { + let frame = self.pyramid_frame.unwrap_or(self.plane); + let (anchor, role) = match self.pyramid_step { + PyramidStep::BaseRadius | PyramidStep::TopRadius => { + (frame.origin, DynRole::Radius) + } + PyramidStep::Height + | PyramidStep::HeightAfterTopRadius + | PyramidStep::AxisEndpoint => (frame.origin, DynRole::Height), + PyramidStep::HeightSecondPoint => { + (self.pyramid_height_first?, DynRole::Height) + } + _ => return None, + }; + return Some(DynSpec { + anchor: DynAnchor::Point(anchor), + fields: vec![DynFieldSpec::new(role)], + guide: if role == DynRole::Radius { + DynGuide::Radius + } else { + DynGuide::None + }, + ref_point: None, + }); + } + let role = if self.shape.rectangular() { match self.box_step { BoxStep::CubeSize | BoxStep::Length => DynRole::Distance, @@ -2153,6 +2814,25 @@ impl CadCommand for PrimitiveCommand { _ => None, }; } + if self.shape == Shape::Pyramid { + return match self.pyramid_step { + PyramidStep::BaseRadius | PyramidStep::TopRadius => { + let frame = self.pyramid_frame?; + let delta = cursor - frame.origin; + Some((delta - frame.z * delta.dot(frame.z)).length()) + } + PyramidStep::Height | PyramidStep::HeightAfterTopRadius => { + self.pyramid_height_at(cursor).map(f64::abs) + } + PyramidStep::HeightSecondPoint => { + Some(self.pyramid_height_first?.distance(cursor)) + } + PyramidStep::AxisEndpoint => { + self.pyramid_frame.map(|frame| frame.origin.distance(cursor)) + } + _ => None, + }; + } if self.shape.rectangular() { let local = self.plane.to_local(cursor); if !local.is_finite() { @@ -2295,6 +2975,14 @@ fn push_loop(points: &mut Vec<[f32; 3]>, path: &[DVec3; N]) { points.extend(path.iter().chain(path.first()).map(|point| point.as_vec3().to_array())); } +fn push_path_loop(points: &mut Vec<[f32; 3]>, path: &[DVec3]) { + if path.is_empty() { + return; + } + push_break(points); + points.extend(path.iter().chain(path.first()).map(|point| point.as_vec3().to_array())); +} + fn push_segment(points: &mut Vec<[f32; 3]>, a: DVec3, b: DVec3) { push_break(points); points.extend([a.as_vec3().to_array(), b.as_vec3().to_array()]); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 5043a52e2..b43e31fb7 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -21,6 +21,7 @@ pub const GRIP_INNER_RADIUS: usize = 10_006; pub const GRIP_SIDES: usize = 10_007; pub const GRIP_MAJOR_RADIUS: usize = 10_008; pub const GRIP_MINOR_RADIUS: usize = 10_009; +pub const GRIP_TOP_RADIUS: usize = 10_010; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -50,6 +51,7 @@ pub const PROP_POSITION_X: &str = "solid_history_position_x"; pub const PROP_POSITION_Y: &str = "solid_history_position_y"; pub const PROP_POSITION_Z: &str = "solid_history_position_z"; pub const PROP_ROTATION: &str = "solid_history_rotation"; +pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -99,6 +101,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Sphere(_) | SolidHistoryOperation::Cone(_) | SolidHistoryOperation::Cylinder(_) + | SolidHistoryOperation::Pyramid(_) ) ) } @@ -114,6 +117,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]), + SolidHistoryOperation::Pyramid(value) => world_point(value.base.transform, [0.0; 3]), _ => None, } } @@ -364,6 +368,124 @@ fn cylinder_properties( ] } +fn pyramid_is_inscribed(value: &SolidHistoryPyramid) -> bool { + value.operation_minor == -1 +} + +fn pyramid_apothem_factor(sides: i32) -> f64 { + (std::f64::consts::PI / sides.clamp(3, 32) as f64).cos() +} + +fn pyramid_display_radius(value: &SolidHistoryPyramid, radius: f64) -> f64 { + if pyramid_is_inscribed(value) { + radius + } else { + radius * pyramid_apothem_factor(value.sides) + } +} + +fn pyramid_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryPyramid, +) -> Vec { + let Some(position) = world_point(value.base.transform, [0.0; 3]) else { + return Vec::new(); + }; + let rotation = matrix(value.base.transform) + .map(|matrix| matrix.x_axis.y.atan2(matrix.x_axis.x)) + .unwrap_or(0.0); + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let show_value = if show_history { "Yes" } else { "No" }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Pyramid").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + Property { + label: t!("Type").into_owned(), + field: PROP_PYRAMID_TYPE, + value: PropValue::Choice { + selected: if pyramid_is_inscribed(value) { + "Inscribed" + } else { + "Circumscribed" + } + .to_string(), + options: vec!["Circumscribed".to_string(), "Inscribed".to_string()], + }, + }, + history_prop( + t!("Base radius").as_ref(), + PROP_BASE_RADIUS, + pyramid_display_radius(value, value.radius), + ), + history_prop( + t!("Top radius").as_ref(), + PROP_TOP_RADIUS, + pyramid_display_radius(value, value.top_radius), + ), + history_prop(t!("Sides").as_ref(), PROP_SIDES, value.sides), + Property { + label: t!("Rotation").into_owned(), + field: PROP_ROTATION, + value: PropValue::EditText( + crate::entities::common::format_direction(rotation), + ), + }, + history_prop(t!("Height").as_ref(), PROP_HEIGHT, value.height), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: show_value.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(show_value.to_string()) + }, + }, + ], + }, + ] +} + fn sphere_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -558,6 +680,9 @@ pub fn primitive_properties( SolidHistoryOperation::Cylinder(value) => { return cylinder_properties(document, handle, value) } + SolidHistoryOperation::Pyramid(value) => { + return pyramid_properties(document, handle, value) + } SolidHistoryOperation::Torus(value) => vec![ history_prop( t!("Outer Radius").as_ref(), @@ -570,11 +695,6 @@ pub fn primitive_properties( (value.major_radius - value.minor_radius).max(0.0), ), ], - SolidHistoryOperation::Pyramid(value) => vec![ - history_prop(t!("Radius").as_ref(), PROP_RADIUS, value.radius), - history_prop(t!("Height").as_ref(), PROP_HEIGHT, value.height), - history_prop(t!("Sides").as_ref(), PROP_SIDES, value.sides), - ], _ => return Vec::new(), }; vec![PropSection { @@ -606,6 +726,7 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_POSITION_Y | PROP_POSITION_Z | PROP_ROTATION + | PROP_PYRAMID_TYPE ) } @@ -827,6 +948,94 @@ fn canonicalize_cylinder_radii(value: &mut SolidHistoryCylinder) -> bool { true } +fn apply_pyramid_geometry_property( + value: &mut SolidHistoryPyramid, + field: &str, + text: &str, +) -> Option { + if field == PROP_PYRAMID_TYPE { + let next_inscribed = if text.eq_ignore_ascii_case("Inscribed") { + true + } else if text.eq_ignore_ascii_case("Circumscribed") { + false + } else { + return Some(false); + }; + let old_inscribed = pyramid_is_inscribed(value); + if next_inscribed == old_inscribed { + return Some(false); + } + let factor = pyramid_apothem_factor(value.sides); + if factor <= 1e-9 { + return Some(false); + } + let base_display = if old_inscribed { value.radius } else { value.radius * factor }; + let top_display = if old_inscribed { + value.top_radius + } else { + value.top_radius * factor + }; + value.radius = if next_inscribed { base_display } else { base_display / factor }; + value.top_radius = if next_inscribed { top_display } else { top_display / factor }; + let Some(current) = matrix(value.base.transform) else { + return Some(false); + }; + let half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; + let delta = if next_inscribed { half } else { -half }; + value.base.transform = + (current * glam::DMat4::from_rotation_z(delta)).to_cols_array(); + value.operation_minor = if next_inscribed { -1 } else { 0 }; + return Some(true); + } + if field == PROP_ROTATION { + let target = crate::entities::common::parse_direction(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let projected = current.x_axis.truncate(); + if projected.length_squared() <= 1e-12 { + return Some(false); + } + let current_angle = projected.y.atan2(projected.x); + let delta = (target - current_angle + std::f64::consts::PI) + .rem_euclid(std::f64::consts::TAU) + - std::f64::consts::PI; + if delta.abs() <= 1e-12 { + return Some(false); + } + let updated = current * glam::DMat4::from_rotation_z(delta); + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + return Some(true); + } + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let origin = current.transform_point3(glam::DVec3::ZERO); + if !origin.is_finite() || (target - origin[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - origin[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, @@ -856,6 +1065,11 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Pyramid(pyramid_value) = operation { + if let Some(applied) = apply_pyramid_geometry_property(pyramid_value, field, value) { + return applied; + } + } let Some(number) = (if field == PROP_SIDES { value.trim().parse::().ok() } else { @@ -1008,7 +1222,26 @@ pub fn apply_primitive_property( } } SolidHistoryOperation::Pyramid(value) => match field { - PROP_RADIUS => value.radius = positive().unwrap_or(value.radius), + PROP_BASE_RADIUS => { + let Some(displayed) = positive() else { + return false; + }; + value.radius = if pyramid_is_inscribed(value) { + displayed + } else { + displayed / pyramid_apothem_factor(value.sides) + }; + } + PROP_TOP_RADIUS => { + if number < 0.0 { + return false; + } + value.top_radius = if pyramid_is_inscribed(value) { + number + } else { + number / pyramid_apothem_factor(value.sides) + }; + } PROP_HEIGHT => value.height = positive().unwrap_or(value.height), PROP_SIDES => { let rounded = number.round(); @@ -1016,9 +1249,26 @@ pub fn apply_primitive_property( return false; } let sides = rounded as i32; - if !(3..=71).contains(&sides) { + if !(3..=32).contains(&sides) { return false; } + if sides == value.sides { + return false; + } + if !pyramid_is_inscribed(value) { + let old_factor = pyramid_apothem_factor(value.sides); + let new_factor = pyramid_apothem_factor(sides); + value.radius = value.radius * old_factor / new_factor; + value.top_radius = value.top_radius * old_factor / new_factor; + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let old_half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; + let new_half = std::f64::consts::PI / sides as f64; + value.base.transform = (current + * glam::DMat4::from_rotation_z(old_half - new_half)) + .to_cols_array(); + } value.sides = sides; } _ => return false, @@ -1318,6 +1568,13 @@ pub fn primitive_grips( GripShape::Square, None, ); + add( + GRIP_TOP_RADIUS, + value.base.transform, + [value.top_radius, 0.0, value.height], + GripShape::Square, + None, + ); add( GRIP_HEIGHT, value.base.transform, @@ -1325,7 +1582,7 @@ pub fn primitive_grips( GripShape::Square, Some([0.0, 0.0, 1.0]), ); - let angle = (value.sides.clamp(3, 71) as f64 * 5.0).to_radians(); + let angle = (value.sides.clamp(3, 32) as f64 * 5.0).to_radians(); add( GRIP_SIDES, value.base.transform, @@ -1480,11 +1737,14 @@ pub fn apply_primitive_grip( }, SolidHistoryOperation::Pyramid(value) => match grip_id { GRIP_RADIUS => value.radius = local.x.hypot(local.y).max(1e-6), + GRIP_TOP_RADIUS => { + value.top_radius = local.x.hypot(local.y).max(0.0); + } GRIP_HEIGHT => value.height = local.z.max(1e-6), GRIP_SIDES => { let angle = local.y.atan2(local.x).rem_euclid(std::f64::consts::TAU); value.sides = (angle.to_degrees() / 5.0).round() as i32; - value.sides = value.sides.clamp(3, 71); + value.sides = value.sides.clamp(3, 32); } _ => return false, }, @@ -1608,15 +1868,19 @@ pub fn torus_op( pub fn pyramid_op( transform: [f64; 16], radius: f64, + top_radius: f64, height: f64, sides: usize, + inscribed: bool, ) -> SolidHistoryOperation { SolidHistoryOperation::Pyramid(SolidHistoryPyramid { base: base(transform), operation_major: 1, + operation_minor: if inscribed { -1 } else { 0 }, height, sides: sides as i32, radius, + top_radius, ..SolidHistoryPyramid::default() }) } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 335c3377f..582037219 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -82,6 +82,17 @@ pub fn pyramid_solid(center: [f64; 3], radius: f64, height: f64, sides: usize) - brep::make::pyramid(center, radius, height, sides) } +/// Solid pyramid or polygonal frustum on a regular polygon of `sides` corners. +pub fn pyramid_frustum_solid( + center: [f64; 3], + base_radius: f64, + top_radius: f64, + height: f64, + sides: usize, +) -> Option { + brep::make::pyramid_frustum(center, base_radius, top_radius, height, sides) +} + // ── Placement ─────────────────────────────────────────────────────────────── /// Moves a body by a rigid transform, given as three axes and an origin. From f9641bd9d51f01f5b41122d62538c078c6202d5e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:19:06 +0300 Subject: [PATCH 3/9] Correct pyramid rotation and property sections --- src/app/properties.rs | 27 +++++++++++++++++++++++++++ src/scene/model/solid_history.rs | 21 +++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index 6520a0939..d0038380e 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2074,6 +2074,9 @@ impl OpenCADStudio { }); } } + if specialized_primitive { + retain_specialized_primitive_sections(&mut sections); + } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] @@ -3047,6 +3050,30 @@ fn aggregate_solid_history_sections( merged } +fn retain_specialized_primitive_sections( + sections: &mut Vec, +) { + sections.iter_mut().for_each(|section| { + section.props.retain(|property| { + matches!( + property.field, + "color" + | "layer" + | "linetype" + | "linetype_scale" + | "plot_style" + | "lineweight" + | "transparency" + | "hyperlink" + | "material" + | "solid_history_type" + ) || crate::scene::model::solid_history::is_primitive_property(property.field) + || crate::scene::model::solid_history::is_history_choice(property.field) + }); + }); + sections.retain(|section| !section.props.is_empty()); +} + fn merge_sections( left: &[crate::scene::model::object::PropSection], right: &[crate::scene::model::object::PropSection], diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index b43e31fb7..409ba1b04 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -384,6 +384,16 @@ fn pyramid_display_radius(value: &SolidHistoryPyramid, radius: f64) -> f64 { } } +fn pyramid_display_rotation(value: &SolidHistoryPyramid) -> Option { + let vertex_rotation = matrix(value.base.transform) + .map(|matrix| matrix.x_axis.y.atan2(matrix.x_axis.x))?; + if pyramid_is_inscribed(value) { + Some(vertex_rotation) + } else { + Some(vertex_rotation + std::f64::consts::PI / value.sides.clamp(3, 32) as f64) + } +} + fn pyramid_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -392,9 +402,7 @@ fn pyramid_properties( let Some(position) = world_point(value.base.transform, [0.0; 3]) else { return Vec::new(); }; - let rotation = matrix(value.base.transform) - .map(|matrix| matrix.x_axis.y.atan2(matrix.x_axis.x)) - .unwrap_or(0.0); + let rotation = pyramid_display_rotation(value).unwrap_or(0.0); let (record_history, object_show_history, show_history_mode) = history_flags(document, handle).unwrap_or((false, false, 1)); let (show_history, show_history_editable) = @@ -998,7 +1006,12 @@ fn apply_pyramid_geometry_property( return Some(false); } let current_angle = projected.y.atan2(projected.x); - let delta = (target - current_angle + std::f64::consts::PI) + let target_vertex_angle = if pyramid_is_inscribed(value) { + target + } else { + target - std::f64::consts::PI / value.sides.clamp(3, 32) as f64 + }; + let delta = (target_vertex_angle - current_angle + std::f64::consts::PI) .rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; if delta.abs() <= 1e-12 { From 96bbdeafedcf43ce398ebdb499ace7324ebefd7c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:39:36 +0300 Subject: [PATCH 4/9] Align torus construction workflow --- src/modules/model/primitive_cmd.rs | 586 ++++++++++++++++++++++++++++- 1 file changed, 576 insertions(+), 10 deletions(-) diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index cd7d95537..5934670eb 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -67,6 +67,61 @@ enum SphereStep { }, } +#[derive(Clone, Copy)] +enum TorusStep { + Center, + Radius(DVec3), + Diameter(DVec3), + TwoPointFirst, + TwoPointSecond(DVec3), + ThreePointFirst, + ThreePointSecond(DVec3), + ThreePointThird(DVec3, DVec3), + TtrFirst, + TtrSecond { + object: TangentObject, + hit: DVec3, + }, + TtrRadius { + first: TangentObject, + second: TangentObject, + first_hit: DVec3, + second_hit: DVec3, + }, + TubeRadius { + center: DVec3, + major_radius: f64, + }, + TubeDiameter { + center: DVec3, + major_radius: f64, + }, + TubeTwoPointFirst { + center: DVec3, + major_radius: f64, + }, + TubeTwoPointSecond { + center: DVec3, + major_radius: f64, + first: DVec3, + }, +} + +#[derive(Clone, Copy)] +struct TorusDefaults { + major_radius: f64, + minor_radius: f64, +} + +impl Default for TorusDefaults { + fn default() -> Self { + Self { + major_radius: 100.0, + minor_radius: 25.0, + } + } +} + #[derive(Clone, Copy)] enum ConeStep { BaseCenter, @@ -138,6 +193,33 @@ fn remember_sphere_radius(radius: f64) { } } +fn torus_defaults_store() -> &'static Mutex { + static VALUE: OnceLock> = OnceLock::new(); + VALUE.get_or_init(|| Mutex::new(TorusDefaults::default())) +} + +fn torus_defaults() -> TorusDefaults { + torus_defaults_store() + .lock() + .map(|value| *value) + .unwrap_or_default() +} + +fn remember_torus_defaults(major_radius: f64, minor_radius: f64) { + if major_radius.is_finite() + && minor_radius.is_finite() + && major_radius > 1e-6 + && minor_radius > 1e-6 + { + if let Ok(mut value) = torus_defaults_store().lock() { + *value = TorusDefaults { + major_radius, + minor_radius, + }; + } + } +} + fn sphere_tangent_local(object: TangentObject, plane: WorkingPlane) -> TangentObject { match object { TangentObject::Line { p1, p2 } => TangentObject::Line { @@ -265,6 +347,8 @@ pub struct PrimitiveCommand { box_height_anchor: Option, sphere_step: SphereStep, sphere_default_radius: f64, + torus_step: TorusStep, + torus_defaults: TorusDefaults, cone_step: ConeStep, cone_frame: Option, cone_base_x_radius: f64, @@ -294,6 +378,8 @@ impl PrimitiveCommand { box_height_anchor: None, sphere_step: SphereStep::Center, sphere_default_radius: sphere_radius_default(), + torus_step: TorusStep::Center, + torus_defaults: torus_defaults(), cone_step: ConeStep::BaseCenter, cone_frame: None, cone_base_x_radius: remembered.base_x_radius, @@ -344,6 +430,81 @@ impl PrimitiveCommand { ))) } + fn set_torus_major_radius(&mut self, center: DVec3, major_radius: f64) -> CmdResult { + if !center.is_finite() || !major_radius.is_finite() || major_radius <= 1e-6 { + return CmdResult::NeedPoint; + } + self.torus_step = TorusStep::TubeRadius { + center, + major_radius, + }; + CmdResult::NeedPoint + } + + fn commit_torus( + &mut self, + center: DVec3, + major_radius: f64, + minor_radius: f64, + ) -> CmdResult { + if !center.is_finite() + || !major_radius.is_finite() + || !minor_radius.is_finite() + || major_radius <= 1e-6 + || minor_radius <= 1e-6 + { + return CmdResult::NeedPoint; + } + self.pts = vec![ + center, + center + DVec3::X * major_radius, + center + DVec3::X * minor_radius, + ]; + self.torus_defaults = TorusDefaults { + major_radius, + minor_radius, + }; + remember_torus_defaults(major_radius, minor_radius); + self.commit(0.0) + } + + fn torus_ttr_result(&mut self, radius: f64) -> CmdResult { + let TorusStep::TtrRadius { + first, + second, + first_hit, + second_hit, + } = self.torus_step + else { + return CmdResult::NeedPoint; + }; + let hint = (first_hit + second_hit) * 0.5; + let Some(center) = closest_sphere_center( + &sphere_ttr_centers(first, second, radius), + hint, + ) else { + return CmdResult::NeedPoint; + }; + self.set_torus_major_radius(center, radius) + } + + fn torus_preview( + &self, + center: DVec3, + major_radius: f64, + minor_radius: f64, + ) -> Option { + if !center.is_finite() + || !major_radius.is_finite() + || !minor_radius.is_finite() + || major_radius <= 1e-6 + || minor_radius <= 1e-6 + { + return None; + } + Some(self.place_preview(torus_wire(center, major_radius, minor_radius))) + } + fn cone_frame_at(&self, center: DVec3) -> WorkingPlane { WorkingPlane { origin: center, @@ -1286,15 +1447,11 @@ impl PrimitiveCommand { } Shape::Torus => { let c = self.pts[0]; - let first = (self.pts[1] - c).length(); - let second = (self.pts[2] - c).length(); - let outer = first.max(second); - let inner = first.min(second); - if inner < 1e-6 || outer - inner < 1e-6 { + let major = (self.pts[1] - c).length(); + let minor = (self.pts[2] - c).length(); + if major < 1e-6 || minor < 1e-6 { return None; } - let major = (outer + inner) * 0.5; - let minor = (outer - inner) * 0.5; let center = [c.x, c.y, c.z]; ( solid_model::torus_solid(center, major, minor), @@ -1393,12 +1550,40 @@ impl CadCommand for PrimitiveCommand { self.cone_step, ConeStep::TtrFirst | ConeStep::TtrSecond { .. } )) + || (self.shape == Shape::Torus + && matches!( + self.torus_step, + TorusStep::TtrFirst | TorusStep::TtrSecond { .. } + )) } fn on_tangent_point(&mut self, object: TangentObject, hit: DVec3) -> CmdResult { if self.shape == Shape::Cone { return self.on_cone_tangent(object, hit); } + if self.shape == Shape::Torus { + let object = sphere_tangent_local(object, self.plane); + let hit = self.plane.to_local(hit); + return match self.torus_step { + TorusStep::TtrFirst => { + self.torus_step = TorusStep::TtrSecond { object, hit }; + CmdResult::NeedPoint + } + TorusStep::TtrSecond { + object: first, + hit: first_hit, + } => { + self.torus_step = TorusStep::TtrRadius { + first, + second: object, + first_hit, + second_hit: hit, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + }; + } if self.shape != Shape::Sphere { return self.on_point(hit); } @@ -1498,13 +1683,69 @@ impl CadCommand for PrimitiveCommand { .into_owned(), }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Center => { + t!("TORUS Specify center point or [3P/2P/Ttr]:").into_owned() + } + TorusStep::Radius(_) => crate::tf!( + "TORUS Specify radius or [Diameter] <{:.4}>:", + self.torus_defaults.major_radius + ) + .into_owned(), + TorusStep::Diameter(_) => crate::tf!( + "TORUS Specify diameter <{:.4}>:", + self.torus_defaults.major_radius * 2.0 + ) + .into_owned(), + TorusStep::TwoPointFirst => { + t!("TORUS Specify first end point of diameter:").into_owned() + } + TorusStep::TwoPointSecond(_) => { + t!("TORUS Specify second end point of diameter:").into_owned() + } + TorusStep::ThreePointFirst => { + t!("TORUS Specify first point on torus:").into_owned() + } + TorusStep::ThreePointSecond(_) => { + t!("TORUS Specify second point on torus:").into_owned() + } + TorusStep::ThreePointThird(_, _) => { + t!("TORUS Specify third point on torus:").into_owned() + } + TorusStep::TtrFirst => { + t!("TORUS Select first tangent object:").into_owned() + } + TorusStep::TtrSecond { .. } => { + t!("TORUS Select second tangent object:").into_owned() + } + TorusStep::TtrRadius { .. } => crate::tf!( + "TORUS Specify radius <{:.4}>:", + self.torus_defaults.major_radius + ) + .into_owned(), + TorusStep::TubeRadius { .. } => crate::tf!( + "TORUS Specify tube radius or [2Point/Diameter] <{:.4}>:", + self.torus_defaults.minor_radius + ) + .into_owned(), + TorusStep::TubeDiameter { .. } => crate::tf!( + "TORUS Specify tube diameter <{:.4}>:", + self.torus_defaults.minor_radius * 2.0 + ) + .into_owned(), + TorusStep::TubeTwoPointFirst { .. } => { + t!("TORUS Specify first end point of tube diameter:").into_owned() + } + TorusStep::TubeTwoPointSecond { .. } => { + t!("TORUS Specify second end point of tube diameter:").into_owned() + } + }; + } if self.height_step { return t!("%{n} Specify height :", n = n).into_owned(); } match (self.shape, self.pts.len()) { - (Shape::Torus, 0) => t!("%{n} Specify center point:", n = n).into_owned(), - (Shape::Torus, 1) => t!("%{n} Specify outer radius:", n = n).into_owned(), - (Shape::Torus, _) => t!("%{n} Specify inner radius:", n = n).into_owned(), (shape, 0) if shape.radial() => { t!("%{n} Specify center point:", n = n).into_owned() } @@ -1528,6 +1769,23 @@ impl CadCommand for PrimitiveCommand { _ => Vec::new(), }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Center => vec![ + CmdOption::new("3P", "3P"), + CmdOption::new("2P", "2P"), + CmdOption::new("Ttr", "T"), + ], + TorusStep::Radius(_) => { + vec![CmdOption::new(t!("Diameter").as_ref(), "D")] + } + TorusStep::TubeRadius { .. } => vec![ + CmdOption::new(t!("2Point").as_ref(), "2P"), + CmdOption::new(t!("Diameter").as_ref(), "D"), + ], + _ => Vec::new(), + }; + } if self.shape == Shape::Cone { return self.cone_options(); } @@ -1589,6 +1847,76 @@ impl CadCommand for PrimitiveCommand { SphereStep::TtrFirst | SphereStep::TtrSecond { .. } => CmdResult::NeedPoint, }; } + if self.shape == Shape::Torus { + let local = self.plane.to_local(pt); + if !local.is_finite() { + return CmdResult::NeedPoint; + } + return match self.torus_step { + TorusStep::Center => { + self.torus_step = TorusStep::Radius(local); + CmdResult::NeedPoint + } + TorusStep::Radius(center) => { + self.set_torus_major_radius(center, center.distance(local)) + } + TorusStep::Diameter(center) => { + self.set_torus_major_radius(center, center.distance(local) * 0.5) + } + TorusStep::TwoPointFirst => { + self.torus_step = TorusStep::TwoPointSecond(local); + CmdResult::NeedPoint + } + TorusStep::TwoPointSecond(first) => self.set_torus_major_radius( + (first + local) * 0.5, + first.distance(local) * 0.5, + ), + TorusStep::ThreePointFirst => { + self.torus_step = TorusStep::ThreePointSecond(local); + CmdResult::NeedPoint + } + TorusStep::ThreePointSecond(first) => { + self.torus_step = TorusStep::ThreePointThird(first, local); + CmdResult::NeedPoint + } + TorusStep::ThreePointThird(first, second) => { + let Some((center, radius)) = + sphere_through_three_points(first, second, local) + else { + return CmdResult::NeedPoint; + }; + self.set_torus_major_radius(center, radius) + } + TorusStep::TtrRadius { second_hit, .. } => { + self.torus_ttr_result(second_hit.distance(local)) + } + TorusStep::TubeRadius { + center, + major_radius, + } => self.commit_torus(center, major_radius, center.distance(local)), + TorusStep::TubeDiameter { + center, + major_radius, + } => self.commit_torus(center, major_radius, center.distance(local) * 0.5), + TorusStep::TubeTwoPointFirst { + center, + major_radius, + } => { + self.torus_step = TorusStep::TubeTwoPointSecond { + center, + major_radius, + first: local, + }; + CmdResult::NeedPoint + } + TorusStep::TubeTwoPointSecond { + center, + major_radius, + first, + } => self.commit_torus(center, major_radius, first.distance(local) * 0.5), + TorusStep::TtrFirst | TorusStep::TtrSecond { .. } => CmdResult::NeedPoint, + }; + } if self.shape == Shape::Cone { return self.on_cone_point(pt); } @@ -1696,6 +2024,25 @@ impl CadCommand for PrimitiveCommand { _ => CmdResult::Cancel, }; } + if self.shape == Shape::Torus { + return match self.torus_step { + TorusStep::Radius(center) | TorusStep::Diameter(center) => { + self.set_torus_major_radius(center, self.torus_defaults.major_radius) + } + TorusStep::TtrRadius { .. } => { + self.torus_ttr_result(self.torus_defaults.major_radius) + } + TorusStep::TubeRadius { + center, + major_radius, + } + | TorusStep::TubeDiameter { + center, + major_radius, + } => self.commit_torus(center, major_radius, self.torus_defaults.minor_radius), + _ => CmdResult::Cancel, + }; + } if self.shape == Shape::Cone { return match self.cone_step { ConeStep::BaseRadius | ConeStep::BaseDiameter => { @@ -1763,6 +2110,17 @@ impl CadCommand for PrimitiveCommand { | SphereStep::TtrRadius { .. } ); } + if self.shape == Shape::Torus { + return matches!( + self.torus_step, + TorusStep::Center + | TorusStep::Radius(_) + | TorusStep::Diameter(_) + | TorusStep::TtrRadius { .. } + | TorusStep::TubeRadius { .. } + | TorusStep::TubeDiameter { .. } + ); + } if self.shape == Shape::Cone { return matches!( self.cone_step, @@ -1796,6 +2154,12 @@ impl CadCommand for PrimitiveCommand { if self.shape == Shape::Sphere { return matches!(self.sphere_step, SphereStep::Center | SphereStep::Radius(_)); } + if self.shape == Shape::Torus { + return matches!( + self.torus_step, + TorusStep::Center | TorusStep::Radius(_) | TorusStep::TubeRadius { .. } + ); + } if self.shape == Shape::Cone { return matches!( self.cone_step, @@ -1852,6 +2216,75 @@ impl CadCommand for PrimitiveCommand { _ => None, }; } + if self.shape == Shape::Torus { + let token = raw.trim(); + let upper = token.to_uppercase(); + return match self.torus_step { + TorusStep::Center if upper == "3P" => { + self.torus_step = TorusStep::ThreePointFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Center if upper == "2P" => { + self.torus_step = TorusStep::TwoPointFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Center if matches!(upper.as_str(), "T" | "TTR") => { + self.torus_step = TorusStep::TtrFirst; + Some(CmdResult::NeedPoint) + } + TorusStep::Radius(center) if matches!(upper.as_str(), "D" | "DIAMETER") => { + self.torus_step = TorusStep::Diameter(center); + Some(CmdResult::NeedPoint) + } + TorusStep::Radius(center) => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.set_torus_major_radius(center, radius)) + } + TorusStep::Diameter(center) => { + let diameter = crate::entities::common::parse_typed_length(token)?; + Some(self.set_torus_major_radius(center, diameter * 0.5)) + } + TorusStep::TtrRadius { .. } => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.torus_ttr_result(radius)) + } + TorusStep::TubeRadius { + center, + major_radius, + } if matches!(upper.as_str(), "2P" | "2POINT") => { + self.torus_step = TorusStep::TubeTwoPointFirst { + center, + major_radius, + }; + Some(CmdResult::NeedPoint) + } + TorusStep::TubeRadius { + center, + major_radius, + } if matches!(upper.as_str(), "D" | "DIAMETER") => { + self.torus_step = TorusStep::TubeDiameter { + center, + major_radius, + }; + Some(CmdResult::NeedPoint) + } + TorusStep::TubeRadius { + center, + major_radius, + } => { + let radius = crate::entities::common::parse_typed_length(token)?; + Some(self.commit_torus(center, major_radius, radius)) + } + TorusStep::TubeDiameter { + center, + major_radius, + } => { + let diameter = crate::entities::common::parse_typed_length(token)?; + Some(self.commit_torus(center, major_radius, diameter * 0.5)) + } + _ => None, + }; + } if self.shape == Shape::Cone { return self.on_cone_text(raw); } @@ -1903,6 +2336,63 @@ impl CadCommand for PrimitiveCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if self.shape == Shape::Torus { + let local = self.plane.to_local(pt); + if !local.is_finite() { + return None; + } + return match self.torus_step { + TorusStep::Radius(center) => self.torus_preview( + center, + center.distance(local), + self.torus_defaults.minor_radius, + ), + TorusStep::Diameter(center) => self.torus_preview( + center, + center.distance(local) * 0.5, + self.torus_defaults.minor_radius, + ), + TorusStep::TwoPointSecond(first) | TorusStep::ThreePointSecond(first) => { + self.torus_preview( + (first + local) * 0.5, + first.distance(local) * 0.5, + self.torus_defaults.minor_radius, + ) + } + TorusStep::ThreePointThird(first, second) => { + let (center, radius) = sphere_through_three_points(first, second, local)?; + self.torus_preview(center, radius, self.torus_defaults.minor_radius) + } + TorusStep::TtrRadius { + first, + second, + first_hit, + second_hit, + } => { + let radius = second_hit.distance(local); + let hint = (first_hit + second_hit) * 0.5; + let center = closest_sphere_center( + &sphere_ttr_centers(first, second, radius), + hint, + )?; + self.torus_preview(center, radius, self.torus_defaults.minor_radius) + } + TorusStep::TubeRadius { + center, + major_radius, + } => self.torus_preview(center, major_radius, center.distance(local)), + TorusStep::TubeDiameter { + center, + major_radius, + } => self.torus_preview(center, major_radius, center.distance(local) * 0.5), + TorusStep::TubeTwoPointSecond { + center, + major_radius, + first, + } => self.torus_preview(center, major_radius, first.distance(local) * 0.5), + _ => None, + }; + } if self.shape == Shape::Sphere { let local = self.plane.to_local(pt); if !local.is_finite() { @@ -2050,6 +2540,25 @@ impl CadCommand for PrimitiveCommand { fn dyn_spec(&self) -> Option { use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + if self.shape == Shape::Torus { + let (anchor, role) = match self.torus_step { + TorusStep::Radius(center) => (center, DynRole::Radius), + TorusStep::Diameter(center) => (center, DynRole::Diameter), + TorusStep::TtrRadius { second_hit, .. } => { + (second_hit, DynRole::Radius) + } + TorusStep::TubeRadius { center, .. } => (center, DynRole::Radius), + TorusStep::TubeDiameter { center, .. } => (center, DynRole::Diameter), + _ => return None, + }; + return Some(DynSpec { + anchor: DynAnchor::Point(self.plane.to_world(anchor)), + fields: vec![DynFieldSpec::new(role)], + guide: DynGuide::Radius, + ref_point: None, + }); + } + if self.shape == Shape::Sphere { let (anchor, role) = match self.sphere_step { SphereStep::Radius(center) => (center, DynRole::Radius), @@ -2117,6 +2626,21 @@ impl CadCommand for PrimitiveCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { + if self.shape == Shape::Torus { + let local = self.plane.to_local(cursor); + return match self.torus_step { + TorusStep::Radius(center) | TorusStep::TubeRadius { center, .. } => { + Some(center.distance(local)) + } + TorusStep::Diameter(center) | TorusStep::TubeDiameter { center, .. } => { + Some(center.distance(local)) + } + TorusStep::TtrRadius { second_hit, .. } => { + Some(second_hit.distance(local)) + } + _ => None, + }; + } if self.shape == Shape::Sphere { let local = self.plane.to_local(cursor); return match self.sphere_step { @@ -2181,6 +2705,48 @@ impl CadCommand for PrimitiveCommand { // ── Footprint preview ─────────────────────────────────────────────────────── +fn torus_wire(center: DVec3, major_radius: f64, minor_radius: f64) -> WireModel { + let mut points = Vec::new(); + let profile_limit = if minor_radius > major_radius { + std::f64::consts::PI - (major_radius / minor_radius).acos() + } else { + std::f64::consts::PI + }; + + for fraction in [-0.75_f64, -0.5, 0.0, 0.5, 0.75] { + let angle = profile_limit * fraction; + let ring_radius = major_radius + minor_radius * angle.cos(); + if ring_radius <= 1e-9 { + continue; + } + if !points.is_empty() { + points.push([f32::NAN; 3]); + } + circle_points( + &mut points, + center + DVec3::Z * (minor_radius * angle.sin()), + ring_radius, + ); + } + + for meridian in 0..12 { + points.push([f32::NAN; 3]); + let around = meridian as f64 * std::f64::consts::TAU / 12.0; + for sample in 0..=32 { + let profile = -profile_limit + 2.0 * profile_limit * sample as f64 / 32.0; + let radial = (major_radius + minor_radius * profile.cos()).max(0.0); + let point = center + + DVec3::new( + radial * around.cos(), + radial * around.sin(), + minor_radius * profile.sin(), + ); + points.push(point.as_vec3().to_array()); + } + } + wire("torus_preview", points) +} + fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel { let mut points: Vec<[f32; 3]> = Vec::new(); if shape == Shape::Pyramid { From 11233aa66a702a7711fc4d634ad9c91dd2a4897e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:39:37 +0300 Subject: [PATCH 5/9] Match torus property behavior --- src/scene/model/solid_history.rs | 165 ++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 37 deletions(-) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 5043a52e2..f713efa4a 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -99,6 +99,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Sphere(_) | SolidHistoryOperation::Cone(_) | SolidHistoryOperation::Cylinder(_) + | SolidHistoryOperation::Torus(_) ) ) } @@ -114,10 +115,80 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]), + SolidHistoryOperation::Torus(value) => world_point(value.base.transform, [0.0; 3]), _ => None, } } +fn torus_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryTorus, +) -> Vec { + let Some(position) = world_point(value.base.transform, [0.0; 3]) else { + return Vec::new(); + }; + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, _) = displayed_history_state(object_show_history, show_history_mode); + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Torus").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + history_prop( + t!("Torus radius").as_ref(), + PROP_MAJOR_RADIUS, + value.major_radius, + ), + history_prop( + t!("Tube radius").as_ref(), + PROP_MINOR_RADIUS, + value.minor_radius, + ), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::ReadOnly( + if record_history { "Record" } else { "None" }.to_string(), + ), + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: PropValue::ReadOnly( + if show_history { "Yes" } else { "No" }.to_string(), + ), + }, + ], + }, + ] +} + fn cone_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -558,18 +629,9 @@ pub fn primitive_properties( SolidHistoryOperation::Cylinder(value) => { return cylinder_properties(document, handle, value) } - SolidHistoryOperation::Torus(value) => vec![ - history_prop( - t!("Outer Radius").as_ref(), - PROP_OUTER_RADIUS, - value.major_radius + value.minor_radius, - ), - history_prop( - t!("Inner Radius").as_ref(), - PROP_INNER_RADIUS, - (value.major_radius - value.minor_radius).max(0.0), - ), - ], + SolidHistoryOperation::Torus(value) => { + return torus_properties(document, handle, value) + } SolidHistoryOperation::Pyramid(value) => vec![ history_prop(t!("Radius").as_ref(), PROP_RADIUS, value.radius), history_prop(t!("Height").as_ref(), PROP_HEIGHT, value.height), @@ -812,6 +874,36 @@ fn apply_cylinder_position_property( Some(true) } +fn apply_torus_position_property( + value: &mut SolidHistoryTorus, + field: &str, + text: &str, +) -> Option { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let origin = current.transform_point3(glam::DVec3::ZERO); + if !origin.is_finite() || (target - origin[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - origin[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + fn canonicalize_cylinder_radii(value: &mut SolidHistoryCylinder) -> bool { if value.minor_radius <= value.major_radius { value.x_radius = value.major_radius; @@ -856,6 +948,11 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Torus(torus_value) = operation { + if let Some(applied) = apply_torus_position_property(torus_value, field, value) { + return applied; + } + } let Some(number) = (if field == PROP_SIDES { value.trim().parse::().ok() } else { @@ -980,33 +1077,27 @@ pub fn apply_primitive_property( } value.radius = radius; } - SolidHistoryOperation::Torus(value) => { - let outer = value.major_radius + value.minor_radius; - let inner = (value.major_radius - value.minor_radius).max(0.0); - match field { - PROP_OUTER_RADIUS => { - let Some(next_outer) = positive() else { - return false; - }; - if next_outer <= inner { - return false; - } - value.major_radius = (next_outer + inner) * 0.5; - value.minor_radius = (next_outer - inner) * 0.5; + SolidHistoryOperation::Torus(value) => match field { + PROP_MAJOR_RADIUS => { + let Some(radius) = positive() else { + return false; + }; + if (radius - value.major_radius).abs() <= 1e-12 { + return false; } - PROP_INNER_RADIUS => { - let Some(next_inner) = positive() else { - return false; - }; - if next_inner >= outer { - return false; - } - value.major_radius = (outer + next_inner) * 0.5; - value.minor_radius = (outer - next_inner) * 0.5; - } - _ => return false, + value.major_radius = radius; } - } + PROP_MINOR_RADIUS => { + let Some(radius) = positive() else { + return false; + }; + if (radius - value.minor_radius).abs() <= 1e-12 { + return false; + } + value.minor_radius = radius; + } + _ => return false, + }, SolidHistoryOperation::Pyramid(value) => match field { PROP_RADIUS => value.radius = positive().unwrap_or(value.radius), PROP_HEIGHT => value.height = positive().unwrap_or(value.height), From 9aa720bec0d334a5e5a5e52806a2e99d77a20ecb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:39:37 +0300 Subject: [PATCH 6/9] Use closed-hole torus kernel --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bcdc4b0d..25fc30bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=48c634995fa60d3928c37bae49b12b421c56c886#48c634995fa60d3928c37bae49b12b421c56c886" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b959b46143fc6bb5de19c7808a909790b114ff19#b959b46143fc6bb5de19c7808a909790b114ff19" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1536791ee..49a76cf84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a0f7d444f1607bc4b2c881060cbe7ea1014253cb", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "48c634995fa60d3928c37bae49b12b421c56c886", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b959b46143fc6bb5de19c7808a909790b114ff19", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } From afff1a40dd04048c0a6d21dd340d28b47aa8e9bc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 1 Sep 2026 12:17:39 +0100 Subject: [PATCH 7/9] feat(ui): add press-to-capture shortcut editing to Keyboard Shortcuts dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual key entry with per-row capture cells: clicking + Add inserts a draft row at the top with its key cell armed, and the next key combination is recorded and normalized (Ctrl+Shift+S → CTRL+SHIFT+S). While armed, a dedicated keyboard listener swallows all input so recorded shortcuts do not execute in the drawing. Draft rows are transient and confirmed only through an explicit accept button, so the table never retains empty rows. Duplicate keys and unrunnable commands are flagged in danger styling with a persistent warning naming the conflicting command. Added reset-to-defaults with confirmation, apply-and-exit, a shortcut count, and an unsaved-changes guard on close. --- src/app/mod.rs | 40 +++++ src/app/shortcuts.rs | 106 +++++++++++++ src/app/update/mod.rs | 118 +++++++++++++- src/app/view/mod.rs | 66 ++++++-- src/app/view/modal.rs | 69 +++++++++ src/ui/icons.rs | 11 ++ src/ui/window/shortcuts.rs | 309 +++++++++++++++++++++++++++++++++---- 7 files changed, 673 insertions(+), 46 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 91034b4e9..173ac41a8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -960,6 +960,20 @@ pub(super) struct OpenCADStudio { shortcut_bindings: rustc_hash::FxHashMap, /// Working rows shown by the shortcut editor until Apply is pressed. shortcut_editor_rows: Vec<(String, String)>, + /// Row whose Key cell is armed for capture: the next key combination + /// pressed anywhere fills that row's key. While set, the keyboard + /// subscription swallows every key so captured shortcuts do not run in + /// the drawing. + shortcut_capture_row: Option, + /// True while a freshly added (top) row is an unfinished draft: it exists + /// only until its key and command are filled (success) or it is cancelled + /// (Esc / ✕ / dialog close), so the list never keeps an empty row. + shortcut_pending_add: bool, + /// True while the "Reset to default" confirmation is showing. + shortcut_reset_confirm: bool, + /// True while the "unsaved changes will be discarded" confirmation + /// overlays the editor: the user tried to close with un-applied rows. + shortcut_close_confirm: bool, // ── Command Aliases ─────────────────────────────────────────────────── /// Command-line aliases: uppercase abbreviation → uppercase command @@ -2552,6 +2566,28 @@ pub enum Message { ShortcutEditorAdd, ShortcutEditorRemove(usize), ShortcutEditorApply, + /// Apply the working rows and close the dialog. + ShortcutEditorApplyExit, + /// The check button on a draft row: finish the addition without applying. + ShortcutEditorDraftAccept, + /// Show the "Reset to default" confirmation. + ShortcutEditorResetAsk, + /// Reset bindings and rows to the shipped defaults. + ShortcutEditorResetConfirm, + /// Hide the reset confirmation without resetting. + ShortcutEditorResetDeny, + /// Discard un-applied rows and close the editor. + ShortcutEditorCloseDiscard, + /// Keep editing: hide the discard confirmation. + ShortcutEditorCloseKeep, + /// The user clicked a row's Key cell: arm capture for that row. + ShortcutCaptureStart(usize), + /// A key combination was pressed while a row's Key cell was armed. + ShortcutCaptureKey(String), + /// Disarm capture without filling the row. + ShortcutCaptureClear, + /// Cancel capture (Esc): also discards an unfinished draft row. + ShortcutCaptureCancel, /// Canonical key emitted by the global keyboard subscription. ShortcutPressed(String), // ── Command Alias Editor (ALIASEDIT) ──────────────────────────────── @@ -3414,6 +3450,10 @@ impl OpenCADStudio { // Keyboard shortcuts shortcut_bindings: rustc_hash::FxHashMap::default(), shortcut_editor_rows: Vec::new(), + shortcut_capture_row: None, + shortcut_pending_add: false, + shortcut_reset_confirm: false, + shortcut_close_confirm: false, // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 6c888fa40..f3116f38c 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -141,7 +141,113 @@ fn is_named_key(key: &str) -> bool { }) } +/// Input actions `run_shortcut` resolves directly instead of routing through +/// the command dispatcher — valid shortcut commands that never appear in the +/// command registry. Used to validate the shortcut editor's command column. +pub(super) const INPUT_ACTIONS: &[&str] = &[ + "FINALIZE", + "COMMANDSPACE", + "CANCEL", + "DELETESELECTED", + "BACKSPACE", + "DYNTAB", + "HISTORYPREV", + "HISTORYNEXT", + "CARETLEFT", + "CARETRIGHT", + "COMMANDHISTORY", + "TOGGLEOSNAP", + "OTRACK", + "DYNINPUT", + "SELECTALL", + "PASTECLIP", +]; + impl OpenCADStudio { + /// Commit the working rows to the live bindings, discarding an + /// unfinished draft and reporting duplicates. Shared by Apply and + /// Apply-and-Exit. + pub(super) fn finish_shortcut_editor(&mut self) { + // An add must end in a complete shortcut or a cancellation; + // applying with a half-filled draft discards the draft. + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + self.command_line + .push_error(crate::tf!("Incomplete shortcut row discarded.").as_ref()); + } + self.shortcut_capture_row = None; + // Rows normalizing to the same key silently overwrite each other in + // the binding map — surface the conflict instead. + let mut seen = rustc_hash::FxHashSet::default(); + let mut duplicates = Vec::new(); + for (key, _) in &self.shortcut_editor_rows { + let key = normalize_key(key); + if key.is_empty() { + continue; + } + if !seen.insert(key.clone()) { + duplicates.push(key); + } + } + if !duplicates.is_empty() { + self.command_line.push_error( + crate::tf!( + "Duplicate shortcut(s) ignored on Apply: {}", + duplicates.join(", ") + ) + .as_ref(), + ); + } + self.apply_shortcut_editor_rows(); + self.command_line.push_info( + crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), + ); + } + + /// Reset the working rows and live bindings to the shipped defaults. + pub(super) fn reset_shortcuts_to_defaults(&mut self) { + let mut rows: Vec<(String, String)> = default_bindings() + .into_iter() + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + self.shortcut_editor_rows = rows; + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + self.shortcut_reset_confirm = false; + self.apply_shortcut_editor_rows(); + } + + /// True when the working rows differ from the live bindings — the editor + /// has un-applied changes a close would discard. + pub(super) fn shortcut_editor_dirty(&self) -> bool { + let rows: FxHashMap = self + .shortcut_editor_rows + .iter() + .filter_map(|(key, action)| { + let key = normalize_key(key); + let action = action.trim().to_uppercase(); + (!key.is_empty() && !action.is_empty()).then_some((key, action)) + }) + .collect(); + rows != self.shortcut_bindings + } + + /// A pending add finishes successfully once its draft row (row 0) has + /// both a key and a command; it then becomes a regular row of the table. + /// Called after each edit while a draft is pending. + pub(super) fn finish_pending_add(&mut self) { + if !self.shortcut_pending_add { + return; + } + if let Some((key, command)) = self.shortcut_editor_rows.first() { + if !key.trim().is_empty() && !command.trim().is_empty() { + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + } + } + } + pub(super) fn apply_shortcut_editor_rows(&mut self) { let bindings: FxHashMap = self .shortcut_editor_rows diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 020fca1c4..9ca1cdb1c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -199,7 +199,13 @@ impl OpenCADStudio { // Closing (✕) discards edits made since the last Apply — matching the // style editors. Committing happens only through the Apply button. Some(Aliases) => self.alias_editor_rows.clear(), - Some(Shortcuts) => self.shortcut_editor_rows.clear(), + Some(Shortcuts) => { + self.shortcut_editor_rows.clear(); + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; + } Some(LayerStateEditor) => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); @@ -5564,13 +5570,31 @@ impl OpenCADStudio { .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.shortcut_editor_rows = rows; + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; self.active_modal = Some(super::ModalKind::Shortcuts); Task::none() } Message::ShortcutsPanelClose => { + if !self.shortcut_close_confirm && self.shortcut_editor_dirty() { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; self.close_active_modal(); Task::none() } + Message::ShortcutEditorCloseDiscard => { + self.shortcut_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorCloseKeep => { + self.shortcut_close_confirm = false; + Task::none() + } Message::ShortcutEditorInput { idx, field, value } => { use crate::ui::window::shortcuts::ShortcutField; if let Some(row) = self.shortcut_editor_rows.get_mut(idx) { @@ -5579,21 +5603,96 @@ impl OpenCADStudio { ShortcutField::Command => row.1 = value.to_uppercase(), } } + // Typing never finishes the addition — only the draft row's + // check button does, so no half-visible "ghost" row appears. Task::none() } Message::ShortcutEditorAdd => { - self.shortcut_editor_rows - .push((String::new(), String::new())); + if self.shortcut_pending_add { + // One draft at a time: re-arm its Key cell instead. + self.shortcut_capture_row = Some(0); + } else { + // The draft row goes to the top of the list so it is + // visible without scrolling, with its Key cell armed. + self.shortcut_editor_rows + .insert(0, (String::new(), String::new())); + self.shortcut_pending_add = true; + self.shortcut_capture_row = Some(0); + } + Task::none() + } + Message::ShortcutCaptureStart(idx) => { + // Clicking the armed cell again cancels capture. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + _ => Some(idx), + }; + Task::none() + } + Message::ShortcutCaptureKey(raw) => { + if let Some(idx) = self.shortcut_capture_row.take() { + let key = crate::app::shortcuts::normalize_key(&raw); + if !key.is_empty() && self.shortcut_editor_rows.get_mut(idx).is_some() { + self.shortcut_editor_rows[idx].0 = key; + } + } + Task::none() + } + Message::ShortcutCaptureClear => { + self.shortcut_capture_row = None; + Task::none() + } + Message::ShortcutCaptureCancel => { + // Esc backs out of capture and abandons an unfinished draft. + self.shortcut_capture_row = None; + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + } Task::none() } Message::ShortcutEditorRemove(idx) => { if idx < self.shortcut_editor_rows.len() { self.shortcut_editor_rows.remove(idx); } + if idx == 0 { + // Removing the draft row is the ✕ cancel path. + self.shortcut_pending_add = false; + } + // Armed indices shift when a row above them disappears. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + Some(armed) if armed > idx => Some(armed - 1), + other => other, + }; Task::none() } Message::ShortcutEditorApply => { - self.apply_shortcut_editor_rows(); + self.finish_shortcut_editor(); + Task::none() + } + Message::ShortcutEditorApplyExit => { + self.finish_shortcut_editor(); + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorDraftAccept => { + // The check button: finish the addition (keep the row in the + // working table) without applying it. Only complete drafts + // can be accepted — the button is disabled otherwise. + self.finish_pending_add(); + Task::none() + } + Message::ShortcutEditorResetAsk => { + self.shortcut_reset_confirm = true; + Task::none() + } + Message::ShortcutEditorResetDeny => { + self.shortcut_reset_confirm = false; + Task::none() + } + Message::ShortcutEditorResetConfirm => { + self.reset_shortcuts_to_defaults(); self.command_line.push_info( crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), ); @@ -5793,6 +5892,17 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // Closing the shortcut editor with un-applied rows needs an + // explicit discard; a second close attempt (or the overlay's + // Discard button) goes through. + if self.active_modal == Some(super::ModalKind::Shortcuts) + && !self.shortcut_close_confirm + && self.shortcut_editor_dirty() + { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 86f997cbe..014b90a84 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -118,6 +118,36 @@ fn hatch_pattern_key_event( } } +fn shortcut_capture_key_event( + event: iced::Event, + _status: iced::event::Status, + _window: window::Id, +) -> Option { + let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event else { + return None; + }; + // Ignore bare modifier presses so holding Ctrl+Shift before the final + // key doesn't end the capture early. + if let keyboard::Key::Named(named) = &key { + if matches!( + named, + keyboard::key::Named::Control + | keyboard::key::Named::Shift + | keyboard::key::Named::Alt + | keyboard::key::Named::Super + | keyboard::key::Named::Meta + ) { + return None; + } + } + // Esc backs out of capture (and abandons an unfinished draft row) — + // binding the ESCAPE key itself is still possible via SHORTCUTS SET. + if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) { + return Some(Message::ShortcutCaptureCancel); + } + shortcut_key_name(&key, modifiers).map(Message::ShortcutCaptureKey) +} + fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option { let key = match key { keyboard::Key::Character(value) if !value.is_empty() => value.to_uppercase(), @@ -2198,19 +2228,12 @@ impl OpenCADStudio { } else { Subscription::none() }; - iced::Subscription::batch([ - frames, - history_tick, - grip_dwell, - hover_dwell, - nav_settle, - thumbnail_capture, - caret_blink, - web_fonts, - autosave, - plugin_drain, - single_instance, - hatch_pattern_keys, + // While a shortcut-editor Key cell is armed for capture, a dedicated + // listener records the pressed combination and swallows every other + // key so captured shortcuts do not run in the drawing. + let keyboard_events = if self.shortcut_capture_row.is_some() { + event::listen_with(shortcut_capture_key_event) + } else { event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { @@ -2340,7 +2363,22 @@ impl OpenCADStudio { } _ => None, } - }), + }) + }; + iced::Subscription::batch([ + frames, + history_tick, + grip_dwell, + hover_dwell, + nav_settle, + thumbnail_capture, + caret_blink, + web_fonts, + autosave, + plugin_drain, + single_instance, + hatch_pattern_keys, + keyboard_events, ]) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0fa5e27a7..444b5fe4a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -78,6 +78,68 @@ impl OpenCADStudio { automatic_flow(ex, crate::ui::window::about::view_window) } super::super::ModalKind::Shortcuts => { + // Keys claimed by two rows — the cells turn red and a + // persistent warning names the command already using each + // key; the last row wins in the binding map on Apply. + let mut key_rows: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in &self.shortcut_editor_rows { + let key = crate::app::shortcuts::normalize_key(&row.0); + if !key.is_empty() { + key_rows.entry(key).or_default().push(row); + } + } + let duplicate_keys: Vec = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, _)| key.clone()) + .collect(); + let duplicate_conflicts: Vec<(String, String)> = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, rows)| { + // Name the established binding: the first row with + // this key that actually has a command. + let command = rows + .iter() + .find(|(_, command)| !command.is_empty()) + .map(|(_, command)| command.clone()) + .unwrap_or_default(); + (key.clone(), command) + }) + .collect(); + let duplicate_set: rustc_hash::FxHashSet = + duplicate_keys.into_iter().collect(); + // Commands the dispatcher can't run: not a registered + // command, plugin command, alias, or input action. + let valid: rustc_hash::FxHashSet = + crate::command::all_registered_command_names() + .into_iter() + .map(str::to_uppercase) + .chain( + self.command_line + .dynamic_commands + .iter() + .map(|cmd| cmd.to_uppercase()), + ) + .chain(self.command_aliases.keys().cloned()) + .chain( + crate::app::shortcuts::INPUT_ACTIONS + .iter() + .map(|action| action.to_string()), + ) + .collect(); + let unknown_commands: Vec = self + .shortcut_editor_rows + .iter() + .filter_map(|(_, command)| { + let command = command.trim(); + (!command.is_empty() && !valid.contains(command)) + .then(|| command.to_string()) + }) + .collect(); + let unknown_set: rustc_hash::FxHashSet = + unknown_commands.into_iter().collect(); sized_flow( ex, 720, @@ -85,6 +147,13 @@ impl OpenCADStudio { |flow| { crate::ui::window::shortcuts::view_window( &self.shortcut_editor_rows, + self.shortcut_capture_row, + self.shortcut_pending_add, + self.shortcut_reset_confirm, + &duplicate_set, + &duplicate_conflicts, + &unknown_set, + self.shortcut_close_confirm, flow, ) }, diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 5bb37dd15..6f38ec873 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -531,6 +531,17 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, .into() } +/// Render an icon with the foreground chosen for a success-coloured surface. +pub fn themed_success_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(themed_handle(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.palette().success.base.text), + }) + .into() +} + /// Render a warning-state chrome icon from the active Iced theme. pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { svg(themed_handle(bytes)) diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 13e2735de..63440fa33 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -19,13 +19,54 @@ fn muted_style(theme: &Theme) -> iced::widget::text::Style { } } -pub fn view_window( - rows: &[(String, String)], +fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + } +} + +/// Text-input variant flagging an invalid value: danger border and value +/// color on the theme's danger weak background. +fn danger_input_style( + theme: &Theme, + status: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let danger = theme.palette().danger.base; + iced::widget::text_input::Style { + background: Background::Color(theme.palette().danger.weak.color), + border: iced::border::rounded(4) + .color(danger.color) + .width( + if matches!( + status, + iced::widget::text_input::Status::Focused { is_hovered: _ } + ) { + 1.5 + } else { + 1.0 + }, + ), + icon: danger.color, + placeholder: danger.color.scale_alpha(0.7), + value: danger.text, + selection: danger.color, + } +} + +pub fn view_window<'a>( + rows: &'a [(String, String)], + capture_row: Option, + pending_add: bool, + reset_confirm: bool, + duplicate_keys: &rustc_hash::FxHashSet, + duplicate_conflicts: &[(String, String)], + unknown_commands: &rustc_hash::FxHashSet, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, -) -> Element<'_, Message> { +) -> Element<'a, Message> { let title = text(t!("Keyboard Shortcuts")).size(15); let hint = text(t!( - "Type SHORTCUTS SET to add custom shortcuts." + "Click + Add, press a key combination, type the command, then Apply. Esc cancels the pending row." )) .size(11) .style(muted_style); @@ -49,15 +90,42 @@ pub fn view_window( let mut list = column![].spacing(3); for (idx, (key, command)) in rows.iter().enumerate() { - let key_box = text_input("Ctrl+Key", key) - .on_input(move |value| Message::ShortcutEditorInput { - idx, - field: ShortcutField::Key, - value, - }) - .size(13) - .padding([3, 6]) - .width(Length::Fixed(180.0)); + // Key cell: click to arm capture, then press the combination. The + // armed cell shows what is expected and highlights until a key + // arrives; clicking it again cancels. A key that already exists in + // another row is shown red. Manual key entry is available via + // SHORTCUTS SET. + let armed = capture_row == Some(idx); + let duplicate = !key.is_empty() && duplicate_keys.contains(key.as_str()); + let key_label = if armed { + t!("Press a key combination...").into_owned() + } else if key.is_empty() { + t!("Click, then press keys...").into_owned() + } else { + key.clone() + }; + let key_box = if armed { + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureClear) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::primary) + } else if duplicate { + // The whole field turns red, not just the text. + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::danger) + } else { + button(text(key_label).size(13).style(muted_style)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::secondary) + }; + let unknown_command = + !command.is_empty() && unknown_commands.contains(command.trim()); let command_box = text_input(t!("command").as_ref(), command) .on_input(move |value| Message::ShortcutEditorInput { idx, @@ -67,13 +135,50 @@ pub fn view_window( .size(13) .padding([3, 6]) .width(sizing.width); - let remove = button(crate::ui::icons::themed_danger_text( - crate::ui::icons::CLOSE, - 12.0, - )) - .on_press(Message::ShortcutEditorRemove(idx)) - .padding([2, 6]) - .style(button::danger); + // An unrunnable command turns the whole field red, mirroring the + // duplicate key cell. + let command_box = if unknown_command { + command_box.style(danger_input_style) + } else { + command_box + }; + // Draft rows get a check (finish the addition, without applying) and + // a cancel ✕; committed rows get the trash bin. + let remove = if pending_add && idx == 0 { + // Done is only clickable for a valid row: key and command filled, + // key not already used, command actually runnable. + let done_ok = !key.is_empty() + && !command.is_empty() + && !duplicate + && !unknown_commands.contains(command.trim()); + row![ + button(crate::ui::icons::themed_success_text( + crate::ui::icons::CHECK, + 12.0, + )) + .on_press_maybe(done_ok.then_some(Message::ShortcutEditorDraftAccept)) + .padding([2, 6]) + .style(button::success), + button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::ShortcutCaptureCancel) + .padding([2, 6]) + .style(button::danger), + ] + .spacing(4) + .align_y(iced::Center) + } else { + row![button(crate::ui::icons::themed_danger_text( + crate::ui::icons::TRASH, + 12.0, + )) + .on_press(Message::ShortcutEditorRemove(idx)) + .padding([2, 6]) + .style(button::danger)] + .align_y(iced::Center) + }; list = list.push( row![key_box, command_box, remove] .spacing(8) @@ -81,16 +186,100 @@ pub fn view_window( ); } - let add = button(text(format!("+ {}", t!("Add"))).size(12)) - .on_press(Message::ShortcutEditorAdd) - .padding([4, 10]) - .style(button::secondary); + // While a draft row is pending, the add button doubles as the visible + // cancel affordance (Esc works too). + let add = if pending_add { + button(text(t!("Cancel add (Esc)")).size(12)) + .on_press(Message::ShortcutCaptureCancel) + .padding([4, 10]) + .style(button::danger) + } else { + button(text(format!("+ {}", t!("Add"))).size(12)) + .on_press(Message::ShortcutEditorAdd) + .padding([4, 10]) + .style(button::secondary) + }; + // The reset confirmation replaces the shortcut count so the warning + // gets the whole left side of the bar. + let stats = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![text(format!("{}: {}", t!("Number of shortcuts"), rows.len())) + .size(12) + .style(muted_style)] + }; + // Reset asks for confirmation in place: the add/reset buttons are + // replaced by the question with Yes / No. + let (reset_area, add_area) = if reset_confirm { + ( + row![ + text(t!( + "Are you sure you want to reset? You will lose all your current shortcuts!" + )) + .size(12) + .style(danger_text_style), + button(text(t!("Yes, reset")).size(12)) + .on_press(Message::ShortcutEditorResetConfirm) + .padding([4, 10]) + .style(button::danger), + button(text(t!("No")).size(12)) + .on_press(Message::ShortcutEditorResetDeny) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(8) + .align_y(iced::Center), + row![Space::new().width(Length::Fixed(0.0))], + ) + } else { + ( + row![button(text(t!("Reset to default")).size(12)) + .on_press(Message::ShortcutEditorResetAsk) + .padding([4, 10]) + .style(button::secondary)] + .spacing(8) + .align_y(iced::Center), + row![add], + ) + }; let apply = button(text(t!("Apply")).size(12)) .on_press(Message::ShortcutEditorApply) .padding([4, 16]) .style(button::primary); + let apply_exit = button(text(t!("Apply && Exit")).size(12)) + .on_press(Message::ShortcutEditorApplyExit) + .padding([4, 16]) + .style(button::primary); + // The reset confirmation takes over the whole action bar. + let apply_area = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![apply, apply_exit].spacing(8).align_y(iced::Center) + }; - container( + // Persistent duplicate warning: shown until every conflicting key is + // resolved (re-captured, changed, or a row removed). + let mut conflict_banner = column![].spacing(2); + for (key, command) in duplicate_conflicts { + conflict_banner = conflict_banner.push( + text(crate::tf!( + "Shortcut already used for command: {} → {}", + key, + command + )) + .size(12) + .style(danger_text_style), + ); + } + for command in unknown_commands { + conflict_banner = conflict_banner.push( + text(crate::tf!("Unknown command: {}", command)) + .size(12) + .style(danger_text_style), + ); + } + + let content = container( column![ title, hint, @@ -98,7 +287,11 @@ pub fn view_window( head, scrollable(container(list).padding(gutter)).height(sizing.height), Space::new().height(6), - row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + conflict_banner, + Space::new().height(4), + row![stats, add_area, reset_area, Space::new().width(sizing.width), apply_area] + .spacing(8) + .align_y(iced::Center), ] .spacing(6) .width(sizing.width) @@ -112,6 +305,66 @@ pub fn view_window( theme.palette().background.base.color, )), ..Default::default() - }) - .into() + }); + + // Unsaved-changes guard: closing with un-applied rows stacks a dimmed + // shield and a confirmation panel on top of the editor. + if !close_confirm { + return content.into(); + } + let shield = iced::widget::mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::ShortcutEditorCloseKeep) + .interaction(iced::mouse::Interaction::Idle); + let panel = container( + column![ + text(t!("Unsaved changes will be discarded.")).size(14), + Space::new().height(10), + row![ + button(text(t!("Discard && close")).size(12)) + .on_press(Message::ShortcutEditorCloseDiscard) + .padding([4, 12]) + .style(button::danger), + button(text(t!("Keep editing")).size(12)) + .on_press(Message::ShortcutEditorCloseKeep) + .padding([4, 12]) + .style(button::secondary), + ] + .spacing(8), + ] + .spacing(4), + ) + .padding(16) + .width(Length::Fixed(320.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + let centered = container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + iced::widget::stack![content, iced::widget::opaque(shield), centered].into() } From 6606da50299e575a5ca8b71a36b779db9436ce8a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 1 Sep 2026 22:41:05 +0300 Subject: [PATCH 8/9] fix(blocks): complete block-backed instance rendering --- src/entities/insert.rs | 131 +++++++++------- src/entities/multileader.rs | 5 +- src/scene/cache/block_cache.rs | 234 +++++++++++++++-------------- src/scene/camera_ops.rs | 3 +- src/scene/convert/dgn_linestyle.rs | 15 +- src/scene/convert/tess.rs | 47 ++++-- src/scene/convert/tessellate.rs | 4 +- src/scene/entity.rs | 2 +- src/scene/mod.rs | 162 ++++++++++++++++++-- src/scene/render_graph.rs | 201 ++++++++++++++++++++----- tests/text_font_rendering.rs | 3 + 11 files changed, 554 insertions(+), 253 deletions(-) diff --git a/src/entities/insert.rs b/src/entities/insert.rs index 17fa1be69..37f7dcd7f 100644 --- a/src/entities/insert.rs +++ b/src/entities/insert.rs @@ -189,16 +189,19 @@ pub(crate) fn insert_attribute_entities( document: &acadrust::CadDocument, insert: &acadrust::entities::Insert, annotation_scale: f32, + scale_policy: crate::scene::BlockScalePolicy, ) -> Vec { let visibility = document.header.attribute_visibility; if visibility == 0 || insert.attributes.is_empty() { return Vec::new(); } - let block_scale = if insert - .common - .extended_data - .get_record("AcAnnotativeData") - .is_some() + let block_scale = if scale_policy + == crate::scene::BlockScalePolicy::FromInsert + && insert + .common + .extended_data + .get_record("AcAnnotativeData") + .is_some() { annotation_scale as f64 } else { @@ -253,59 +256,75 @@ pub(crate) fn append_insert_attribute_wires( pslt_factor: f32, anno_scale: f32, ) { - for attr_entity in insert_attribute_entities(document, ins, anno_scale) { - let attr = attr_entity.common(); - let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) = render::render_style_for_block_sub( + let attributes = insert_attribute_entities( + document, + ins, + anno_scale, + crate::scene::BlockScalePolicy::FromInsert, + ); + for offset in crate::scene::render_graph::array_offsets(ins) { + let delta = crate::scene::render_graph::insert_instance_translation_delta( document, - &attr_entity, - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - ins_layer, + ins, + offset, + anno_scale, + crate::scene::BlockScalePolicy::FromInsert, ); - let sub_color = render::adapt_to_bg(sub_color, bg_color); - let sub_color = if is_xref && !sel { - block_cache::fade_toward_bg(sub_color, bg_color) - } else { - sub_color - }; - let attr_plottable = if render::is_effective_layer_zero(&attr.layer) { - ins_layer_plottable - } else { - document - .layers - .get(&attr.layer) - .map(|layer| layer.is_plottable) - .unwrap_or(true) - }; - let attr_plottable = ins_layer_plottable && attr_plottable; - let sub_aabb = crate::scene::entity_aabb(&attr_entity); - let mut attr_wires = tessellate::tessellate( - document, - insert_handle, - &attr_entity, - sel, - sub_color, - sub_plen * pslt_factor, - sub_pat.map(|v| v * pslt_factor), - sub_lw_px, - // Neutral: the attribute is already scaled as part of the block unit - // above, so the text path must not scale it a second time. - 1.0, - None, - None, - bg_color, - false, - ); - // Use the INSERT's handle so selection / picking groups attribute - // text with the parent insert instead of treating it as a stray text. - for w in &mut attr_wires { - w.name = insert_handle.value().to_string(); - w.aci = sub_aci; - w.aabb = sub_aabb; - w.plot_visible &= attr_plottable; + for source in &attributes { + let mut attr_entity = source.clone(); + if delta != Vector3::ZERO { + attr_entity.apply_transform(&Transform::from_translation(delta)); + } + let attr = attr_entity.common(); + let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) = + render::render_style_for_block_sub( + document, + &attr_entity, + ins_color, + ins_pat_len, + ins_pat, + ins_lw_px, + ins_layer, + ); + let sub_color = render::adapt_to_bg(sub_color, bg_color); + let sub_color = if is_xref && !sel { + block_cache::fade_toward_bg(sub_color, bg_color) + } else { + sub_color + }; + let attr_plottable = if render::is_effective_layer_zero(&attr.layer) { + ins_layer_plottable + } else { + document + .layers + .get(&attr.layer) + .map(|layer| layer.is_plottable) + .unwrap_or(true) + }; + let attr_plottable = ins_layer_plottable && attr_plottable; + let sub_aabb = crate::scene::entity_aabb(&attr_entity); + let mut attr_wires = tessellate::tessellate( + document, + insert_handle, + &attr_entity, + sel, + sub_color, + sub_plen * pslt_factor, + sub_pat.map(|v| v * pslt_factor), + sub_lw_px, + 1.0, + None, + None, + bg_color, + false, + ); + for w in &mut attr_wires { + w.name = insert_handle.value().to_string(); + w.aci = sub_aci; + w.aabb = sub_aabb; + w.plot_visible &= attr_plottable; + } + wires.extend(attr_wires); } - wires.extend(attr_wires); } } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index c3737e28d..c1c4bcc65 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2129,7 +2129,10 @@ impl MultiLeaderTess for MultiLeader { view_aabb, world_per_pixel, 1.0, - crate::scene::convert::tess::BlockObjectOptions::default(), + crate::scene::convert::tess::BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..crate::scene::convert::tess::BlockObjectOptions::default() + }, ) .wires, ); diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index aaf12f4da..5e04a9613 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -140,12 +140,16 @@ pub enum LocalSub { Nested(NestedRef), } +#[derive(Clone, Copy, Debug, Default)] +pub struct BlockMetrics { + pub aabb_local: [f32; 4], + pub has_point_marker: bool, +} + #[derive(Clone, Debug, Default)] pub struct BlockDefn { pub subs: Vec, - pub has_point_marker: bool, - /// Union of direct and nested geometry in definition coordinates. - pub aabb_local: [f32; 4], + pub metrics: BlockMetrics, /// Raw entity count of the source block record (`entity_handles.len()`). /// Divisor for nested depth composition: a nested insert's children get a /// sub-range of `parent_scale / (child_count + 1)`, shared with the scene @@ -247,8 +251,8 @@ impl BlockCache { // by-name references (`LocalSub::Nested`), never expanded here, so a // block's build never depends on another block's defn. That makes the // builds embarrassingly parallel over the read-only `doc` — no - // topological ordering required. `compute_block_aabbs` stays a serial - // post-pass (it resolves nested references and is comparatively cheap). + // topological ordering required. Metrics are resolved after all + // definitions exist. cache.defns = referenced .par_iter() .map(|name| { @@ -267,7 +271,7 @@ impl BlockCache { ) }) .collect(); - cache.compute_block_aabbs(&referenced); + cache.compute_block_metrics(&referenced); cache } @@ -304,106 +308,73 @@ impl BlockCache { ) }) .collect(); - cache.compute_block_aabbs(&referenced); + cache.compute_block_metrics(&referenced); cache } - /// Compute and store the `aabb_local` for every cached defn. Direct wires - /// contribute their own aabb_local; nested INSERT references look up the - /// nested defn (already cached) and transform its aabb_local by the - /// nested Insert's transform before unioning. - /// - /// Run as a post-pass so it doesn't matter which order build_defn was - /// called in. Cycle guard: a self-referential block keeps an empty AABB - /// (will fail every frustum test → not emitted, which is correct). - fn compute_block_aabbs(&mut self, names: &[String]) { + /// Resolve recursive bounds and point-marker presence. + fn compute_block_metrics(&mut self, names: &[String]) { use crate::par::prelude::*; - // Phase 1 (parallel, read-only): each defn's union AABB is resolved - // by `defn_aabb_recursive`, which only *reads* `self.defns` (the map - // is fully built by now). There's no memoization, so a defn shared by - // many parents is re-walked per parent — real work on block-heavy - // drawings — and the per-name walks are independent, so they fan out. + // Definitions are complete, so each recursive read can run independently. let this: &Self = self; - let resolved: Vec<(&String, [f32; 4], bool)> = names + let resolved: Vec<(&String, BlockMetrics)> = names .par_iter() .map(|name| { let mut visited: Vec = Vec::new(); - let aabb = this.defn_aabb_recursive(name, &mut visited); - visited.clear(); - let has_marker = this.defn_has_point_marker_recursive(name, &mut visited); - (name, aabb, has_marker) + (name, this.defn_metrics_recursive(name, &mut visited)) }) .collect(); - // Phase 2 (serial): store the AABB back into each defn. - for (name, aabb, has_marker) in resolved { + for (name, metrics) in resolved { if let Some(defn_arc) = self.defns.get_mut(name) { let mut defn = (**defn_arc).clone(); - defn.aabb_local = aabb; - defn.has_point_marker = has_marker; + defn.metrics = metrics; *defn_arc = Arc::new(defn); } } } - fn defn_has_point_marker_recursive( + fn defn_metrics_recursive( &self, block_name: &str, visited: &mut Vec, - ) -> bool { + ) -> BlockMetrics { if visited.iter().any(|name| name == block_name) { - return false; + return BlockMetrics::default(); } let Some(defn) = self.defns.get(block_name) else { - return false; + return BlockMetrics::default(); }; visited.push(block_name.to_string()); - let found = defn.subs.iter().any(|sub| match sub { - LocalSub::Wire(wire) => wire.point_marker.is_some(), - LocalSub::Nested(insert) => { - insert - .attachments - .iter() - .any(|wire| wire.point_marker.is_some()) - || self.defn_has_point_marker_recursive(&insert.block_name, visited) - } - }); - visited.pop(); - found - } - - /// Returns the union AABB for `block_name`'s defn, expressed in **that - /// defn's offset frame** (so its caller can store it in - /// `BlockDefn.aabb_local` without a coordinate-frame mismatch). - /// - /// LocalWire contributions are already in the parent defn's offset - /// frame. Nested-INSERT contributions live in the *child* defn's offset - /// frame, so we re-add `child.local_offset` (f64), apply the nested - /// Insert's transform to get parent-native coordinates, then subtract - /// `parent.local_offset` to land back in the parent's offset frame. - fn defn_aabb_recursive(&self, block_name: &str, visited: &mut Vec) -> [f32; 4] { - if visited.iter().any(|n| n == block_name) { - return [0.0, 0.0, 0.0, 0.0]; - } - let Some(defn) = self.defns.get(block_name) else { - return [0.0, 0.0, 0.0, 0.0]; - }; - visited.push(block_name.to_string()); - let mut acc = [0.0_f32, 0.0, 0.0, 0.0]; + let mut metrics = BlockMetrics::default(); for sub in &defn.subs { - let aabb = match sub { - LocalSub::Wire(lw) => lw.aabb_local, - LocalSub::Nested(nref) => { - let nested = self.defn_aabb_recursive(&nref.block_name, visited); - nref.attachments.iter().fold( - transform_aabb_xy(nested, &nref.xform), - |aabb, wire| aabb_union(aabb, wire.aabb_local), - ) + match sub { + LocalSub::Wire(wire) => { + metrics.aabb_local = aabb_union(metrics.aabb_local, wire.aabb_local); + metrics.has_point_marker |= wire.point_marker.is_some(); } - }; - acc = aabb_union(acc, aabb); + LocalSub::Nested(nref) => { + let nested = self.defn_metrics_recursive(&nref.block_name, visited); + metrics.has_point_marker |= nested.has_point_marker; + for offset in &nref.instance_offsets { + let transform = nested_instance_transform(nref, *offset); + metrics.aabb_local = aabb_union( + metrics.aabb_local, + transform_aabb_xy(nested.aabb_local, &transform), + ); + let attachment_transform = nested_attachment_transform(nref, *offset); + for wire in &nref.attachments { + metrics.aabb_local = aabb_union( + metrics.aabb_local, + transform_aabb_xy(wire.aabb_local, &attachment_transform), + ); + metrics.has_point_marker |= wire.point_marker.is_some(); + } + } + } + } } visited.pop(); - acc + metrics } } @@ -479,6 +450,21 @@ fn layer_hidden(doc: &CadDocument, layer: &str) -> bool { .unwrap_or(false) } +fn nested_instance_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform { + if offset == [0.0; 3] { + nref.xform.clone() + } else { + Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])) + .then(&nref.xform) + } +} + +fn nested_attachment_transform(nref: &NestedRef, offset: [f64; 3]) -> Transform { + let base = nref.xform.apply(Vector3::ZERO); + let instance = nested_instance_transform(nref, offset).apply(Vector3::ZERO); + Transform::from_translation(instance - base) +} + fn build_defn( doc: &CadDocument, block_name: &str, @@ -549,6 +535,7 @@ fn build_defn( for block_use in &block_uses { let mut nested = build_nested_ref( &block_use.insert, + block_use.scale_policy, doc, anno_scale, bg_color, @@ -597,14 +584,14 @@ fn build_defn( } BlockDefn { subs, - has_point_marker: false, - aabb_local: [0.0; 4], + metrics: BlockMetrics::default(), child_count: br.entity_handles.len(), } } fn build_nested_ref( nested_ins: &acadrust::entities::Insert, + scale_policy: crate::scene::BlockScalePolicy, doc: &CadDocument, anno_scale: f32, bg_color: [f32; 4], @@ -614,10 +601,11 @@ fn build_nested_ref( // Bake the XCLIP boundary (parent-defn-local) so the nested insert keeps // its clip when the parent block is expanded — the spatial filter object // isn't reachable at expand time. - let xform = crate::scene::render_graph::insert_transform_at_scale( + let xform = crate::scene::render_graph::insert_transform_with_policy( doc, nested_ins, anno_scale, + scale_policy, ); let clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins) .map(|filter| { @@ -635,6 +623,7 @@ fn build_nested_ref( doc, nested_ins, anno_scale, + scale_policy, ) .into_iter() .flat_map(|attribute| { @@ -977,12 +966,18 @@ pub fn expand_insert( // Current annotation scale. An annotative block scales as one uniform unit // about its insertion point; a non-annotative block is unaffected. anno_scale: f32, + scale_policy: crate::scene::BlockScalePolicy, // Dimension picture blocks store definition POINTs at their root. They are // metadata, not visible geometry; nested POINTs remain regular block data. suppress_root_points: bool, ) -> Option> { let defn = cache.defn(&ins.block_name)?; - let xform = crate::scene::render_graph::insert_transform_at_scale(doc, ins, anno_scale); + let xform = crate::scene::render_graph::insert_transform_with_policy( + doc, + ins, + anno_scale, + scale_policy, + ); let name = ins_handle.value().to_string(); let prototype_key = if !ins.is_array() && cache.prototype_blocks.contains(&ins.block_name) @@ -1042,10 +1037,22 @@ pub fn expand_insert( let mut batches = Batches::default(); let mut visited: Vec = Vec::with_capacity(8); - // `defn.aabb_local` is in the defn's offset frame — re-add - // `defn.local_offset` (f64) before transforming so the world AABB is - // accurate for distant content. - let insert_world = transform_aabb_xy(defn.aabb_local, &xform); + let offsets = crate::scene::render_graph::array_offsets(ins); + let cell_xform = |offset: &[f64; 3]| { + crate::scene::render_graph::insert_instance_transform( + doc, + ins, + *offset, + anno_scale, + scale_policy, + ) + }; + let insert_world = offsets.iter().fold([0.0_f32; 4], |aabb, offset| { + aabb_union( + aabb, + transform_aabb_xy(defn.metrics.aabb_local, &cell_xform(offset)), + ) + }); let insert_local = [ insert_world[0] as f32, insert_world[1] as f32, @@ -1062,7 +1069,7 @@ pub fn expand_insert( // Whole-Insert pixel-size LOD: if the entire Insert footprint projects // to sub-pixel size, skip it entirely. if let Some(wpp) = world_per_pixel { - if !defn.has_point_marker && aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE { + if !defn.metrics.has_point_marker && aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE { return Some(vec![]); } } @@ -1085,16 +1092,7 @@ pub fn expand_insert( is_xref, bg_color, }; - let offsets = crate::scene::render_graph::array_offsets(ins); if offsets.len() > 1 { - let cell_xform = |offset: &[f64; 3]| { - if offset == &[0.0; 3] { - xform.clone() - } else { - Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])) - .then(&xform) - } - }; let first_xform = cell_xform(&offsets[0]); let first_translation = transform_translation(&first_xform); let mut first_batches = Batches::default(); @@ -1135,14 +1133,7 @@ pub fn expand_insert( } for offset in &offsets { - let base_xform = if offset == &[0.0; 3] { - xform.clone() - } else { - let translation = Transform::from_translation(Vector3::new( - offset[0], offset[1], offset[2], - )); - translation.then(&xform) - }; + let base_xform = cell_xform(offset); expand_defn( defn, &base_xform, @@ -1816,8 +1807,18 @@ fn expand_defn( is_xref: ctx.is_xref, bg_color: ctx.bg_color, }; - for attachment in &nref.attachments { - emit_wire(attachment, accum_xform, &inner_ctx, out, d_range); + for offset in &nref.instance_offsets { + let attachment_transform = nested_attachment_transform(nref, *offset) + .then(accum_xform); + for attachment in &nref.attachments { + emit_wire( + attachment, + &attachment_transform, + &inner_ctx, + out, + d_range, + ); + } } if visited.iter().any(|n| n == &nref.block_name) { continue; @@ -1825,8 +1826,17 @@ fn expand_defn( let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else { continue; }; - let composed = nref.xform.then(accum_xform); - let world = transform_aabb_xy(nested_defn.aabb_local, &composed); + let world = nref.instance_offsets.iter().fold( + [0.0_f32; 4], + |aabb, offset| { + let composed = nested_instance_transform(nref, *offset) + .then(accum_xform); + aabb_union( + aabb, + transform_aabb_xy(nested_defn.metrics.aabb_local, &composed), + ) + }, + ); let local = [ world[0] as f32, world[1] as f32, @@ -1851,15 +1861,7 @@ fn expand_defn( d_range.1 / (nested_defn.child_count.max(1) as f32 + 1.0), ); let composed_for = |offset: &[f64; 3]| { - if offset == &[0.0; 3] { - nref.xform.then(accum_xform) - } else { - Transform::from_translation(Vector3::new( - offset[0], offset[1], offset[2], - )) - .then(&nref.xform) - .then(accum_xform) - } + nested_instance_transform(nref, *offset).then(accum_xform) }; if let Some(cp) = &nref.clip_poly { let base_composed = nref.xform.then(accum_xform); diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 317148c9b..30fbc7259 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -107,10 +107,11 @@ fn block_entity_transform( visited, annotation_scale, )?; - Some(inner.then(&crate::scene::render_graph::insert_transform_at_scale( + Some(inner.then(&crate::scene::render_graph::insert_transform_with_policy( document, &block_use.insert, annotation_scale, + block_use.scale_policy, ))) }) }); diff --git a/src/scene/convert/dgn_linestyle.rs b/src/scene/convert/dgn_linestyle.rs index 70ba4c6b5..6b7b4c041 100644 --- a/src/scene/convert/dgn_linestyle.rs +++ b/src/scene/convert/dgn_linestyle.rs @@ -144,8 +144,8 @@ pub fn symbol_radius(doc: &CadDocument, block: Handle, scale: f64) -> f64 { ) else { return 0.0; }; - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, block, |_, _| true, |entity, context| { @@ -322,16 +322,15 @@ pub fn place_block_wires( ) else { return Vec::new(); }; - let insert = &mut block_use.insert; - insert.set_x_scale(s); - insert.set_y_scale(s); - insert.set_z_scale(s); + block_use.insert.set_x_scale(s); + block_use.insert.set_y_scale(s); + block_use.insert.set_z_scale(s); let depths = rustc_hash::FxHashMap::default(); let graph = crate::scene::render_graph::RenderSceneGraph::new(doc, None, None, true, &depths) .with_annotation_scale(anno_scale); let mut out = Vec::new(); - graph.walk_insert( - insert, + graph.walk_block_use( + &block_use, block, |_, _| true, |entity, context| { diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index f6a78a726..b877827dd 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -39,6 +39,7 @@ pub(crate) struct ExpandedBlockObject { pub(crate) struct BlockObjectOptions { pub(crate) suppress_root_points: bool, pub(crate) apply_xclip: bool, + pub(crate) scale_policy: crate::scene::BlockScalePolicy, } fn block_object_style( @@ -141,16 +142,18 @@ pub(crate) fn expand_block_object( is_xref, bg_color, anno_scale, + options.scale_policy, options.suppress_root_points, ) .unwrap_or_default(); if options.apply_xclip { if let Some(filter) = pick::xclip::insert_spatial_filter(document, insert) { - let transform = crate::scene::render_graph::insert_transform_at_scale( + let transform = crate::scene::render_graph::insert_transform_with_policy( document, insert, anno_scale, + options.scale_policy, ); let polygon = pick::xclip::world_clip_polygon_for_transform(filter, &transform); pick::xclip::clip_wires(&mut wires, &polygon); @@ -568,7 +571,7 @@ fn tessellate_entity_inner( // True only when tessellating content shown inside a paper-space viewport. // Retained through recursive INSERT expansion; PSLTSCALE itself is applied // by the viewport's GPU uniform so it never changes resident wire content. - paper_space: bool, + _paper_space: bool, ) -> Vec { let contextual = crate::scene::annotative::entity_for_annotation_context( document, @@ -980,6 +983,7 @@ fn tessellate_entity_inner( pslt_factor, BlockObjectOptions { suppress_root_points: block_use.suppress_root_points, + scale_policy: block_use.scale_policy, ..BlockObjectOptions::default() }, ) @@ -1087,7 +1091,10 @@ fn tessellate_entity_inner( view_aabb, world_per_pixel, pslt_factor, - BlockObjectOptions::default(), + BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..BlockObjectOptions::default() + }, ) .wires; if !wires.is_empty() { @@ -1119,19 +1126,27 @@ fn tessellate_entity_inner( .filter(|block_use| { block_use.role == crate::scene::render_graph::BlockRole::TableCell }) { - wires.extend(tessellate_entity( - document, - selected, - active_viewport, - bg_color, - 1.0, - None, - &EntityType::Insert(block_use.insert), - block_cache, - view_aabb, - world_per_pixel, - paper_space, - )); + wires.extend( + expand_block_object( + document, + &block_use.insert, + h, + sel, + active_viewport, + bg_color, + table_anno, + None, + block_cache, + view_aabb, + world_per_pixel, + pslt_factor, + BlockObjectOptions { + scale_policy: block_use.scale_policy, + ..BlockObjectOptions::default() + }, + ) + .wires, + ); } if !wires.is_empty() { let aabb = entity_aabb(e); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index aa131ce88..6bf477734 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -2100,8 +2100,8 @@ fn custom_arrow_from_block( let mut lines = Vec::new(); let mut fill = Vec::new(); let mut deferred_hatch = false; - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, record.handle, |_, _| true, |entity, context| { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 908a59100..85f25954e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1183,7 +1183,7 @@ impl Scene { fn layer_plottable_in_context( &self, entity: &EntityType, - context: &crate::scene::render_graph::RenderContext, + context: &crate::scene::render_graph::InstanceContext, ) -> bool { let common = entity.common(); let layer = if crate::scene::view::render::is_effective_layer_zero(&common.layer) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e54209b74..13f5bf6c2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -17,6 +17,13 @@ pub(crate) mod render_graph; pub mod text; pub mod view; +#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] +pub enum BlockScalePolicy { + #[default] + FromInsert, + Applied, +} + // Topic submodules split out of this root (each contributes `impl Scene` // blocks and/or free functions). Pure text-move from the original mod.rs. mod boundary; @@ -66,6 +73,13 @@ struct DependencyTargets { touches_block_definition: bool, } +#[derive(Clone, Copy)] +enum DependencyKind { + Layer, + TextStyle, + DimStyle, +} + #[derive(Default)] struct SceneDependencyIndex { layers: HashMap, @@ -75,6 +89,7 @@ struct SceneDependencyIndex { points: DependencyTargets, text_geometry: DependencyTargets, annotation_geometry: DependencyTargets, + signatures: HashMap, } fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { @@ -164,6 +179,7 @@ use glam; use iced::time::Duration; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::cell::RefCell; +use std::hash::{Hash, Hasher}; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -2429,11 +2445,7 @@ impl Scene { } } if !changes.is_empty() { - // A Modified delta can change layer/style/block references as well - // as coordinates. Rebuild the reverse dependency map lazily on its - // next use so later targeted global-property updates never follow - // stale ownership. - self.invalidate_dependency_index(); + self.refresh_dependency_index_for_changes(&changes); } let cached_light_changed = self.lighting_cache.borrow().values().any(|lights| { changes @@ -2533,6 +2545,7 @@ impl Scene { // Structural change — drop both per-entity tessellation memos. self.tess_memo.borrow_mut().clear(); self.resident_tess_memo.borrow_mut().clear(); + self.invalidate_dependency_index(); // Can't name the changed handles → force every journal consumer to rebuild. self.push_geometry_delta(epoch, Vec::new(), true); } @@ -2616,6 +2629,7 @@ impl Scene { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; self.lighting_cache.borrow_mut().clear(); + self.invalidate_dependency_index(); self.push_geometry_delta(epoch, Vec::new(), true); } @@ -7132,8 +7146,8 @@ impl Scene { ) { continue; } - graph.walk_insert( - &block_use.insert, + graph.walk_block_use( + &block_use, common.handle, |_, _| true, |entity, context| { @@ -7387,7 +7401,10 @@ impl Scene { let changed_live: Vec = changes .iter() .filter_map(|(handle, kind)| { - (!matches!(kind, ChangeKind::Removed)).then_some(*handle) + (!matches!(kind, ChangeKind::Removed) + && !self.entity_temporarily_hidden(*handle) + && self.document.get_entity(*handle).is_some()) + .then_some(*handle) }) .collect(); let wires = Arc::new(self.wire_models_for(&changed_live)); @@ -8847,6 +8864,113 @@ impl Scene { }) } + fn entity_dependency_signature(&self, entity: &EntityType) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let common = entity.common(); + common.handle.hash(&mut hasher); + common.owner_handle.hash(&mut hasher); + normalize_name(&common.layer).hash(&mut hasher); + + let category = match entity { + EntityType::Point(_) => 1_u8, + EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Dimension(_) + | EntityType::Leader(_) + | EntityType::Tolerance(_) + | EntityType::MultiLeader(_) + | EntityType::Table(_) + | EntityType::Shape(_) => 2, + EntityType::Hatch(_) => 3, + EntityType::Insert(insert) if !insert.attributes.is_empty() => 4, + _ => 0, + }; + category.hash(&mut hasher); + crate::scene::annotative::is_annotative(&self.document, entity).hash(&mut hasher); + + for block_use in render_graph::entity_block_uses(&self.document, entity, 1.0) { + block_use.block.hash(&mut hasher); + normalize_name(&block_use.insert.block_name).hash(&mut hasher); + block_use.role.hash(&mut hasher); + block_use.scale_policy.hash(&mut hasher); + block_use.active.hash(&mut hasher); + } + + match entity { + EntityType::Text(text) => normalize_name(&text.style).hash(&mut hasher), + EntityType::MText(text) => normalize_name(&text.style).hash(&mut hasher), + EntityType::AttributeDefinition(attribute) => { + normalize_name(&attribute.text_style).hash(&mut hasher) + } + EntityType::AttributeEntity(attribute) => { + normalize_name(&attribute.text_style).hash(&mut hasher) + } + EntityType::Insert(insert) => { + for attribute in &insert.attributes { + normalize_name(&attribute.common.layer).hash(&mut hasher); + normalize_name(&attribute.text_style).hash(&mut hasher); + } + } + EntityType::Dimension(dimension) => { + normalize_name(&dimension.base().style_name).hash(&mut hasher) + } + EntityType::Leader(leader) => { + normalize_name(&leader.dimension_style).hash(&mut hasher) + } + EntityType::Tolerance(tolerance) => { + normalize_name(&tolerance.dimension_style_name).hash(&mut hasher) + } + EntityType::Table(table) => { + table.table_style_handle.hash(&mut hasher); + for row in &table.rows { + row.style + .as_ref() + .and_then(|style| style.text_style_handle) + .hash(&mut hasher); + for cell in &row.cells { + cell.style + .as_ref() + .and_then(|style| style.text_style_handle) + .hash(&mut hasher); + for content in &cell.contents { + content.text_style_handle.hash(&mut hasher); + } + } + } + } + EntityType::MultiLeader(leader) => { + leader.style_handle.hash(&mut hasher); + leader.text_style_handle.hash(&mut hasher); + leader.context.text_style_handle.hash(&mut hasher); + } + EntityType::MLine(line) => line.style_handle.hash(&mut hasher), + _ => {} + } + hasher.finish() + } + + fn refresh_dependency_index_for_changes(&self, changes: &[(Handle, ChangeKind)]) { + let mut cache = self.dependency_index_cache.borrow_mut(); + let Some(index) = cache.as_ref() else { + return; + }; + let unchanged = changes.iter().all(|(handle, kind)| { + if !matches!(kind, ChangeKind::Modified) { + return false; + } + let Some(entity) = self.document.get_entity(*handle) else { + return false; + }; + index.signatures.get(handle).copied() + == Some(self.entity_dependency_signature(entity)) + }); + if !unchanged { + cache.take(); + } + } + fn rebuild_dependency_index(&self) -> SceneDependencyIndex { let layout_blocks: HashSet = self .document @@ -8928,6 +9052,9 @@ impl Scene { let mut index = SceneDependencyIndex::default(); for entity in self.document.entities() { let common = entity.common(); + index + .signatures + .insert(common.handle, self.entity_dependency_signature(entity)); let owner = if common.owner_handle.is_null() { membership .get(&common.handle) @@ -9102,17 +9229,20 @@ impl Scene { index } - fn dependency_targets(&self, kind: &str, names: &[String]) -> DependencyTargets { + fn dependency_targets( + &self, + kind: DependencyKind, + names: &[String], + ) -> DependencyTargets { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } let cache = self.dependency_index_cache.borrow(); let index = cache.as_ref().unwrap(); let map = match kind { - "layer" => &index.layers, - "text" => &index.text_styles, - "dim" => &index.dim_styles, - _ => unreachable!(), + DependencyKind::Layer => &index.layers, + DependencyKind::TextStyle => &index.text_styles, + DependencyKind::DimStyle => &index.dim_styles, }; let mut combined = DependencyTargets::default(); for name in names { @@ -9148,7 +9278,7 @@ impl Scene { } pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { - let targets = self.dependency_targets("layer", names); + let targets = self.dependency_targets(DependencyKind::Layer, names); self.invalidate_dependency_targets(targets); } @@ -9157,7 +9287,7 @@ impl Scene { } pub fn invalidate_text_style_dependencies_many(&mut self, names: &[String]) { - let targets = self.dependency_targets("text", names); + let targets = self.dependency_targets(DependencyKind::TextStyle, names); self.invalidate_dependency_targets(targets); } @@ -9166,7 +9296,7 @@ impl Scene { } pub fn invalidate_dim_style_dependencies_many(&mut self, names: &[String]) { - let targets = self.dependency_targets("dim", names); + let targets = self.dependency_targets(DependencyKind::DimStyle, names); self.invalidate_dependency_targets(targets); } diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index 69400ad4f..db73f1f56 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -15,6 +15,7 @@ use crate::scene::view::render::{ has_resolved_book_color, is_effective_layer_zero, layer_render_style_viewport, render_style_for_block_sub_viewport, render_style_for_viewport, InheritStyle, }; +use crate::scene::BlockScalePolicy; pub type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8); @@ -216,7 +217,7 @@ fn layer_aci(document: &CadDocument, layer: &str) -> u8 { } #[derive(Clone, Debug)] -pub struct RenderContext { +pub struct InstanceContext { pub transform: Transform, pub root_handle: Handle, pub parent_insert: Handle, @@ -227,9 +228,10 @@ pub struct RenderContext { pub depth_scale: f32, pub nesting_depth: usize, pub viewport: Option, + pub scale_policy: BlockScalePolicy, } -impl RenderContext { +impl InstanceContext { fn direct(depth_base: f32, viewport: Option) -> Self { Self { transform: Transform::identity(), @@ -242,6 +244,7 @@ impl RenderContext { depth_scale: 1.0, nesting_depth: 0, viewport, + scale_policy: BlockScalePolicy::FromInsert, } } @@ -314,8 +317,8 @@ impl<'a> RenderSceneGraph<'a> { /// false for an Insert removes its whole subtree. pub fn walk_root(&self, root: SceneRoot, mut visible: V, mut leaf: F) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let block = root.content_block(); let viewport = match root { @@ -344,12 +347,18 @@ impl<'a> RenderSceneGraph<'a> { .depths .get(&handle.value()) .map_or(0.0, |depth| depth[0]); - let context = RenderContext::direct(direct_depth, viewport); + let context = InstanceContext::direct(direct_depth, viewport); if !self.document_visible(entity) || !visible(entity, &context) { continue; } if let EntityType::Insert(insert) = entity { - self.walk_insert_instances(insert, &context, &mut visible, &mut leaf); + self.walk_insert_instances( + insert, + BlockScalePolicy::FromInsert, + &context, + &mut visible, + &mut leaf, + ); } else { leaf(entity, &context); self.walk_owned_content(entity, &context, &mut visible, &mut leaf, &mut Vec::new()); @@ -359,15 +368,36 @@ impl<'a> RenderSceneGraph<'a> { /// Walk one synthetic or document-owned Insert. Used by entity renderers /// whose content is itself a block reference. - pub fn walk_insert( + pub fn walk_block_use( &self, - insert: &Insert, + block_use: &BlockUse, root_handle: Handle, mut visible: V, mut leaf: F, + ) + where + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), + { + self.walk_insert_with_policy( + &block_use.insert, + root_handle, + block_use.scale_policy, + &mut visible, + &mut leaf, + ); + } + + fn walk_insert_with_policy( + &self, + insert: &Insert, + root_handle: Handle, + scale_policy: BlockScalePolicy, + visible: &mut V, + leaf: &mut F, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let mut root_insert = insert.clone(); root_insert.common.handle = root_handle; @@ -376,9 +406,15 @@ impl<'a> RenderSceneGraph<'a> { .depths .get(&root_handle.value()) .map_or(0.0, |depth| depth[0]); - let context = RenderContext::direct(depth_base, self.viewport); + let context = InstanceContext::direct(depth_base, self.viewport); if self.document_visible(&entity) && visible(&entity, &context) { - self.walk_insert_instances(&root_insert, &context, &mut visible, &mut leaf); + self.walk_insert_instances( + &root_insert, + scale_policy, + &context, + visible, + leaf, + ); } } @@ -386,12 +422,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_insert_instances( &self, insert: &Insert, - parent: &RenderContext, + scale_policy: BlockScalePolicy, + parent: &InstanceContext, visible: &mut V, leaf: &mut F, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { let insert_entity = EntityType::Insert(insert.clone()); let block_style = parent @@ -424,6 +461,7 @@ impl<'a> RenderSceneGraph<'a> { insert, offset, self.annotation_scale, + scale_policy, ); let transform = local.then(&parent.transform); let mut context = parent.clone(); @@ -435,6 +473,7 @@ impl<'a> RenderSceneGraph<'a> { context.depth_base = depth_base; context.depth_scale = depth_scale; context.nesting_depth += 1; + context.scale_policy = scale_policy; if let Some(filter) = crate::scene::pick::xclip::insert_spatial_filter( self.document, insert, @@ -465,13 +504,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_block( &self, block_name: &str, - context: &RenderContext, + context: &InstanceContext, visible: &mut V, leaf: &mut F, stack: &mut Vec, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { if context.nesting_depth > 32 { return; @@ -504,7 +543,13 @@ impl<'a> RenderSceneGraph<'a> { continue; } stack.push(nested.block_name.clone()); - self.walk_insert_instances(nested, context, visible, leaf); + self.walk_insert_instances( + nested, + BlockScalePolicy::FromInsert, + context, + visible, + leaf, + ); stack.pop(); } _ => { @@ -518,13 +563,13 @@ impl<'a> RenderSceneGraph<'a> { fn walk_owned_content( &self, entity: &EntityType, - context: &RenderContext, + context: &InstanceContext, visible: &mut V, leaf: &mut F, stack: &mut Vec, ) where - V: FnMut(&EntityType, &RenderContext) -> bool, - F: FnMut(&EntityType, &RenderContext), + V: FnMut(&EntityType, &InstanceContext) -> bool, + F: FnMut(&EntityType, &InstanceContext), { for block_use in entity_render_block_uses(self.document, entity, self.annotation_scale) .into_iter() @@ -537,7 +582,13 @@ impl<'a> RenderSceneGraph<'a> { continue; } stack.push(block_use.insert.block_name.clone()); - self.walk_insert_instances(&block_use.insert, context, visible, leaf); + self.walk_insert_instances( + &block_use.insert, + block_use.scale_policy, + context, + visible, + leaf, + ); stack.pop(); } } @@ -595,9 +646,24 @@ pub fn insert_transform_at_scale( document: &CadDocument, insert: &Insert, annotation_scale: f32, +) -> Transform { + insert_transform_with_policy( + document, + insert, + annotation_scale, + BlockScalePolicy::FromInsert, + ) +} + +pub fn insert_transform_with_policy( + document: &CadDocument, + insert: &Insert, + annotation_scale: f32, + scale_policy: BlockScalePolicy, ) -> Transform { let mut transform = insert_transform(document, insert); - if (annotation_scale - 1.0).abs() > 1.0e-6 + if scale_policy == BlockScalePolicy::FromInsert + && (annotation_scale - 1.0).abs() > 1.0e-6 && insert .common .extended_data @@ -615,7 +681,7 @@ pub fn insert_transform_at_scale( transform } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum BlockRole { Insert, DimensionPicture, @@ -631,6 +697,7 @@ pub struct BlockUse { pub block: Handle, pub insert: Insert, pub role: BlockRole, + pub scale_policy: BlockScalePolicy, /// Whether this edge belongs to the active render representation. Inactive /// edges remain dependencies so cache invalidation and purge stay complete. pub active: bool, @@ -654,6 +721,7 @@ fn block_use( document: &CadDocument, mut insert: Insert, role: BlockRole, + scale_policy: BlockScalePolicy, active: bool, replaces_host_wire: bool, suppress_root_points: bool, @@ -667,6 +735,7 @@ fn block_use( block, insert, role, + scale_policy, active, replaces_host_wire, suppress_root_points, @@ -687,6 +756,7 @@ pub fn block_use_from_handle( document, Insert::new(record.name.clone(), insertion), role, + BlockScalePolicy::Applied, true, false, false, @@ -727,6 +797,7 @@ fn entity_owned_block_uses( document, insert.clone(), BlockRole::Insert, + BlockScalePolicy::FromInsert, true, true, false, @@ -743,6 +814,7 @@ fn entity_owned_block_uses( document, insert, BlockRole::DimensionPicture, + BlockScalePolicy::FromInsert, picture, picture, true, @@ -771,6 +843,7 @@ fn entity_owned_block_uses( document, insert, BlockRole::TablePicture, + BlockScalePolicy::FromInsert, true, true, false, @@ -780,15 +853,12 @@ fn entity_owned_block_uses( uses.extend( crate::entities::table::block_cell_inserts(table, document, annotation_scale) .into_iter() - .map(|mut insert| { - insert - .common - .extended_data - .remove_record("AcAnnotativeData"); + .map(|insert| { block_use( document, insert, BlockRole::TableCell, + BlockScalePolicy::Applied, cells_active, false, false, @@ -799,15 +869,12 @@ fn entity_owned_block_uses( } EntityType::MultiLeader(multileader) => { crate::entities::multileader::block_content_insert(document, multileader) - .map(|mut insert| { - insert - .common - .extended_data - .remove_record("AcAnnotativeData"); + .map(|insert| { vec![block_use( document, insert, BlockRole::MultiLeaderContent, + BlockScalePolicy::Applied, true, false, false, @@ -997,8 +1064,9 @@ pub fn insert_instance_transform( insert: &Insert, offset: [f64; 3], annotation_scale: f32, + scale_policy: BlockScalePolicy, ) -> Transform { - let transform = insert_transform_at_scale(document, insert, annotation_scale); + let transform = insert_transform_with_policy(document, insert, annotation_scale, scale_policy); if offset == [0.0; 3] { transform } else { @@ -1007,6 +1075,32 @@ pub fn insert_instance_transform( } } +pub fn insert_instance_translation_delta( + document: &CadDocument, + insert: &Insert, + offset: [f64; 3], + annotation_scale: f32, + scale_policy: BlockScalePolicy, +) -> Vector3 { + let base = insert_instance_transform( + document, + insert, + [0.0; 3], + annotation_scale, + scale_policy, + ) + .apply(Vector3::ZERO); + let instance = insert_instance_transform( + document, + insert, + offset, + annotation_scale, + scale_policy, + ) + .apply(Vector3::ZERO); + instance - base +} + pub fn block_contains_hatch( document: &CadDocument, block_name: &str, @@ -1035,3 +1129,38 @@ pub fn block_contains_hatch( memo.insert(key, contains); contains } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::xdata::ExtendedDataRecord; + + #[test] + fn applied_block_scale_skips_annotative_rescaling() { + let document = CadDocument::new(); + let mut insert = Insert::new("BLOCK", Vector3::new(10.0, 20.0, 0.0)); + insert + .common + .extended_data + .add_record(ExtendedDataRecord::new("AcAnnotativeData")); + let point = Vector3::new(1.0, 0.0, 0.0); + + let from_insert = insert_transform_with_policy( + &document, + &insert, + 2.0, + BlockScalePolicy::FromInsert, + ) + .apply(point); + let applied = insert_transform_with_policy( + &document, + &insert, + 2.0, + BlockScalePolicy::Applied, + ) + .apply(point); + + assert!((from_insert.x - 12.0).abs() < 1.0e-9); + assert!((applied.x - 11.0).abs() < 1.0e-9); + } +} diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs index eb741373c..0209a3b82 100644 --- a/tests/text_font_rendering.rs +++ b/tests/text_font_rendering.rs @@ -59,6 +59,7 @@ fn expand_block_mtext( &Default::default(), ); expand_insert( + &doc, &cache, &ins, Handle::new(999), @@ -86,6 +87,8 @@ fn expand_block_mtext( // Annotation scale: 1.0 = no annotative scaling, matching the // `BlockCache::build(&doc, 1.0, ...)` call above. 1.0, + OpenCADStudio::scene::BlockScalePolicy::FromInsert, + false, ) .expect("block defn is cached") } From 8f5bffd74a629d1e2e080bd90353dd193510fb80 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 1 Sep 2026 22:51:42 +0300 Subject: [PATCH 9/9] fix(pyramid): preserve displayed construction values --- src/modules/model/primitive_cmd.rs | 25 ++++++++++++-- src/scene/model/solid_history.rs | 54 ++++++++++++++++-------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index c0bfcdc6a..c862fe8e9 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -1058,6 +1058,17 @@ impl PrimitiveCommand { } } + fn set_pyramid_type(&mut self, kind: PyramidType) { + let displayed_top = pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); + self.pyramid_type = kind; + self.pyramid_top_radius = + pyramid_display_to_circumradius(displayed_top, kind, self.pyramid_sides); + } + fn pyramid_set_base_from_point(&mut self, point: DVec3) -> bool { let Some(frame) = self.pyramid_frame else { return false; @@ -1410,11 +1421,11 @@ impl PrimitiveCommand { return Some(CmdResult::NeedPoint); } PyramidStep::BaseRadius if matches!(upper.as_str(), "I" | "INSCRIBED") => { - self.pyramid_type = PyramidType::Inscribed; + self.set_pyramid_type(PyramidType::Inscribed); return Some(CmdResult::NeedPoint); } PyramidStep::BaseRadius if matches!(upper.as_str(), "C" | "CIRCUMSCRIBED") => { - self.pyramid_type = PyramidType::Circumscribed; + self.set_pyramid_type(PyramidType::Circumscribed); return Some(CmdResult::NeedPoint); } PyramidStep::Height => { @@ -1445,7 +1456,17 @@ impl PrimitiveCommand { if !(3..=32).contains(&sides) { return None; } + let displayed_top = pyramid_circumradius_to_display( + self.pyramid_top_radius, + self.pyramid_type, + self.pyramid_sides, + ); self.pyramid_sides = sides; + self.pyramid_top_radius = pyramid_display_to_circumradius( + displayed_top, + self.pyramid_type, + self.pyramid_sides, + ); self.pyramid_defaults.sides = sides; self.pyramid_step = PyramidStep::BaseCenter; return Some(CmdResult::NeedPoint); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 409ba1b04..a0b5a1879 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -983,11 +983,11 @@ fn apply_pyramid_geometry_property( } else { value.top_radius * factor }; - value.radius = if next_inscribed { base_display } else { base_display / factor }; - value.top_radius = if next_inscribed { top_display } else { top_display / factor }; let Some(current) = matrix(value.base.transform) else { return Some(false); }; + value.radius = if next_inscribed { base_display } else { base_display / factor }; + value.top_radius = if next_inscribed { top_display } else { top_display / factor }; let half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; let delta = if next_inscribed { half } else { -half }; value.base.transform = @@ -1049,6 +1049,27 @@ fn apply_pyramid_geometry_property( Some(true) } +fn set_pyramid_sides(value: &mut SolidHistoryPyramid, sides: i32) -> bool { + if !(3..=32).contains(&sides) || sides == value.sides { + return false; + } + if !pyramid_is_inscribed(value) { + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let old_factor = pyramid_apothem_factor(value.sides); + let new_factor = pyramid_apothem_factor(sides); + value.radius = value.radius * old_factor / new_factor; + value.top_radius = value.top_radius * old_factor / new_factor; + let old_half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; + let new_half = std::f64::consts::PI / sides as f64; + value.base.transform = + (current * glam::DMat4::from_rotation_z(old_half - new_half)).to_cols_array(); + } + value.sides = sides; + true +} + pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, @@ -1261,28 +1282,7 @@ pub fn apply_primitive_property( if (number - rounded).abs() > 1e-9 { return false; } - let sides = rounded as i32; - if !(3..=32).contains(&sides) { - return false; - } - if sides == value.sides { - return false; - } - if !pyramid_is_inscribed(value) { - let old_factor = pyramid_apothem_factor(value.sides); - let new_factor = pyramid_apothem_factor(sides); - value.radius = value.radius * old_factor / new_factor; - value.top_radius = value.top_radius * old_factor / new_factor; - let Some(current) = matrix(value.base.transform) else { - return false; - }; - let old_half = std::f64::consts::PI / value.sides.clamp(3, 32) as f64; - let new_half = std::f64::consts::PI / sides as f64; - value.base.transform = (current - * glam::DMat4::from_rotation_z(old_half - new_half)) - .to_cols_array(); - } - value.sides = sides; + return set_pyramid_sides(value, rounded as i32); } _ => return false, }, @@ -1756,8 +1756,10 @@ pub fn apply_primitive_grip( GRIP_HEIGHT => value.height = local.z.max(1e-6), GRIP_SIDES => { let angle = local.y.atan2(local.x).rem_euclid(std::f64::consts::TAU); - value.sides = (angle.to_degrees() / 5.0).round() as i32; - value.sides = value.sides.clamp(3, 32); + let sides = ((angle.to_degrees() / 5.0).round() as i32).clamp(3, 32); + if !set_pyramid_sides(value, sides) { + return false; + } } _ => return false, },