Merge remote-tracking branch 'origin/main' into merge-test/main-into-constraints

# Conflicts:
#	src/app/command_driver.rs
#	src/command.rs
#	src/ui/ribbon/mod.rs
#	src/ui/window/options.rs
This commit is contained in:
Felix Riestra committed 2026-09-10 23:34:50 +02:00
commit ad718def3f
124 files changed
+9866 -692

No files matched your search

+4 -2
View File
@@ -24,7 +24,8 @@ pub fn tessellate_sat(
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
) -> Option<MeshLodSet> {
let (bodies, loss) = lift(document);
if bodies.is_empty() {
@@ -114,7 +115,8 @@ pub fn tessellate_sat(
max_angle,
source_fit * placement_scale,
)
.with_isolines(isolines);
.with_uv_isolines(isolines[0], isolines[1])
.with_planar_isolines(planar_isolines);
if let Some(deflection) = chordal_deflection {
tolerance = tolerance.with_chordal_deflection(deflection);
}
+15 -6
View File
@@ -10,7 +10,8 @@ fn tessellate_acis(
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
) -> Option<MeshLodSet> {
crate::scene::convert::acis_kernel::tessellate_sat(
sat,
@@ -19,6 +20,7 @@ fn tessellate_acis(
facet_res,
chordal_deflection,
isolines,
planar_isolines,
)
}
@@ -218,7 +220,8 @@ fn finish(
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
acis: &acadrust::entities::AcisData,
) -> Option<MeshLodSet> {
let mut set = tessellate_acis(
@@ -228,6 +231,7 @@ fn finish(
facet_res,
chordal_deflection,
isolines,
planar_isolines,
)?;
remap_acis_material_bindings(&mut set, acis);
Some(set)
@@ -251,7 +255,8 @@ pub fn tessellate_region(
color,
facet_res,
chordal_deflection,
isolines,
[isolines; 2],
false,
&region.acis_data,
)
}
@@ -274,7 +279,8 @@ pub fn tessellate_body(
color,
facet_res,
chordal_deflection,
isolines,
[isolines; 2],
false,
&body.acis_data,
)
}
@@ -284,7 +290,8 @@ pub fn tessellate_surface(
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
) -> Option<MeshLodSet> {
let sat = parse_acis(
|| surface.parse_sat(),
@@ -298,6 +305,7 @@ pub fn tessellate_surface(
facet_res,
chordal_deflection,
isolines,
planar_isolines,
&surface.acis_data,
)
}
@@ -320,7 +328,8 @@ pub fn tessellate_solid3d(
color,
facet_res,
chordal_deflection,
isolines,
[isolines; 2],
false,
&solid.acis_data,
)
}
+286 -44
View File
@@ -215,17 +215,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> {
(0..seg_count)
.map(|i| {
let v = &p.vertices[i];
let sw = if v.start_width > 1e-9 {
v.start_width
let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 {
(v.start_width, v.end_width)
} else {
c
} as f32;
let ew = if v.end_width > 1e-9 {
v.end_width
} else {
c
} as f32;
(sw, ew)
(c, c)
};
(sw as f32, ew as f32)
})
.collect()
}
@@ -244,17 +239,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> {
(0..seg_count)
.map(|i| {
let v = &verts[i];
let sw = if v.start_width > 1e-9 {
v.start_width
let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 {
(v.start_width, v.end_width)
} else {
def_start
} as f32;
let ew = if v.end_width > 1e-9 {
v.end_width
} else {
def_end
} as f32;
(sw, ew)
(def_start, def_end)
};
(sw as f32, ew as f32)
})
.collect()
}
@@ -292,7 +282,53 @@ fn split_mixed_polyline(
end_angle,
} = *tg
{
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
(w0, w1)
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
};
// Check if the CCW start_angle of this TangentGeom::Arc corresponds
// to the segment's end vertex (clockwise bulge arc in CAD where
// bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW).
let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() {
(key_vertices[i], key_vertices[i + 1])
} else if !key_vertices.is_empty() {
(
key_vertices[i % key_vertices.len()],
key_vertices[(i + 1) % key_vertices.len()],
)
} else {
([0.0; 3], [0.0; 3])
};
let p_sa = [
center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]),
center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]),
center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]),
];
let is_reversed = if !key_vertices.is_empty() {
let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2)
+ (p_sa[1] - p_seg_start[1]).powi(2)
+ (p_sa[2] - p_seg_start[2]).powi(2);
let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2)
+ (p_sa[1] - p_seg_end[1]).powi(2)
+ (p_sa[2] - p_seg_end[2]).powi(2);
d_end_sq < d_start_sq
} else {
false
};
let (w_at_sa, w_at_ea) = if is_reversed {
(ew, sw)
} else {
(sw, ew)
};
let mut arc_pts = Vec::with_capacity(17);
let mut arc_widths = Vec::with_capacity(17);
let n = 16;
let sweep = if end_angle >= start_angle {
end_angle - start_angle
@@ -308,25 +344,12 @@ fn split_mixed_polyline(
center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]),
];
arc_pts.push(p);
arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32);
}
let (points, points_low) = points_to_ds(arc_pts);
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
if w0 > 1e-9 || w1 > 1e-9 {
(w0, w1)
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
}
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
};
let taper_widths = if (sw - ew).abs() > 1e-6 {
vec![sw, ew]
let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 {
arc_widths
} else {
Vec::new()
};
@@ -377,43 +400,77 @@ fn split_mixed_polyline(
// Collect straight lines into a line wire
let mut straight_pts: Vec<[f64; 3]> = Vec::new();
let mut straight_widths: Vec<f32> = Vec::new();
let mut straight_tangents: Vec<TangentGeom> = Vec::new();
let mut last_end: Option<[f64; 3]> = None;
let mut last_end_w: f32 = 0.0;
for (i, tg) in tangent_geoms.iter().enumerate() {
if let TangentGeom::Line { p1, p2 } = tg {
straight_tangents.push(tg.clone());
let (p_start, p_end) = if i + 1 < key_vertices.len() {
(key_vertices[i], key_vertices[i + 1])
let p_start = if i + 1 < key_vertices.len()
&& (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3
&& (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3
&& (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3
{
key_vertices[i]
} else {
(
[p1[0] as f64, p1[1] as f64, p1[2] as f64],
[p2[0] as f64, p2[1] as f64, p2[2] as f64],
)
[p1[0] as f64, p1[1] as f64, p1[2] as f64]
};
let p_end = if i + 1 < key_vertices.len()
&& (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3
&& (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3
&& (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3
{
key_vertices[i + 1]
} else {
[p2[0] as f64, p2[1] as f64, p2[2] as f64]
};
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
(w0, w1)
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
};
if !plinegen {
if !straight_pts.is_empty() {
straight_pts.push([f64::NAN; 3]);
straight_widths.push(0.0);
}
straight_pts.push(p_start);
straight_widths.push(sw);
straight_pts.push(p_end);
straight_widths.push(ew);
} else {
if let Some(prev) = last_end {
if (prev[0] - p_start[0]).abs() < 1e-7
&& (prev[1] - p_start[1]).abs() < 1e-7
&& (prev[2] - p_start[2]).abs() < 1e-7
&& (last_end_w - sw).abs() < 1e-6
{
straight_pts.push(p_end);
straight_widths.push(ew);
} else {
straight_pts.push([f64::NAN; 3]);
straight_widths.push(0.0);
straight_pts.push(p_start);
straight_widths.push(sw);
straight_pts.push(p_end);
straight_widths.push(ew);
}
} else {
if !straight_pts.is_empty() {
straight_pts.push([f64::NAN; 3]);
straight_widths.push(0.0);
}
straight_pts.push(p_start);
straight_widths.push(sw);
straight_pts.push(p_end);
straight_widths.push(ew);
}
last_end = Some(p_end);
last_end_w = ew;
}
} else {
last_end = None;
@@ -427,12 +484,24 @@ fn split_mixed_polyline(
} else {
(Vec::new(), Vec::new())
};
let line_world_width = straight_widths
.iter()
.copied()
.fold(0.0f32, f32::max);
let has_line_taper = straight_widths
.first()
.map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6));
let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) {
straight_widths
} else {
Vec::new()
};
out.push(WireModel {
bg_adapt: None,
point_marker,
taper_widths: Vec::new(),
taper_widths,
pattern_stations: Vec::new(),
world_width: global_world_width,
world_width: line_world_width,
depth_override: None,
display_visible: true,
plot_visible: true,
@@ -2989,3 +3058,176 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
v.normalize()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_mixed_polyline_straight_lines_have_taper_widths() {
let tg_line = TangentGeom::Line {
p1: [0.0, 0.0, 0.0],
p2: [10.0, 0.0, 0.0],
};
let tg_arc = TangentGeom::Arc {
center: [10.0, 5.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: -std::f64::consts::FRAC_PI_2,
end_angle: 0.0,
};
let tgs = vec![tg_line, tg_arc];
let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]];
let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)];
let wires = split_mixed_polyline(
&tgs,
&kv,
"mixed",
[1.0, 1.0, 1.0, 1.0],
false,
0.0,
[0.0; 8],
1.0,
Vec::new(),
None,
true,
&seg_widths,
20.0,
Vec::new(),
Vec::new(),
);
// Arc wire + Line wire
assert_eq!(wires.len(), 2);
let arc_wire = &wires[0];
assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0));
assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0));
let line_wire = &wires[1];
assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]);
assert_eq!(line_wire.world_width, 10.0);
}
#[test]
fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() {
let tg_line = TangentGeom::Line {
p1: [0.0, 0.0, 0.0],
p2: [10.0, 0.0, 0.0],
};
let tg_arc1 = TangentGeom::Arc {
center: [10.0, 5.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: -std::f64::consts::FRAC_PI_2,
end_angle: 0.0,
};
let tg_arc2 = TangentGeom::Arc {
center: [15.0, 10.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: -std::f64::consts::FRAC_PI_2,
end_angle: 0.0,
};
let tgs = vec![tg_line, tg_arc1, tg_arc2];
let kv = vec![
[0.0, 0.0, 0.0],
[10.0, 0.0, 0.0],
[15.0, 5.0, 0.0],
[20.0, 10.0, 0.0],
];
// Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0)
let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)];
let wires = split_mixed_polyline(
&tgs,
&kv,
"taper_then_zero",
[1.0, 1.0, 1.0, 1.0],
false,
0.0,
[0.0; 8],
1.0,
Vec::new(),
None,
true,
&seg_widths,
2.0,
Vec::new(),
Vec::new(),
);
// 2 arc wires + 1 line wire = 3 wires
assert_eq!(wires.len(), 3);
let arc1 = &wires[0];
let arc2 = &wires[1];
let line = &wires[2];
// Arcs must have world_width = 0.0 and empty taper_widths
assert_eq!(arc1.world_width, 0.0);
assert!(arc1.taper_widths.is_empty());
assert_eq!(arc2.world_width, 0.0);
assert!(arc2.taper_widths.is_empty());
let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0)
.expect("arc1 instance");
assert_eq!(inst1.start_width, 0.0);
assert_eq!(inst1.params[3], 0.0);
// Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0]
assert_eq!(line.world_width, 2.0);
assert_eq!(line.taper_widths, vec![2.0, 0.0]);
}
#[test]
fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() {
// Clockwise arc from (10.0, 0.0) to (15.0, 5.0).
// bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0).
let tg_arc_reversed = TangentGeom::Arc {
center: [10.0, 5.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: 0.0, // at (15.0, 5.0) - physical end!
end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start!
};
let tgs = vec![tg_arc_reversed];
let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]];
let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end
let wires = split_mixed_polyline(
&tgs,
&kv,
"arc_rev",
[1.0, 1.0, 1.0, 1.0],
false,
0.0,
[0.0; 8],
1.0,
Vec::new(),
None,
true,
&seg_widths,
25.0,
Vec::new(),
Vec::new(),
);
assert_eq!(wires.len(), 1);
let arc_wire = &wires[0];
// In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0).
// end_angle is at (10.0, 0.0) which is physical start (sw = 5.0).
assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0));
assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0));
let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0)
.expect("arc instance extracted");
// start_width is at start_angle (physical end, 25.0)
assert_eq!(inst.start_width, 25.0);
// params.w (end_width) is at end_angle (physical start, 5.0)
assert_eq!(inst.params[3], 5.0);
}
}
+98 -6
View File
@@ -3159,7 +3159,13 @@ impl Scene {
let facet_resolution = self.document.header.facet_resolution;
let chordal_deflection =
crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution);
let isolines = self.document.header.isolines.max(0) as usize;
let (isolines, planar_isolines) = match self.document.get_entity(handle) {
Some(EntityType::Surface(surface)) => (
crate::entities::solid3d::surface_isoline_counts(surface),
crate::entities::solid3d::surface_property_state(surface).isolines,
),
_ => ([self.document.header.isolines.max(0) as usize; 2], false),
};
let (mut set, wires, center) =
crate::scene::model::solid_model::display_from_solid(
solid,
@@ -3167,6 +3173,7 @@ impl Scene {
facet_resolution,
chordal_deflection,
isolines,
planar_isolines,
)?;
let name = handle.value().to_string();
for mesh in &mut set.lods {
@@ -5487,8 +5494,12 @@ impl Scene {
// changed entities into it, instead of re-cloning + re-sorting the whole
// set. Falls back to the full build below when it can't (no cache entry,
// journal un-replayable, or a structural assumption violated).
if let Some(arc) =
self.try_resident_patch(
// A live section changes the displayed wires of every intersected
// solid, even when the delta journal contains only the section entity.
// Rebuild the resident set in that case so an incremental patch cannot
// retain the source solid's uncut edge cache.
if self.active_live_section(block).is_none() {
if let Some(arc) = self.try_resident_patch(
key,
block,
bg,
@@ -5497,9 +5508,9 @@ impl Scene {
all_visible,
frozen_layers,
style_viewport,
)
{
return arc;
) {
return arc;
}
}
// Build once: full tessellation, no cull, no zoom LOD — the resident
// set is zoom-independent (GPU analytical circles/arcs/ellipses).
@@ -7073,6 +7084,48 @@ impl Scene {
Self::section_body(&body, &section.data, section.slice_depth, None)
}
/// Build a temporary entity whose wire data matches the live-section body.
/// The document entity stays unchanged so disabling the section restores
/// the complete source geometry and saving never persists the clipped body.
fn sectioned_wire_entity(
&self,
handle: Handle,
section: &LiveSection,
) -> Result<Option<EntityType>, ()> {
let Some(body) = self.sectioned_body(handle, section)? else {
return Ok(None);
};
let (_, wires, center) = self
.prepare_solid_model_display(handle, &body)
.ok_or(())?;
let mut entity = self.document.get_entity(handle).cloned().ok_or(())?;
let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]);
match &mut entity {
EntityType::Solid3D(solid) => {
solid.point_of_reference = reference;
solid.wires = wires;
solid.silhouettes.clear();
}
EntityType::Surface(surface) => {
surface.point_of_reference = reference;
surface.wires = wires;
surface.silhouettes.clear();
}
EntityType::Region(region) => {
region.point_of_reference = reference;
region.wires = wires;
region.silhouettes.clear();
}
EntityType::Body(body) => {
body.point_of_reference = reference;
body.wires = wires;
body.silhouettes.clear();
}
_ => return Err(()),
}
Ok(Some(entity))
}
fn section_source_body(
&self,
handle: Handle,
@@ -9786,6 +9839,45 @@ impl Scene {
memo_misses = visible_count;
out
};
// Normal Wireframe renders the persisted entity wires, whereas shaded
// and 3D Wireframe modes render the temporary sectioned mesh. Replace
// only the displayed wire run with one tessellated from that same
// temporary body so every visual style shows the identical live cut.
if let Some(section) = self.active_live_section(block_handle) {
let handles: Vec<Handle> = self.meshes.keys().copied().collect();
for handle in handles {
let Some(source) = self.document.get_entity(handle) else {
continue;
};
if !visibility_ok(source) {
continue;
}
let replacement = match self.sectioned_wire_entity(handle, &section) {
Ok(Some(entity)) => Some(tessellate_entity(
doc,
sel,
avp,
bg,
anno,
annotation_scale_handle,
&entity,
Some(blk_ref),
view_aabb,
wpp,
paper,
)),
Ok(None) => Some(Vec::new()),
Err(()) => None,
};
let Some(replacement) = replacement else {
continue;
};
let name = handle.value().to_string();
wires.retain(|wire| wire.name != name);
wires.extend(replacement);
}
}
let build_ms = crate::perf::elapsed_ms(t_build);
// Resolve display colours for the live background. Memo hits were
+6
View File
@@ -120,6 +120,8 @@ pub enum GripShape {
Circle,
/// Screen-offset menu selector.
Dropdown,
/// Menu selector placed immediately beside its anchor grip.
DropdownAdjacent,
}
/// Describes one grip point for an entity.
@@ -202,6 +204,10 @@ pub enum GripMenuAction {
RefineVertices,
ShowFit,
ShowControlVertices,
SectionPlane,
SectionSlice,
SectionBoundary,
SectionVolume,
MoveWithText,
StackText,
UnstackText,
+195 -19
View File
@@ -94,6 +94,14 @@ pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed";
pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic";
pub const PROP_HISTORY: &str = "solid_history_record";
pub const PROP_SHOW_HISTORY: &str = "solid_history_show";
pub const PROP_SURFACE_TYPE: &str = "surface_type";
pub const PROP_SURFACE_WIREFRAME_TYPE: &str = "srf_wireframe_type";
pub const PROP_SURFACE_U_ISOLINES: &str = "srf_u_isolines";
pub const PROP_SURFACE_V_ISOLINES: &str = "srf_v_isolines";
pub const PROP_SURFACE_MAINTAIN_ASSOCIATIVITY: &str = "srf_maintain_associativity";
pub const PROP_SURFACE_SHOW_ASSOCIATIVITY: &str = "srf_show_associativity";
pub const PROP_SURFACE_TRIMMED: &str = "srf_trimmed";
pub const PROP_SURFACE_TRIMMING_EDGES: &str = "srf_trimming_edges";
fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Property {
Property {
@@ -103,6 +111,23 @@ fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Prope
}
}
fn compact_surface_number(mut value: String) -> String {
if value.ends_with('°') {
value.pop();
}
if let Some(decimal) = value.rfind('.') {
if value[decimal + 1..].chars().all(|character| character.is_ascii_digit()) {
while value.ends_with('0') {
value.pop();
}
if value.ends_with('.') {
value.pop();
}
}
}
value
}
fn history_flags(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
@@ -239,9 +264,14 @@ pub fn has_compact_solid_properties(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
) -> bool {
document
.get_entity(handle)
.is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_)))
match document.get_entity(handle) {
Some(acadrust::EntityType::Solid3D(_)) => true,
Some(acadrust::EntityType::Surface(_)) => matches!(
primitive_property_operation(document, handle),
Some(SolidHistoryOperation::Extrusion(_))
),
_ => false,
}
}
pub fn reference_point(operation: &SolidHistoryOperation) -> Option<glam::DVec3> {
@@ -403,10 +433,6 @@ fn extrusion_properties(
|direction| crate::entities::common::format_length(direction[axis]),
)),
};
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 path_length = sweep_path_length(value);
let height_value = path_length.unwrap_or(value.end_draft_distance);
let height = if value.path_entity.is_some() {
@@ -419,6 +445,134 @@ fn extrusion_properties(
} else {
PropValue::EditText(crate::entities::common::format_angle(value.draft_angle))
};
if let Some(EntityType::Surface(surface)) = document.get_entity(handle) {
let state = crate::entities::solid3d::surface_property_state(surface);
let surface_height = if value.path_entity.is_some() {
PropValue::ReadOnly(compact_surface_number(
crate::entities::common::format_length(height_value),
))
} else {
PropValue::EditText(compact_surface_number(
crate::entities::common::format_length(height_value),
))
};
let surface_taper = if value.path_entity.is_some() {
PropValue::ReadOnly(compact_surface_number(
crate::entities::common::format_angle(value.draft_angle),
))
} else {
PropValue::EditText(compact_surface_number(
crate::entities::common::format_angle(value.draft_angle),
))
};
let surface_direction_property =
|label: &str, field: &'static str, axis: usize| Property {
label: label.to_string(),
field,
value: PropValue::ReadOnly(direction.map_or_else(
|| t!("Unavailable").into_owned(),
|direction| {
compact_surface_number(crate::entities::common::format_length(
direction[axis],
))
},
)),
};
let yes_no_choice = |selected: bool| PropValue::Choice {
selected: if selected { "Yes" } else { "No" }.to_string(),
options: vec!["Yes".to_string(), "No".to_string()],
};
return vec![
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
Property {
label: t!("Surface Type").into_owned(),
field: PROP_SURFACE_TYPE,
value: PropValue::ReadOnly(t!("Extrusion").into_owned()),
},
Property {
label: t!("Height").into_owned(),
field: PROP_EXTRUSION_HEIGHT,
value: surface_height,
},
Property {
label: t!("Taper angle").into_owned(),
field: PROP_TAPER_ANGLE,
value: surface_taper,
},
surface_direction_property(
t!("Direction X").as_ref(),
PROP_EXTRUSION_DIRECTION_X,
0,
),
surface_direction_property(
t!("Direction Y").as_ref(),
PROP_EXTRUSION_DIRECTION_Y,
1,
),
surface_direction_property(
t!("Direction Z").as_ref(),
PROP_EXTRUSION_DIRECTION_Z,
2,
),
Property {
label: t!("Wireframe type").into_owned(),
field: PROP_SURFACE_WIREFRAME_TYPE,
value: PropValue::Choice {
selected: if state.isolines { "Isolines" } else { "Isoparms" }
.to_string(),
options: vec!["Isolines".to_string(), "Isoparms".to_string()],
},
},
Property {
label: t!("U isolines").into_owned(),
field: PROP_SURFACE_U_ISOLINES,
value: PropValue::EditText(surface.u_isolines.max(0).to_string()),
},
Property {
label: t!("V isolines").into_owned(),
field: PROP_SURFACE_V_ISOLINES,
value: PropValue::EditText(surface.v_isolines.max(0).to_string()),
},
],
},
PropSection {
title: t!("Surface Associativity").into_owned(),
props: vec![
Property {
label: t!("Maintain associativity").into_owned(),
field: PROP_SURFACE_MAINTAIN_ASSOCIATIVITY,
value: yes_no_choice(state.maintain_associativity),
},
Property {
label: t!("Show associativity").into_owned(),
field: PROP_SURFACE_SHOW_ASSOCIATIVITY,
value: yes_no_choice(state.show_associativity),
},
],
},
PropSection {
title: t!("Trims").into_owned(),
props: vec![
Property {
label: t!("Trimmed surface").into_owned(),
field: PROP_SURFACE_TRIMMED,
value: PropValue::ReadOnly("No".to_string()),
},
Property {
label: t!("Trimming edges").into_owned(),
field: PROP_SURFACE_TRIMMING_EDGES,
value: PropValue::ReadOnly("0".to_string()),
},
],
},
];
}
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);
vec![
PropSection {
title: t!("Geometry").into_owned(),
@@ -1531,6 +1685,15 @@ pub fn is_history_choice(field: &str) -> bool {
matches!(field, PROP_HISTORY | PROP_SHOW_HISTORY)
}
pub fn is_surface_property_choice(field: &str) -> bool {
matches!(
field,
PROP_SURFACE_WIREFRAME_TYPE
| PROP_SURFACE_MAINTAIN_ASSOCIATIVITY
| PROP_SURFACE_SHOW_ASSOCIATIVITY
)
}
pub fn is_loft_geometry_choice(field: &str) -> bool {
matches!(field, PROP_LOFT_NORMALS | PROP_LOFT_CLOSED | PROP_LOFT_PERIODIC)
}
@@ -1538,7 +1701,11 @@ pub fn is_loft_geometry_choice(field: &str) -> bool {
pub fn is_specialized_property(field: &str) -> bool {
matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH
| PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT
| PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z)
| PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z
| PROP_SURFACE_TYPE | PROP_SURFACE_WIREFRAME_TYPE
| PROP_SURFACE_U_ISOLINES | PROP_SURFACE_V_ISOLINES
| PROP_SURFACE_MAINTAIN_ASSOCIATIVITY | PROP_SURFACE_SHOW_ASSOCIATIVITY
| PROP_SURFACE_TRIMMED | PROP_SURFACE_TRIMMING_EDGES)
|| is_primitive_property(field)
|| is_history_choice(field)
}
@@ -3018,11 +3185,6 @@ fn extrusion_draft_grip(
if !inverse.is_finite() {
return None;
}
let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?;
let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?;
let normal = transform
.transform_vector3(glam::DVec3::from_array(normal))
.try_normalize()?;
let center_local = inverse.transform_point3(center);
let anchor = profile_grips
.into_iter()
@@ -3057,7 +3219,7 @@ fn extrusion_draft_grip(
value.direction.y,
value.direction.z,
));
let height = direction.dot(normal).abs();
let height = direction.length();
if !direction.is_finite() || !height.is_finite() || height <= 1e-6 {
return None;
}
@@ -3307,6 +3469,7 @@ pub fn primitive_grips(
if let SolidHistoryOperation::Chamfer(value) = operation {
return chamfer_distance_grips(document, handle, value);
}
let is_surface = matches!(document.get_entity(handle), Some(EntityType::Surface(_)));
let mut grips = Vec::new();
let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| {
if let Some(world) = world_point(transform, point) {
@@ -3528,27 +3691,40 @@ pub fn primitive_grips(
}
SolidHistoryOperation::Extrusion(value) => {
let (profile_grips, profile_center) = extrusion_profile_grips(value);
grips.extend(profile_grips);
if !is_surface {
grips.extend(profile_grips);
}
let direction = [value.direction.x, value.direction.y, value.direction.z];
if value.path_entity.is_none() {
if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) {
let direction = base.transform_vector3(glam::DVec3::from_array(direction));
if direction.length_squared() > 1e-12 {
grips.push(grip(
let mut height_grip = grip(
GRIP_HEIGHT,
center + direction,
GripShape::Triangle,
Some(direction.normalize()),
));
);
if is_surface {
// Surface construction arrows keep one stable screen
// orientation while their world-space axis continues to
// constrain the edit.
height_grip.dir = None;
}
grips.push(height_grip);
}
}
if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) {
grips.push(grip(
let mut draft_grip = grip(
GRIP_DRAFT,
world,
GripShape::Triangle,
Some(radial),
));
);
if is_surface {
draft_grip.dir = None;
}
grips.push(draft_grip);
}
}
}
+53 -10
View File
@@ -22,7 +22,8 @@ fn display_tessellation(
body: &Body,
facet_resolution: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
) -> brep::mesh::BodyMesh {
let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 {
facet_resolution.clamp(0.01, 10.0)
@@ -34,7 +35,8 @@ fn display_tessellation(
|_| cadkernel::tessellation::display_angle_for_resolution(resolution),
);
let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL)
.with_isolines(isolines);
.with_uv_isolines(isolines[0], isolines[1])
.with_planar_isolines(planar_isolines);
if let Some(deflection) = chordal_deflection {
tolerance = tolerance.with_chordal_deflection(deflection);
}
@@ -267,20 +269,42 @@ pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
.collect()
}
/// White wireframe used while a solid-history grip is hot.
/// White wireframe used while a solid-history grip is hot. Surface previews
/// include the requested construction isolines so their cage follows the edit.
///
/// This deliberately does not touch the resident solid mesh or its entity
/// wires: the selected source stays visible in blue while the candidate body
/// is presented as a separate, non-pickable outline until placement.
pub fn grip_preview_wires(body: &Body, handle: acadrust::Handle) -> Vec<WireModel> {
tessellation(body)
pub fn grip_preview_wires(
body: &Body,
handle: acadrust::Handle,
isolines: [usize; 2],
planar_isolines: bool,
) -> Vec<WireModel> {
let tessellation = brep::mesh::tessellate(
body,
brep::mesh::TessellationTolerance::new(
cadkernel::tessellation::DEFAULT_ANGLE,
TOL,
)
.with_uv_isolines(isolines[0], isolines[1])
.with_planar_isolines(planar_isolines),
);
tessellation
.edges
.into_iter()
.filter(|edge| edge.positions.len() >= 2)
.map(|edge| {
.map(|edge| edge.positions)
.chain(
tessellation
.isolines
.into_iter()
.map(|isoline| isoline.positions),
)
.filter(|positions| positions.len() >= 2)
.map(|positions| {
WireModel::solid_f64(
format!("{}-GRIP-PREVIEW", handle.value()),
edge.positions,
positions,
WireModel::WHITE,
false,
)
@@ -453,7 +477,8 @@ pub fn display_from_solid(
color: [f32; 4],
facet_resolution: f64,
chordal_deflection: Option<f64>,
isolines: usize,
isolines: [usize; 2],
planar_isolines: bool,
) -> Option<(MeshLodSet, Vec<acadrust::entities::Wire>, [f64; 3])> {
use acadrust::types::Vector3;
let tessellation = display_tessellation(
@@ -461,6 +486,7 @@ pub fn display_from_solid(
facet_resolution,
chordal_deflection,
isolines,
planar_isolines,
);
let center = mesh_center(&tessellation.mesh)?;
let wires = tessellation
@@ -541,7 +567,7 @@ mod tests {
use super::*;
fn tri_count(body: &Body) -> usize {
display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0)
display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, [0; 2], false)
.map(|(m, _, _)| m.lods[0].indices.len() / 3)
.unwrap_or(0)
}
@@ -558,6 +584,23 @@ mod tests {
assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid");
}
#[test]
fn planar_display_uses_independent_isoline_counts() {
let body = box_solid([0.0; 3], 10.0, 10.0, 10.0).unwrap();
let display = |counts, planar| {
display_from_solid(&body, [0.7, 0.7, 0.7, 1.0], 1.0, None, counts, planar)
.unwrap()
.0
.edge_verts
.len()
};
let boundaries = display([0, 0], false);
assert_eq!(display([1, 0], true) - boundaries, 12);
assert_eq!(display([0, 2], true) - boundaries, 24);
assert_eq!(display([2, 2], false), boundaries);
}
#[test]
fn every_primitive_is_the_size_it_was_asked_for() {
// Triangle counts say a mesh exists; the volume says it is the right
+13 -1
View File
@@ -1046,9 +1046,21 @@ impl Scene {
{
return false;
}
let (isoline_counts, planar_isolines) = match self.document.get_entity(handle) {
Some(EntityType::Surface(surface)) => (
crate::entities::solid3d::surface_isoline_counts(surface),
crate::entities::solid3d::surface_property_state(surface).isolines,
),
_ => ([0; 2], false),
};
self.solid_history_preview_wires.insert(
handle,
crate::scene::model::solid_model::grip_preview_wires(&body, handle),
crate::scene::model::solid_model::grip_preview_wires(
&body,
handle,
isoline_counts,
planar_isolines,
),
);
true
}
+24 -3
View File
@@ -13,11 +13,18 @@ pub const GRIP_HALF_PX: f32 = 5.0;
/// Screen offset for dropdown selectors anchored to a vertex.
pub const GRIP_DROPDOWN_OFFSET_X_PX: f32 = 24.0;
pub const GRIP_DROPDOWN_OFFSET_Y_PX: f32 = 27.0;
pub const GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX: f32 = 12.0;
fn marker_screen_position(mut point: Point, shape: GripShape) -> Point {
if shape == GripShape::Dropdown {
point.x += GRIP_DROPDOWN_OFFSET_X_PX;
point.y += GRIP_DROPDOWN_OFFSET_Y_PX;
match shape {
GripShape::Dropdown => {
point.x += GRIP_DROPDOWN_OFFSET_X_PX;
point.y += GRIP_DROPDOWN_OFFSET_Y_PX;
}
GripShape::DropdownAdjacent => {
point.x += GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX;
}
_ => {}
}
point
}
@@ -47,6 +54,8 @@ pub struct GripEdit {
pub enum GripEditMode {
Stretch,
Lengthen,
Radius,
ArcLength,
}
#[derive(Clone, Debug)]
@@ -85,6 +94,18 @@ impl GripEdit {
edit.mode = GripEditMode::Lengthen;
edit
}
pub fn radius(handle: Handle, grip_id: usize, world: DVec3) -> Self {
let mut edit = Self::single(handle, grip_id, false, world);
edit.mode = GripEditMode::Radius;
edit
}
pub fn arc_length(handle: Handle, grip_id: usize, world: DVec3) -> Self {
let mut edit = Self::single(handle, grip_id, false, world);
edit.mode = GripEditMode::ArcLength;
edit
}
}
// ── Screen-space helpers ───────────────────────────────────────────────────
+1 -1
View File
@@ -258,7 +258,7 @@ pub fn extract_circle_instance_from_geom_indexed(
let (start_width, end_width) = if !wire.taper_widths.is_empty() {
if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 {
(wire.taper_widths[0], wire.taper_widths[1])
(wire.taper_widths[0], *wire.taper_widths.last().unwrap())
} else if wire.taper_widths.len() > geom_index {
let sw = wire.taper_widths[geom_index];
let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw);
+14 -12
View File
@@ -116,7 +116,7 @@ pub struct WireConst {
/// the quad by `world_half_width / world_per_pixel` so the band tracks zoom
/// in drawing units.
pub world_half_width: f32,
pub _pad1: f32,
pub is_tapered: f32,
pub _pad2: f32,
/// Point-marker origin as a double-single pair. `marker_normal_scale.w`
/// stores the viewport-height percentage; zero disables marker scaling.
@@ -592,7 +592,14 @@ pub(crate) fn emit_wire_packed(
let (dists, align_end, align_total) = wire_distances(wire);
let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire);
let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) };
let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 };
let is_tapered = !wire.taper_widths.is_empty();
let tw = |i: usize| -> f32 {
if is_tapered {
wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5
} else {
-1.0
}
};
let mut instances: Vec<PackedWireInstance> = Vec::with_capacity(seg_count);
for i in 0..seg_count {
let a = wire.points[i];
@@ -636,6 +643,7 @@ pub(crate) fn emit_wire_native(
) -> (Vec<WireInstance>, WireConst) {
let (dists, align_end, align_total) = wire_distances(wire);
let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire);
let is_tapered = !wire.taper_widths.is_empty();
let cst = WireConst {
color,
pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]],
@@ -646,7 +654,7 @@ pub(crate) fn emit_wire_native(
align_end,
align_total,
world_half_width: wire.world_width * 0.5,
_pad1: 0.0,
is_tapered: if is_tapered { 1.0 } else { 0.0 },
_pad2: 0.0,
marker_origin_high,
marker_origin_low,
@@ -658,18 +666,12 @@ pub(crate) fn emit_wire_native(
return (Vec::new(), cst);
}
let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) };
// Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing
// units and range out of the packed field; UNORM16 contributes only a
// relative error below 1/65535. Preserve zero as the existing constant
// width fallback sentinel.
let taper_ratio = |i: usize| -> u16 {
let width = wire.taper_widths.get(i).copied().unwrap_or(0.0);
if width <= 0.0 || wire.world_width <= 0.0 {
if !is_tapered || wire.world_width <= 0.0 {
0
} else {
((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32)
.round()
.max(1.0) as u16
let width = wire.taper_widths.get(i).copied().unwrap_or(0.0);
((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16
}
};
let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count);