From aee3397f5d04d8059430a133309fb0b2462d5929 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:33:09 +0300 Subject: [PATCH 1/2] fix(gradient): align properties and editing --- src/app/commands/draw.rs | 5 +- src/app/properties.rs | 6 +- src/entities/hatch.rs | 186 +++++++++++++----------- src/modules/draw/draw/hatch.rs | 56 +++---- src/scene/entity.rs | 74 ++++++++-- src/scene/model/hatch_model.rs | 66 +++++++++ src/scene/pipeline/hatch_gpu/storage.rs | 37 +++-- src/scene/pipeline/hatch_gpu/texture.rs | 43 +++--- src/scene/view/render.rs | 2 + 9 files changed, 309 insertions(+), 166 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index c26073815..c9ff44328 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -668,8 +668,9 @@ impl OpenCADStudio { "GRADIENT" => { use crate::modules::draw::draw::hatch::GradientCommand; - let outlines = self.tabs[i].scene.closed_outlines(); - let new_cmd = GradientCommand::new(outlines); + let outlines = self.tabs[i].scene.hatch_boundary_outlines(); + let boundary_sources = self.tabs[i].scene.hatch_boundary_sources(); + let new_cmd = GradientCommand::new(outlines, boundary_sources); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/properties.rs b/src/app/properties.rs index e4dc29f93..f3a5f1c15 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1222,7 +1222,8 @@ impl OpenCADStudio { acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_) | acadrust::EntityType::Insert(_) - | acadrust::EntityType::Leader(_) => Some(("annotative", None)), + | acadrust::EntityType::Leader(_) + | acadrust::EntityType::Hatch(_) => Some(("annotative", None)), acadrust::EntityType::MultiLeader(_) => { Some(("enable_annotation_scale", None)) } @@ -1265,7 +1266,8 @@ impl OpenCADStudio { }, ), acadrust::EntityType::Text(_) - | acadrust::EntityType::Insert(_) => set_row_value( + | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Hatch(_) => set_row_value( &mut sections, "annotative", crate::scene::model::object::PropValue::BoolToggle { diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index d0f2201a6..8a7f707bf 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -345,59 +345,66 @@ fn properties(h: &Hatch) -> Vec { if g.enabled { // ── Gradient fill ────────────────────────────────────────────────── let grad_type = if g.is_single_color { "One color" } else { "Two color" }; - let centered = if g.shift.abs() < 1e-9 { "Yes" } else { "No" }; - let mut sections = vec![ + let (kind, inverted) = + crate::scene::model::hatch_model::GradientKind::from_name(&g.name); + let mut pattern_props = vec![ + ro(t!("Type").as_ref(), "fill_kind", t!("Gradient").into_owned()), + Property { + label: t!("Color mode").into_owned(), + field: "fill_type", + value: PropValue::Choice { + selected: grad_type.to_string(), + options: vec!["One color".into(), "Two color".into()], + }, + }, + Property { + label: t!("Gradient type").into_owned(), + field: "gradient_type", + value: PropValue::Choice { + selected: kind.choice_label(inverted).to_string(), + options: crate::scene::model::hatch_model::GradientKind::CHOICES + .iter() + .map(|(kind, inverted)| kind.choice_label(*inverted).to_string()) + .collect(), + }, + }, + Property { + label: t!("Color 1").into_owned(), + field: "gradient_color_1", + value: PropValue::ColorChoice(grad_c1), + }, + ]; + if g.is_single_color { + pattern_props.push(edit( + t!("Tint/Shade").as_ref(), + "gradient_tint", + g.color_tint.clamp(0.0, 1.0), + )); + } else { + pattern_props.push(Property { + label: t!("Color 2").into_owned(), + field: "gradient_color_2", + value: PropValue::ColorChoice(grad_c2), + }); + } + pattern_props.push(edit_angle( + t!("Angle").as_ref(), + "pattern_angle", + g.angle.to_degrees(), + )); + pattern_props.push(Property { + label: t!("Centered").into_owned(), + field: "gradient_centered", + value: PropValue::BoolToggle { + field: "gradient_centered", + value: g.shift < 0.5, + }, + }); + + return vec![ PropSection { title: t!("Pattern").into_owned(), - props: vec![ - Property { - label: t!("Type").into_owned(), - field: "fill_type", - value: PropValue::Choice { - selected: grad_type.to_string(), - options: vec!["Two color".into(), "One color".into()], - }, - }, - { - let (kind, invert) = - crate::scene::model::hatch_model::GradientKind::from_name(&g.name); - let _ = invert; - Property { - label: t!("Gradient type").into_owned(), - field: "gradient_type", - value: PropValue::Choice { - selected: kind.label().to_string(), - options: crate::scene::model::hatch_model::GradientKind::ALL - .iter() - .map(|k| k.label().to_string()) - .collect(), - }, - } - }, - Property { - label: t!("Invert").into_owned(), - field: "gradient_invert", - value: PropValue::BoolToggle { - field: "gradient_invert", - value: crate::scene::model::hatch_model::GradientKind::from_name( - &g.name, - ) - .1, - }, - }, - Property { - label: t!("Color 1").into_owned(), - field: "gradient_color_1", - value: PropValue::ColorChoice(grad_c1), - }, - Property { - label: t!("Color 2").into_owned(), - field: "gradient_color_2", - value: PropValue::ColorChoice(grad_c2), - }, - edit_angle(t!("Angle").as_ref(), "pattern_angle", g.angle.to_degrees()), - ro(t!("Centered").as_ref(), "gradient_centered", centered), - ], + props: pattern_props, }, PropSection { title: t!("Geometry").into_owned(), @@ -410,33 +417,26 @@ fn properties(h: &Hatch) -> Vec { PropSection { title: t!("Misc").into_owned(), props: vec![ - ro(t!("Associative").as_ref(), - "associative", - if h.is_associative { "Yes" } else { "No" }, - ), - ro(t!("Annotative").as_ref(), "annotative", String::new()), - ro(t!("Island detection style").as_ref(), "style", style), Property { - label: t!("Background").into_owned(), - field: "bg_enabled", + label: t!("Associative").into_owned(), + field: "associative", value: PropValue::BoolToggle { - field: "bg_enabled", - value: bg_on, + field: "associative", + value: h.is_associative, + }, + }, + ro(t!("Annotative").as_ref(), "annotative", String::new()), + Property { + label: t!("Island detection style").into_owned(), + field: "style", + value: PropValue::Choice { + selected: style.to_string(), + options: vec!["Normal".into(), "Outer".into(), "Ignore".into()], }, }, ], }, ]; - if bg_on { - if let Some(sec) = sections.last_mut() { - sec.props.push(Property { - label: t!("Background color").into_owned(), - field: "background_color", - value: PropValue::ColorChoice(bg_col), - }); - } - } - return sections; } @@ -567,11 +567,22 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { return; } "associative" => { - h.is_associative = if value == "toggle" { + let requested = if value == "toggle" { !h.is_associative } else { value == "true" }; + // An associative flag without source handles is inert and cannot + // update from boundary edits. Only enable it when a real + // relationship is available; disabling retains the handles so the + // user can turn it back on later. + if !requested + || h.paths + .iter() + .any(|path| !path.boundary_handles.is_empty()) + { + h.is_associative = requested; + } return; } "pattern_type_label" => { @@ -593,31 +604,27 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { return; } "fill_type" => { - h.gradient_color.is_single_color = value == "One color"; + let one_color = value == "One color"; + if one_color && !h.gradient_color.is_single_color { + h.gradient_color.color_tint = 1.0; + } + h.gradient_color.is_single_color = one_color; return; } - // Gradient shape selection + stop inversion (#415). Both re-derive the - // standard DXF gradient name; Linear has no INV name, so inverting a - // linear swaps the colour stops instead. "gradient_type" => { use crate::scene::model::hatch_model::GradientKind; - let (_, invert) = GradientKind::from_name(&h.gradient_color.name); - if let Some(kind) = GradientKind::from_label(value) { + if let Some((kind, invert)) = GradientKind::from_choice_label(value) { h.gradient_color.name = kind.dxf_name(invert).to_string(); } return; } - "gradient_invert" => { - use crate::scene::model::hatch_model::GradientKind; - let (kind, invert) = GradientKind::from_name(&h.gradient_color.name); - let invert = !invert; - if matches!(kind, GradientKind::Linear) { - if h.gradient_color.colors.len() >= 2 { - h.gradient_color.colors.swap(0, 1); - } + "gradient_centered" => { + let centered = if value == "toggle" { + h.gradient_color.shift >= 0.5 } else { - h.gradient_color.name = kind.dxf_name(invert).to_string(); - } + value == "true" + }; + h.gradient_color.shift = if centered { 0.0 } else { 1.0 }; return; } _ => {} @@ -630,6 +637,9 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { h.gradient_color.angle = v.to_radians(); h.pattern_angle = v.to_radians(); } + "gradient_tint" if h.gradient_color.enabled => { + h.gradient_color.color_tint = v.clamp(0.0, 1.0); + } "pattern_angle" => { let angle = v.to_radians(); let delta = angle - h.pattern_angle; diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 614fee11b..abeb025da 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -643,6 +643,7 @@ impl CadCommand for HatchCommand { pub struct GradientCommand { outlines: Vec>, + boundary_sources: rustc_hash::FxHashMap>, mode: Mode, manual_pts: Vec, missed: bool, @@ -653,9 +654,13 @@ pub struct GradientCommand { } impl GradientCommand { - pub fn new(outlines: Vec>) -> Self { + pub fn new( + outlines: Vec>, + boundary_sources: rustc_hash::FxHashMap>, + ) -> Self { Self { outlines, + boundary_sources, mode: Mode::PickInside, manual_pts: vec![], missed: false, @@ -666,6 +671,14 @@ impl GradientCommand { fn make_hatch(&self, rings: Vec>) -> HatchModel { let (rel, origin, wcs) = pack_rings(&rings); + let exterior = cadkernel::geom2d::ring_nesting_depths(&rings) + .into_iter() + .map(|depth| depth % 2 == 0) + .collect(); + let boundary_sources = rings + .iter() + .map(|ring| crate::scene::ring_source_handles(ring, &self.boundary_sources)) + .collect(); HatchModel { render_instance: None, boundary: std::sync::Arc::new(rel), @@ -674,6 +687,7 @@ impl GradientCommand { color2: [0.18, 0.18, 0.18, 0.0], kind: self.kind, invert: self.invert, + shift: 0.0, }, name: self.kind.dxf_name(self.invert).into(), color: [0.30, 0.60, 0.95, 0.80], @@ -683,8 +697,8 @@ impl GradientCommand { scale: 1.0, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), - boundary_exterior: None, - boundary_sources: None, + boundary_exterior: Some(std::sync::Arc::new(exterior)), + boundary_sources: Some(std::sync::Arc::new(boundary_sources)), draw_depth: 0.0, } } @@ -703,15 +717,10 @@ impl CadCommand for GradientCommand { } else { std::borrow::Cow::Borrowed("") }; - let invert = if self.invert { - t!(", inverted") - } else { - std::borrow::Cow::Borrowed("") - }; t!( "GRADIENT (%{kind}%{invert}) Pick internal point:%{miss}", - kind = t!(self.kind.label()), - invert = invert, + kind = t!(self.kind.choice_label(self.invert)), + invert = std::borrow::Cow::Borrowed(""), miss = miss ) .into_owned() @@ -731,15 +740,14 @@ impl CadCommand for GradientCommand { match &self.mode { Mode::PickInside => { let mut opts = vec![CmdOption::new("Draw manually", "S")]; - for k in crate::scene::model::hatch_model::GradientKind::ALL { - if k != self.kind { - opts.push(CmdOption::new(k.label(), k.label())); + for (kind, inverted) in + crate::scene::model::hatch_model::GradientKind::CHOICES + { + if kind != self.kind || inverted != self.invert { + let label = kind.choice_label(inverted); + opts.push(CmdOption::new(label, label)); } } - opts.push(CmdOption::new( - if self.invert { "Invert: on" } else { "Invert: off" }, - "I", - )); opts } Mode::Manual => { @@ -804,17 +812,11 @@ impl CadCommand for GradientCommand { self.missed = false; return Some(CmdResult::NeedPoint); } - // Gradient type keywords / buttons + the invert toggle (#415). - if t.eq_ignore_ascii_case("i") || t.eq_ignore_ascii_case("invert") { - self.invert = !self.invert; - return Some(CmdResult::NeedPoint); - } - if let Some(k) = crate::scene::model::hatch_model::GradientKind::ALL - .iter() - .copied() - .find(|k| k.label().eq_ignore_ascii_case(t)) + if let Some((kind, inverted)) = + crate::scene::model::hatch_model::GradientKind::from_choice_label(t) { - self.kind = k; + self.kind = kind; + self.invert = inverted; return Some(CmdResult::NeedPoint); } None diff --git a/src/scene/entity.rs b/src/scene/entity.rs index d3b1ae2d3..659d353bd 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -90,6 +90,36 @@ fn family_from_stored_line( } } +/// Preserve the selected hue while moving its HSL lightness towards the +/// persisted one-colour tint/shade target (0 = black, 1 = white). +fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { + let max = base[0].max(base[1]).max(base[2]); + let min = base[0].min(base[1]).min(base[2]); + let lightness = (max + min) * 0.5; + let target = target.clamp(0.0, 1.0); + let mut result = base; + if target <= lightness { + let factor = if lightness > 1.0e-6 { + target / lightness + } else { + 0.0 + }; + for channel in &mut result[..3] { + *channel *= factor; + } + } else { + let factor = if lightness < 1.0 - 1.0e-6 { + (target - lightness) / (1.0 - lightness) + } else { + 1.0 + }; + for channel in &mut result[..3] { + *channel += (1.0 - *channel) * factor; + } + } + result +} + impl Scene { // ── Entity management ───────────────────────────────────────────────── @@ -1041,8 +1071,13 @@ impl Scene { m.angle_offset = dxf.pattern_angle as f32; m.scale = dxf.pattern_scale as f32; } - model::hatch_model::HatchPattern::Gradient { angle_deg, .. } => { - *angle_deg = dxf.pattern_angle.to_degrees() as f32; + model::hatch_model::HatchPattern::Gradient { + angle_deg, + shift, + .. + } => { + *angle_deg = dxf.gradient_color.angle.to_degrees() as f32; + *shift = dxf.gradient_color.shift as f32; } model::hatch_model::HatchPattern::Pattern(_) | model::hatch_model::HatchPattern::Solid => {} @@ -1522,6 +1557,7 @@ impl Scene { let mut boundary: Vec<[f64; 2]> = Vec::new(); let mut boundary_exterior = Vec::new(); + let mut boundary_sources = Vec::new(); for path in &dxf.paths { // Skip TEXTBOX boundary paths (flag bit 3). These are text @@ -1531,6 +1567,19 @@ impl Scene { if path.flags.bits() & 8 != 0 { continue; } + let first_fill_path = boundary_exterior.is_empty(); + let keep_for_style = match dxf.style { + acadrust::entities::HatchStyleType::Normal => true, + acadrust::entities::HatchStyleType::Outer => { + first_fill_path || path.flags.is_external() || path.flags.is_outermost() + } + acadrust::entities::HatchStyleType::Ignore => { + first_fill_path || path.flags.is_external() + } + }; + if !keep_for_style { + continue; + } let before_path = boundary.len(); if !boundary.is_empty() { boundary.push([f64::NAN, f64::NAN]); @@ -1556,6 +1605,7 @@ impl Scene { continue; } boundary_exterior.push(path.flags.is_external() || path.flags.is_outermost()); + boundary_sources.push(path.boundary_handles.clone()); if boundary.len() >= path_start + 3 { let first = boundary[path_start]; let last = *boundary.last().unwrap(); @@ -1618,8 +1668,13 @@ impl Scene { ) }; gradient_color1 = stop(0); - let color2 = stop(1).unwrap_or(color); - let angle_deg = dxf.pattern_angle.to_degrees() as f32; + let color1 = gradient_color1.unwrap_or(color); + let color2 = if dxf.gradient_color.is_single_color { + gradient_tint_color(color1, dxf.gradient_color.color_tint as f32) + } else { + stop(1).unwrap_or(color) + }; + let angle_deg = dxf.gradient_color.angle.to_degrees() as f32; let (kind, invert) = model::hatch_model::GradientKind::from_name(&dxf.gradient_color.name); model::hatch_model::HatchPattern::Gradient { @@ -1627,6 +1682,7 @@ impl Scene { color2, kind, invert, + shift: dxf.gradient_color.shift as f32, } } else if dxf.is_solid { model::hatch_model::HatchPattern::Solid @@ -1768,13 +1824,7 @@ impl Scene { boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), - boundary_sources: Some(std::sync::Arc::new( - dxf.paths - .iter() - .filter(|path| path.flags.bits() & 8 == 0) - .map(|path| path.boundary_handles.clone()) - .collect(), - )), + boundary_sources: Some(std::sync::Arc::new(boundary_sources)), pattern, name, // A gradient starts from its first stop; other fills use the @@ -2254,6 +2304,7 @@ impl Scene { color2, kind, invert, + shift, } = &model.pattern { let to_color = |c: [f32; 4]| acadrust::types::Color::Rgb { @@ -2268,6 +2319,7 @@ impl Scene { // (radians); the gradient record keeps its own copy for the file. dxf.pattern_angle = (*angle_deg as f64).to_radians(); dxf.gradient_color.angle = (*angle_deg as f64).to_radians(); + dxf.gradient_color.shift = *shift as f64; dxf.gradient_color.is_single_color = false; // Linear has no INV name in the standard set: persist an inverted // linear by swapping the colour stops instead. diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 0afc3e667..411c4ee26 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -58,6 +58,21 @@ impl GradientKind { GradientKind::Curved, ]; + /// User-facing gradient definitions in their standard persisted order. + /// Inverted definitions are real named patterns rather than a separate + /// property, so Properties and the draw command share this single list. + pub const CHOICES: [(GradientKind, bool); 9] = [ + (GradientKind::Linear, false), + (GradientKind::Cylinder, false), + (GradientKind::Cylinder, true), + (GradientKind::Spherical, false), + (GradientKind::Hemispherical, false), + (GradientKind::Curved, false), + (GradientKind::Spherical, true), + (GradientKind::Hemispherical, true), + (GradientKind::Curved, true), + ]; + /// Radial fills shade from the boundary centre outward. pub fn radial(self) -> bool { matches!(self, GradientKind::Spherical | GradientKind::Hemispherical) @@ -77,6 +92,27 @@ impl GradientKind { Self::ALL.iter().copied().find(|k| k.label() == label) } + pub fn choice_label(self, inverted: bool) -> &'static str { + match (self, inverted) { + (GradientKind::Linear, _) => "Linear", + (GradientKind::Cylinder, false) => "Cylindrical", + (GradientKind::Cylinder, true) => "Inverted cylindrical", + (GradientKind::Spherical, false) => "Spherical", + (GradientKind::Spherical, true) => "Inverted spherical", + (GradientKind::Hemispherical, false) => "Hemispherical", + (GradientKind::Hemispherical, true) => "Inverted hemispherical", + (GradientKind::Curved, false) => "Curved", + (GradientKind::Curved, true) => "Inverted curved", + } + } + + pub fn from_choice_label(label: &str) -> Option<(Self, bool)> { + Self::CHOICES + .iter() + .copied() + .find(|(kind, inverted)| kind.choice_label(*inverted).eq_ignore_ascii_case(label)) + } + /// Parse the DXF gradient name (`LINEAR`, `INVCYLINDER`, …) into the kind /// plus its inverted flag. Unknown names read as Linear. pub fn from_name(name: &str) -> (Self, bool) { @@ -139,9 +175,39 @@ pub enum HatchPattern { color2: [f32; 4], kind: GradientKind, invert: bool, + /// 0 = centred, 1 = shifted towards the upper-left light source. + shift: f32, }, } +/// Local-space focal offset for the persisted 0..1 gradient shift. +pub fn gradient_shift_offset(boundary: &[[f32; 2]], shift: f32) -> [f32; 2] { + let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); + let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY); + for &[x, y] in boundary { + if x.is_finite() && y.is_finite() { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + if !min_x.is_finite() { + return [0.0, 0.0]; + } + let shift = shift.clamp(0.0, 1.0); + [-(max_x - min_x) * 0.25 * shift, (max_y - min_y) * 0.25 * shift] +} + +pub fn gradient_radius(boundary: &[[f32; 2]], center: [f32; 2]) -> f32 { + boundary + .iter() + .filter(|point| point[0].is_finite() && point[1].is_finite()) + .map(|point| (point[0] - center[0]).hypot(point[1] - center[1])) + .fold(0.0_f32, f32::max) + .max(1.0) +} + /// GPU-side separator between disconnected boundary sub-loops. A finite /// magnitude sentinel instead of NaN: shaders detect it with a plain /// `abs(x) < 1e29` compare, which every driver evaluates identically, diff --git a/src/scene/pipeline/hatch_gpu/storage.rs b/src/scene/pipeline/hatch_gpu/storage.rs index 4ab94131e..6c3e9f1a4 100644 --- a/src/scene/pipeline/hatch_gpu/storage.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -262,21 +262,32 @@ impl StorageHatchBatch { let mut grad_kind = 0u32; let (mode, color2, grad_cos, grad_sin, grad_min, grad_range) = match &h.pattern { HatchPattern::Solid => (1u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0), - HatchPattern::Gradient { angle_deg, color2, kind, invert } => { + HatchPattern::Gradient { + angle_deg, + color2, + kind, + invert, + shift, + } => { grad_kind = kind.shader_kind() | if *invert { 16 } else { 0 }; + let center = crate::scene::model::hatch_model::gradient_shift_offset( + &h.boundary, + *shift, + ); if kind.radial() { - // Radial fill: the boundary is stored relative to its - // centre (`world_origin`), so the centre is the local - // origin; radius = the farthest boundary vertex. mode 3. - let radius = radial_radius(&h.boundary); - (3u32, *color2, 0.0, 0.0, 0.0, radius) + let radius = crate::scene::model::hatch_model::gradient_radius( + &h.boundary, + center, + ); + (3u32, *color2, center[0], center[1], 0.0, radius) } else { let r = angle_deg.to_radians(); // Gradient projection range (proj_min / proj_range) — // computed at upload time, identical to per-hatch path. let (gmin, gmax) = boundary_projection_range(&h.boundary, r); let grange = (gmax - gmin).max(1.0); - (2u32, *color2, r.cos(), r.sin(), gmin, grange) + let shifted_min = gmin + center[0] * r.cos() + center[1] * r.sin(); + (2u32, *color2, r.cos(), r.sin(), shifted_min, grange) } } HatchPattern::Pattern(fams) => { @@ -659,18 +670,6 @@ fn boundary_projection_range(boundary: &[[f32; 2]], theta: f32) -> (f32, f32) { (lo, hi) } -/// Radius of a radial gradient — the distance from the boundary centre (the -/// local origin, since vertices are stored relative to it) to the farthest -/// vertex, so `t = 1` (the end colour) reaches the corners. -fn radial_radius(boundary: &[[f32; 2]]) -> f32 { - boundary - .iter() - .filter(|p| p[0].is_finite() && p[1].is_finite()) - .map(|p| (p[0] * p[0] + p[1] * p[1]).sqrt()) - .fold(0.0_f32, f32::max) - .max(1.0) -} - // PatFamily is re-exported by hatch_model so we don't need to import // it explicitly anywhere else — but rust needs the type referenced to // confirm the layout assumption above. diff --git a/src/scene/pipeline/hatch_gpu/texture.rs b/src/scene/pipeline/hatch_gpu/texture.rs index 83fe314b8..b3aeffe67 100644 --- a/src/scene/pipeline/hatch_gpu/texture.rs +++ b/src/scene/pipeline/hatch_gpu/texture.rs @@ -165,7 +165,7 @@ impl TextureHatch { let (mode, color2, grad_cos, grad_sin) = match &model.pattern { HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32), HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32), - HatchPattern::Gradient { angle_deg, color2, kind, invert } => { + HatchPattern::Gradient { angle_deg, color2, kind, invert, .. } => { let gk = (kind.shader_kind() | if *invert { 16 } else { 0 }) << 8; if kind.radial() { // Radial: centre is the local origin; grad_cos/sin unused. @@ -239,7 +239,16 @@ impl TextureHatch { }); // ── Gradient projection range (snapped-local space) ─────────────── - let (grad_min, grad_range) = if mode == 2 { + let base_mode = mode & 0xFF; + let shift = match &model.pattern { + HatchPattern::Gradient { shift, .. } => *shift, + _ => 0.0, + }; + let center = crate::scene::model::hatch_model::gradient_shift_offset( + &model.boundary, + shift, + ); + let (grad_min, grad_range, radial_center) = if base_mode == 2 { let projs: Vec = model .boundary .iter() @@ -247,25 +256,25 @@ impl TextureHatch { .map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin) .collect(); if projs.is_empty() { - (0.0, 1.0) + (0.0, 1.0, [0.0, 0.0]) } else { let proj_min = projs.iter().cloned().fold(f32::INFINITY, f32::min); let proj_max = projs.iter().cloned().fold(f32::NEG_INFINITY, f32::max); // Floor matches the storage backend. - (proj_min, (proj_max - proj_min).max(1.0)) + ( + proj_min + center[0] * grad_cos + center[1] * grad_sin, + (proj_max - proj_min).max(1.0), + [0.0, 0.0], + ) } - } else if mode == 3 { - // Radial: range = the farthest boundary vertex from the centre. - let radius = model - .boundary - .iter() - .filter(|v| v[0].is_finite() && v[1].is_finite()) - .map(|&[x, y]| (x * x + y * y).sqrt()) - .fold(0.0_f32, f32::max) - .max(1.0); - (0.0, radius) + } else if base_mode == 3 { + let radius = crate::scene::model::hatch_model::gradient_radius( + &model.boundary, + center, + ); + (0.0, radius, center) } else { - (0.0, 1.0) + (0.0, 1.0, [0.0, 0.0]) }; // ── Pack the data texture: families | dashes ───────────────────── @@ -355,8 +364,8 @@ impl TextureHatch { // Clamp like the desktop renderer so scale==0 can't make perp_step 0 // → round(perp/0)=NaN → an invisible hatch. scale: model.scale.max(1e-6), - grad_cos, - grad_sin, + grad_cos: if base_mode == 3 { radial_center[0] } else { grad_cos }, + grad_sin: if base_mode == 3 { radial_center[1] } else { grad_sin }, grad_min, grad_range, origin: [origin[0] as f32, origin[1] as f32], diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index d2ab7b66f..1259872d4 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1345,6 +1345,7 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { color2, kind, invert, + shift, } => { 2_u8.hash(&mut h); angle_deg.to_bits().hash(&mut h); @@ -1353,6 +1354,7 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { } kind.shader_kind().hash(&mut h); invert.hash(&mut h); + shift.to_bits().hash(&mut h); } } } From 4d2e8bf0b0bba97a2056fdedb4d6cb24209fa3f6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:41:37 +0300 Subject: [PATCH 2/2] refactor(gradient): remove obsolete outline collector --- src/scene/boundary.rs | 4 +-- src/scene/mod.rs | 65 ------------------------------------------- 2 files changed, 2 insertions(+), 67 deletions(-) diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 1e0c12ce0..62dfdb82b 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -301,8 +301,8 @@ impl Scene { /// Build closed planar regions from the visible wire geometry. /// - /// Unlike `closed_outlines()`, the source entities do not need to be closed - /// individually. Intersections are inserted as temporary graph vertices and + /// Source entities do not need to be closed individually. Intersections are + /// inserted as temporary graph vertices and /// the bounded faces of that planar graph are returned as hatch candidates. /// /// Curved entities participate through their already-tessellated WireModel diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ad546ffb8..d3f263a08 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -4646,71 +4646,6 @@ impl Scene { names } - /// Collect closed polygon outlines (world XY) from the current layout. - pub fn closed_outlines(&self) -> Vec> { - self.entity_wires() - .iter() - .filter_map(|wire| { - let pts: Vec<[f64; 2]> = wire - .points - .iter() - .copied() - .enumerate() - .filter_map(|(index, high)| { - if !high[0].is_finite() || !high[1].is_finite() { - return None; - } - let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); - let point = [ - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - ]; - point.iter().all(|value| value.is_finite()).then_some(point) - }) - .collect(); - if pts.len() < 4 { - return None; - } - let f = pts.first()?; - let l = pts.last()?; - let dx = f[0] - l[0]; - let dy = f[1] - l[1]; - if (dx * dx + dy * dy).sqrt() > 1e-2 { - return None; - } - // Segment-list wires (e.g. LwPolyline) store each segment as an - // independent NaN-separated pair, so every shared corner repeats - // (`A B | B C | C D | D A`). Collapse that back into a clean ring: - // skip the NaN separators and any vertex coincident with the - // previous one, so consumers (point-in-polygon, the hatch / - // boundary commands) see one vertex per corner — not the doubled - // ring that otherwise shows two grips at every corner. - let mut ring: Vec<[f64; 2]> = Vec::with_capacity(pts.len()); - for q in pts { - if let Some(&last) = ring.last() { - if (last[0] - q[0]).abs() < 1e-4 && (last[1] - q[1]).abs() < 1e-4 { - continue; - } - } - ring.push(q); - } - // Drop a trailing vertex equal to the first — the ring is closed - // implicitly, so keeping it would be a duplicate corner. - if ring.len() > 1 { - let first = ring[0]; - let last = *ring.last().unwrap(); - if (first[0] - last[0]).abs() < 1e-4 && (first[1] - last[1]).abs() < 1e-4 { - ring.pop(); - } - } - if ring.len() < 3 { - return None; - } - Some(ring) - }) - .collect() - } - /// Wire set for the Model layout, shared by every tile. /// /// The model wire geometry is **camera-independent**, so it is tessellated