feat(gradient): selectable gradient types + working creation

GRADIENT silently degraded to a plain hatch: add_hatch rebuilt the
render model from the DXF entity but never encoded the gradient on it,
so the committed fill lost its gradient entirely. The entity now
carries the full gradient record (name, angle, colour stops,
solid-fill flag), and the standard shape set became selectable:
Linear / Cylinder / Spherical / Hemispherical / Curved plus an Invert
toggle, offered as prompt buttons on the GRADIENT command and as a
Gradient type dropdown + Invert box in the properties panel. All nine
standard DXF names round-trip (inverted linear persists by swapping
the colour stops), and the shaders shape each kind distinctly —
mirrored cylinder, eased curve, dome-profiled hemisphere — on both the
native and web pipelines without changing the GPU struct sizes.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-21 12:51:08 +03:00
1 parent 9bfcad18b9
commit 930d8c51ea
8 files changed
+290 -32

No files matched your search

+51 -1
View File
@@ -213,7 +213,33 @@ fn properties(h: &Hatch) -> Vec<PropSection> {
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 {
+42 -5
View File
@@ -375,6 +375,10 @@ pub struct GradientCommand {
mode: Mode,
manual_pts: Vec<DVec3>,
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<crate::command::CmdOption> {
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<CmdResult> {
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
}
+48 -5
View File
@@ -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`
+91 -4
View File
@@ -29,6 +29,93 @@ pub struct PatFamily {
pub dashes: Vec<f32>,
}
/// 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> {
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<PatFamily>),
/// 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,
},
}
+8 -5
View File
@@ -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,
});
}
+7 -4
View File
@@ -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())
}
}
};
+20 -3
View File
@@ -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<f32>(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);
}
+23 -5
View File
@@ -222,16 +222,34 @@ fn check_family(
@fragment fn fs_main(v: VOut) -> @location(0) vec4<f32> {
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<f32>(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);
}