diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index decc6c309..bdc4f3ad8 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -213,7 +213,33 @@ fn properties(h: &Hatch) -> Vec { options: vec!["Two color".into(), "One color".into()], }, }, - ro("Gradient name", "gradient_name", g.name.clone()), + { + let (kind, invert) = + crate::scene::model::hatch_model::GradientKind::from_name(&g.name); + let _ = invert; + Property { + label: "Gradient type".into(), + 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: "Invert".into(), + field: "gradient_invert", + value: PropValue::BoolToggle { + field: "gradient_invert", + value: crate::scene::model::hatch_model::GradientKind::from_name( + &g.name, + ) + .1, + }, + }, Property { label: "Color 1".into(), field: "gradient_color_1", @@ -427,6 +453,30 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { h.gradient_color.is_single_color = value == "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) { + 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); + } + } else { + h.gradient_color.name = kind.dxf_name(invert).to_string(); + } + return; + } _ => {} } let Some(v) = parse_f64(value) else { diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 808e12024..69e58cae5 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -375,6 +375,10 @@ pub struct GradientCommand { mode: Mode, manual_pts: Vec, missed: bool, + /// Gradient shape, switchable via the prompt options (#415). + kind: crate::scene::model::hatch_model::GradientKind, + /// Swap the two colour stops. + invert: bool, } impl GradientCommand { @@ -384,6 +388,8 @@ impl GradientCommand { mode: Mode::PickInside, manual_pts: vec![], missed: false, + kind: crate::scene::model::hatch_model::GradientKind::Linear, + invert: false, } } @@ -394,9 +400,10 @@ impl GradientCommand { pattern: HatchPattern::Gradient { angle_deg: 0.0, color2: [0.18, 0.18, 0.18, 0.0], - radial: false, + kind: self.kind, + invert: self.invert, }, - name: "LINEAR".into(), + name: self.kind.dxf_name(self.invert).into(), color: [0.30, 0.60, 0.95, 0.80], angle_offset: 0.0, scale: 1.0, @@ -420,7 +427,11 @@ impl CadCommand for GradientCommand { } else { "" }; - format!("GRADIENT Pick internal point:{miss}") + format!( + "GRADIENT ({}{}) Pick internal point:{miss}", + self.kind.label(), + if self.invert { ", inverted" } else { "" } + ) } Mode::Manual => { if self.manual_pts.is_empty() { @@ -435,7 +446,19 @@ impl CadCommand for GradientCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match &self.mode { - Mode::PickInside => vec![CmdOption::new("Draw manually", "S")], + 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())); + } + } + opts.push(CmdOption::new( + if self.invert { "Invert: on" } else { "Invert: off" }, + "I", + )); + opts + } Mode::Manual => { // Enter accepts the boundary once at least 3 points are picked. if self.manual_pts.len() >= 3 { @@ -493,11 +516,25 @@ impl CadCommand for GradientCommand { } fn on_text_input(&mut self, text: &str) -> Option { - if text.trim().eq_ignore_ascii_case("s") { + let t = text.trim(); + if t.eq_ignore_ascii_case("s") { self.mode = Mode::Manual; 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)) + { + self.kind = k; + return Some(CmdResult::NeedPoint); + } None } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0499c82ca..22fd5f544 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1418,14 +1418,13 @@ impl Scene { gradient_color1 = stop(0); let color2 = stop(1).unwrap_or(color); let angle_deg = dxf.pattern_angle.to_degrees() as f32; - // Spherical / hemispherical / curved names are radial fills; linear - // and cylinder names run along the angle. - let n = dxf.gradient_color.name.to_ascii_uppercase(); - let radial = n.contains("SPHERICAL") || n.contains("CURVED"); + let (kind, invert) = + model::hatch_model::GradientKind::from_name(&dxf.gradient_color.name); model::hatch_model::HatchPattern::Gradient { angle_deg, color2, - radial, + kind, + invert, } } else if dxf.is_solid { model::hatch_model::HatchPattern::Solid @@ -1885,6 +1884,50 @@ impl Scene { } else { 1.0 }; + // A gradient fill must be encoded on the DXF entity itself: the render + // model is rebuilt from the entity below (`add_entity` → + // `hatch_model_from_dxf`), so a gradient kept only on the command's + // model silently degraded to a plain pattern hatch. + if let crate::scene::model::hatch_model::HatchPattern::Gradient { + angle_deg, + color2, + kind, + invert, + } = &model.pattern + { + let to_color = |c: [f32; 4]| acadrust::types::Color::Rgb { + r: (c[0] * 255.0).round().clamp(0.0, 255.0) as u8, + g: (c[1] * 255.0).round().clamp(0.0, 255.0) as u8, + b: (c[2] * 255.0).round().clamp(0.0, 255.0) as u8, + }; + dxf.is_solid = true; + dxf.gradient_color.enabled = true; + dxf.gradient_color.name = kind.dxf_name(*invert).to_string(); + // The render model reads the gradient angle from pattern_angle + // (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.is_single_color = false; + // Linear has no INV name in the standard set: persist an inverted + // linear by swapping the colour stops instead. + let (c0, c1) = + if *invert && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) + { + (*color2, model.color) + } else { + (model.color, *color2) + }; + dxf.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: to_color(c0), + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: to_color(c1), + }, + ]; + } // `add_entity` already builds the render model from the DXF entity via // `hatch_model_from_dxf` and inserts it with a correct `world_origin` diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 2dad73864..338f19c1d 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -29,6 +29,93 @@ pub struct PatFamily { pub dashes: Vec, } +/// The standard gradient fill shapes. Spherical / Hemispherical run from the +/// boundary centre outward; the others run along `angle_deg`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GradientKind { + Linear, + Cylinder, + Spherical, + Hemispherical, + Curved, +} + +impl GradientKind { + pub const ALL: [GradientKind; 5] = [ + GradientKind::Linear, + GradientKind::Cylinder, + GradientKind::Spherical, + GradientKind::Hemispherical, + GradientKind::Curved, + ]; + + /// Radial fills shade from the boundary centre outward. + pub fn radial(self) -> bool { + matches!(self, GradientKind::Spherical | GradientKind::Hemispherical) + } + + pub fn label(self) -> &'static str { + match self { + GradientKind::Linear => "Linear", + GradientKind::Cylinder => "Cylinder", + GradientKind::Spherical => "Spherical", + GradientKind::Hemispherical => "Hemispherical", + GradientKind::Curved => "Curved", + } + } + + pub fn from_label(label: &str) -> Option { + Self::ALL.iter().copied().find(|k| k.label() == 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) { + let n = name.trim().to_ascii_uppercase(); + let invert = n.starts_with("INV"); + let base = n.trim_start_matches("INV"); + let kind = if base.contains("CYL") { + GradientKind::Cylinder + } else if base.contains("HEMI") { + GradientKind::Hemispherical + } else if base.contains("SPHER") { + GradientKind::Spherical + } else if base.contains("CURV") { + GradientKind::Curved + } else { + GradientKind::Linear + }; + (kind, invert) + } + + /// The DXF gradient name. Linear has no INV variant in the standard set — + /// an inverted linear is persisted by swapping the colour stops instead. + pub fn dxf_name(self, invert: bool) -> &'static str { + match (self, invert) { + (GradientKind::Linear, _) => "LINEAR", + (GradientKind::Cylinder, false) => "CYLINDER", + (GradientKind::Cylinder, true) => "INVCYLINDER", + (GradientKind::Spherical, false) => "SPHERICAL", + (GradientKind::Spherical, true) => "INVSPHERICAL", + (GradientKind::Hemispherical, false) => "HEMISPHERICAL", + (GradientKind::Hemispherical, true) => "INVHEMISPHERICAL", + (GradientKind::Curved, false) => "CURVED", + (GradientKind::Curved, true) => "INVCURVED", + } + } + + /// Shape selector for the GPU shader (`grad_kind` low bits). + pub fn shader_kind(self) -> u32 { + match self { + GradientKind::Linear => 0, + GradientKind::Cylinder => 1, + GradientKind::Spherical => 2, + GradientKind::Hemispherical => 3, + GradientKind::Curved => 4, + } + } +} + /// Hatch fill pattern. #[derive(Clone, Debug)] pub enum HatchPattern { @@ -36,13 +123,13 @@ pub enum HatchPattern { Solid, /// One or more line families (PAT format). Pattern(Vec), - /// Two-stop gradient from `color` to `color2`. Linear along `angle_deg` - /// unless `radial`, in which case it runs from the boundary centre outward - /// (spherical / hemispherical / curved fills). + /// Two-stop gradient from `color` to `color2`, shaped by `kind`; `invert` + /// swaps the two stops. Gradient { angle_deg: f32, color2: [f32; 4], - radial: bool, + kind: GradientKind, + invert: bool, }, } diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 6db010133..73e1235b8 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -85,7 +85,8 @@ pub struct HatchInstance { /// an instance whose boundary failed to tessellate); 0 = the rasterized /// triangles already bound the fill, skip the test. pub poly_test: u32, // 116 - pub _pad1: u32, // 120 + /// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops. + pub grad_kind: u32, // 120 pub _pad2: u32, // 124 } @@ -223,10 +224,12 @@ impl HatchGpu { let family_offset = families.len() as u32; let mut family_count = 0u32; + 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, radial } => { - if *radial { + HatchPattern::Gradient { angle_deg, color2, kind, invert } => { + grad_kind = kind.shader_kind() | if *invert { 16 } else { 0 }; + 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. @@ -319,7 +322,7 @@ impl HatchGpu { family_count, draw_depth: h.draw_depth, poly_test: 1, - _pad1: 0, + grad_kind, _pad2: 0, }); continue; @@ -367,7 +370,7 @@ impl HatchGpu { // boundary complexity, so a hatch-dense drawing can't overflow // the device buffer limit. Each instance draws its AABB quad. poly_test: 1, - _pad1: 0, + grad_kind, _pad2: 0, }); } diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs index 7edc980a0..c047b1b74 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -105,16 +105,19 @@ impl HatchWebGpu { bgl1: &wgpu::BindGroupLayout, ) -> Self { // ── Decode pattern mode (mirrors WipeoutGpu::new) ───────────────── + // The gradient shape (kind + invert bit) rides in the mode's high + // bits — the 96-byte uniform has no spare slot. 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, radial } => { - if *radial { + 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. - (3u32, *color2, 0.0, 0.0) + (3u32 | gk, *color2, 0.0, 0.0) } else { let r = angle_deg.to_radians(); - (2u32, *color2, r.cos(), r.sin()) + (2u32 | gk, *color2, r.cos(), r.sin()) } } }; diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 8d48f6b23..a9bbf45cd 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -59,7 +59,7 @@ struct HatchInstance { family_count: u32, draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral poly_test: u32, // 1 = run in_polygon (fallback), 0 = skip - _pad1: u32, + grad_kind: u32, // shape (0=linear,1=cyl,2=sph,3=hemi,4=curved), bit4=invert _pad2: u32, } @@ -272,12 +272,29 @@ fn check_family( return inst.color; } else if inst.mode == 2u { let proj = v.xz.x * inst.grad_cos + v.xz.y * inst.grad_sin; - let t = clamp((proj - inst.grad_min) / inst.grad_range, 0.0, 1.0); + var t = clamp((proj - inst.grad_min) / inst.grad_range, 0.0, 1.0); + // Shape profile: cylinder mirrors around the middle, curved eases in. + let k = inst.grad_kind & 15u; + if k == 1u { + t = 1.0 - abs(2.0 * t - 1.0); + } else if k == 4u { + t = t * t; + } + if (inst.grad_kind & 16u) != 0u { + t = 1.0 - t; + } return mix(inst.color, inst.color2, t); } else if inst.mode == 3u { // Radial gradient: centre is (grad_cos, grad_sin), radius is grad_range. let d = length(v.xz - vec2(inst.grad_cos, inst.grad_sin)); - let t = clamp(d / inst.grad_range, 0.0, 1.0); + var t = clamp(d / inst.grad_range, 0.0, 1.0); + // Hemispherical shades faster near the centre (dome profile). + if (inst.grad_kind & 15u) == 3u { + t = sqrt(t); + } + if (inst.grad_kind & 16u) != 0u { + t = 1.0 - t; + } return mix(inst.color, inst.color2, t); } diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl index e3eeb34ae..2a4d987b5 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_web.wgsl @@ -222,16 +222,34 @@ fn check_family( @fragment fn fs_main(v: VOut) -> @location(0) vec4 { if !in_polygon(v.xz) { discard; } - if h.mode == 1u { + let base_mode = h.mode & 0xFFu; + let gk = (h.mode >> 8u) & 15u; + let ginv = ((h.mode >> 8u) & 16u) != 0u; + if base_mode == 1u { return h.color; - } else if h.mode == 2u { + } else if base_mode == 2u { let proj = v.xz.x * h.grad_cos + v.xz.y * h.grad_sin; - let t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0); + var t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0); + // Shape profile: cylinder mirrors around the middle, curved eases in. + if gk == 1u { + t = 1.0 - abs(2.0 * t - 1.0); + } else if gk == 4u { + t = t * t; + } + if ginv { + t = 1.0 - t; + } return mix(h.color, h.color2, t); - } else if h.mode == 3u { + } else if base_mode == 3u { // Radial gradient: centre is (grad_cos, grad_sin), radius is grad_range. let d = length(v.xz - vec2(h.grad_cos, h.grad_sin)); - let t = clamp(d / h.grad_range, 0.0, 1.0); + var t = clamp(d / h.grad_range, 0.0, 1.0); + if gk == 3u { + t = sqrt(t); + } + if ginv { + t = 1.0 - t; + } return mix(h.color, h.color2, t); }