diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index d0d1c9ff2..610227f71 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -395,6 +395,7 @@ impl GradientCommand { pattern: HatchPattern::Gradient { angle_deg: 0.0, color2: [0.18, 0.18, 0.18, 0.0], + radial: false, }, name: "LINEAR".into(), color: [0.30, 0.60, 0.95, 0.80], diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 2f4bdcca8..8f7071af5 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -626,7 +626,12 @@ impl Scene { // emitted first so LessEqual layering keeps it underneath. let mut backdrop: Option = None; if let Some(e) = entity { - m.color = self.render_style(e).0; + // A gradient's colour is its first stop (already baked into + // the cached model); only solid / pattern fills take the + // entity's resolved colour. + if !matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + m.color = self.render_style(e).0; + } if let EntityType::Hatch(dxf) = e { if let Some(bg) = crate::entities::hatch::background_color(dxf) { let mut b = m.clone(); @@ -1421,16 +1426,27 @@ impl Scene { && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty(); + // The gradient's first stop is the fill's start colour (not the + // entity colour); capture it so the HatchModel draws stop-0 → stop-1. + let mut gradient_color1: Option<[f32; 4]> = None; let mut pattern = if dxf.gradient_color.is_enabled() { - let color2 = dxf - .gradient_color - .colors - .get(1) - .and_then(|e| e.color.rgb()) - .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) - .unwrap_or(color); + let stop = |i: usize| { + dxf.gradient_color.colors.get(i).and_then(|e| e.color.rgb()).map( + |(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0], + ) + }; + gradient_color1 = stop(0); + let color2 = stop(1).unwrap_or(color); let angle_deg = dxf.pattern_angle.to_degrees() as f32; - model::hatch_model::HatchPattern::Gradient { angle_deg, color2 } + // 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"); + model::hatch_model::HatchPattern::Gradient { + angle_deg, + color2, + radial, + } } else if dxf.is_solid { model::hatch_model::HatchPattern::Solid } else if prebaked { @@ -1571,7 +1587,9 @@ impl Scene { boundary_wcs: None, pattern, name, - color, + // A gradient starts from its first stop; other fills use the + // entity colour. + color: gradient_color1.unwrap_or(color), angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, world_origin, diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 9978c6b36..bd2e78c4a 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -36,8 +36,14 @@ pub enum HatchPattern { Solid, /// One or more line families (PAT format). Pattern(Vec), - /// Linear gradient from `color` to `color2` along `angle_deg`. - Gradient { angle_deg: f32, color2: [f32; 4] }, + /// 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). + Gradient { + angle_deg: f32, + color2: [f32; 4], + radial: bool, + }, } /// A hatched region defined by a closed polygon boundary. diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 8500ee5ff..6db010133 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -225,13 +225,21 @@ impl HatchGpu { 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 } => { - 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) + HatchPattern::Gradient { angle_deg, color2, radial } => { + if *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) + } 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) + } } HatchPattern::Pattern(fams) => { for fam in fams { @@ -559,6 +567,18 @@ 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_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs index be1327270..9341fbdb7 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -110,9 +110,14 @@ impl HatchWebGpu { 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 } => { - let r = angle_deg.to_radians(); - (2u32, *color2, r.cos(), r.sin()) + HatchPattern::Gradient { angle_deg, color2, radial } => { + if *radial { + // Radial: centre is the local origin; grad_cos/sin unused. + (3u32, *color2, 0.0, 0.0) + } else { + let r = angle_deg.to_radians(); + (2u32, *color2, r.cos(), r.sin()) + } } }; @@ -181,6 +186,16 @@ impl HatchWebGpu { // Floor matches the desktop hatch renderer (hatch_gpu.rs). (proj_min, (proj_max - proj_min).max(1.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 { (0.0, 1.0) }; diff --git a/src/scene/pipeline/wipeout_gpu.rs b/src/scene/pipeline/wipeout_gpu.rs index b163fec1f..ad21dd3d8 100644 --- a/src/scene/pipeline/wipeout_gpu.rs +++ b/src/scene/pipeline/wipeout_gpu.rs @@ -138,7 +138,7 @@ impl WipeoutGpu { 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 } => { + HatchPattern::Gradient { angle_deg, color2, .. } => { let r = angle_deg.to_radians(); (2u32, *color2, r.cos(), r.sin()) } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index b7e3c0383..8d48f6b23 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -274,6 +274,11 @@ fn check_family( 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); 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); + return mix(inst.color, inst.color2, t); } // 3. Pattern LOD: when the densest family's spacing projects below diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl index 6c6d2282d..e3eeb34ae 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_web.wgsl @@ -228,6 +228,11 @@ fn check_family( 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); return mix(h.color, h.color2, t); + } else if h.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); + return mix(h.color, h.color2, t); } // Pattern LOD: when the densest family projects below 2 px, lines blur into