feat(gradient): align editing and rendering

This commit is contained in:
Hakan Seven committed 2026-08-20 21:17:10 +03:00
commit 2a852f4479
37 files changed
+539 -371

No files matched your search

+2 -2
View File
@@ -545,8 +545,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
+98 -52
View File
@@ -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 => {}
@@ -1520,8 +1555,8 @@ impl Scene {
return None;
}
let mut boundary: Vec<[f64; 2]> = Vec::new();
let mut boundary_exterior = Vec::new();
let mut rings = Vec::new();
let mut ring_sources = Vec::new();
for path in &dxf.paths {
// Skip TEXTBOX boundary paths (flag bit 3). These are text
@@ -1531,12 +1566,6 @@ impl Scene {
if path.flags.bits() & 8 != 0 {
continue;
}
let before_path = boundary.len();
if !boundary.is_empty() {
boundary.push([f64::NAN, f64::NAN]);
}
let path_start = boundary.len();
let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new();
for edge in &path.edges {
if let Some(curve) = crate::entities::hatch::edge_curve(edge) {
@@ -1549,29 +1578,44 @@ impl Scene {
);
}
}
boundary.extend(chain_path_edges(edge_polys));
if boundary.len() == path_start {
boundary.truncate(before_path);
let mut ring = chain_path_edges(edge_polys);
if ring.is_empty() {
continue;
}
boundary_exterior.push(path.flags.is_external() || path.flags.is_outermost());
if boundary.len() >= path_start + 3 {
let first = boundary[path_start];
let last = *boundary.last().unwrap();
if ring.len() >= 3 {
let first = ring[0];
let last = *ring.last().unwrap();
if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 {
boundary.push(first);
ring.push(first);
}
}
rings.push(ring);
ring_sources.push(path.boundary_handles.clone());
}
if boundary.is_empty() {
if rings.is_empty() {
return None;
}
if !boundary_exterior.iter().any(|role| *role) {
if let Some(first) = boundary_exterior.first_mut() {
*first = true;
let depths = cadkernel::geom2d::ring_nesting_depths(&rings);
let mut boundary = Vec::new();
let mut boundary_exterior = Vec::new();
let mut boundary_sources = Vec::new();
for ((ring, sources), depth) in rings.into_iter().zip(ring_sources).zip(depths) {
let keep = match dxf.style {
acadrust::entities::HatchStyleType::Normal => true,
acadrust::entities::HatchStyleType::Outer => depth <= 1,
acadrust::entities::HatchStyleType::Ignore => depth == 0,
};
if !keep {
continue;
}
if !boundary.is_empty() {
boundary.push([f64::NAN, f64::NAN]);
}
boundary.extend(ring);
boundary_exterior.push(depth == 0);
boundary_sources.push(sources);
}
// The batched hatch renderer keeps boundaries in a GPU storage
// buffer (no fixed length), so a hatch with many island loops must
@@ -1618,8 +1662,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 +1676,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 +1818,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
@@ -2144,27 +2188,28 @@ impl Scene {
let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool, index: usize| {
if !r.is_empty() {
let edge = PolylineEdge::new(std::mem::take(r), true);
let mut path = if is_outer {
let mut p = BoundaryPath::external();
p.flags = acadrust::entities::hatch::BoundaryPathFlags::from_bits(
p.flags.bits()
| acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(),
);
p
} else {
BoundaryPath::new()
};
path.add_edge(BoundaryEdge::Polyline(edge));
if let Some(handles) = model
let handles: Vec<_> = model
.boundary_sources
.as_deref()
.and_then(|sources| sources.get(index))
{
for &handle in handles {
if handle.is_valid() {
path.add_boundary_handle(handle);
}
}
.into_iter()
.flatten()
.copied()
.filter(|handle| handle.is_valid())
.collect();
let mut bits = 0;
if is_outer {
bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits();
}
if !handles.is_empty() {
bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits();
}
let mut path = BoundaryPath::with_flags(
acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits),
);
path.add_edge(BoundaryEdge::Polyline(edge));
for handle in handles {
path.add_boundary_handle(handle);
}
dxf.paths.push(path);
}
@@ -2254,6 +2299,7 @@ impl Scene {
color2,
kind,
invert,
shift,
} = &model.pattern
{
let to_color = |c: [f32; 4]| acadrust::types::Color::Rgb {
@@ -2264,10 +2310,10 @@ impl Scene {
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.
// Keep both persisted angle fields aligned.
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.
-65
View File
@@ -4652,71 +4652,6 @@ impl Scene {
names
}
/// Collect closed polygon outlines (world XY) from the current layout.
pub fn closed_outlines(&self) -> Vec<Vec<[f64; 2]>> {
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
+50 -15
View File
@@ -50,12 +50,19 @@ pub enum GradientKind {
}
impl GradientKind {
pub const ALL: [GradientKind; 5] = [
GradientKind::Linear,
GradientKind::Cylinder,
GradientKind::Spherical,
GradientKind::Hemispherical,
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.
@@ -63,18 +70,25 @@ impl GradientKind {
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 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_label(label: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|k| k.label() == label)
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
@@ -139,6 +153,8 @@ pub enum HatchPattern {
color2: [f32; 4],
kind: GradientKind,
invert: bool,
/// 0 = centred, 1 = shifted towards the upper-left light source.
shift: f32,
},
}
@@ -208,6 +224,25 @@ pub struct HatchModel {
}
impl HatchModel {
pub(crate) fn gradient_frame(
&self,
angle_deg: f32,
shift: f32,
) -> Option<cadkernel::geom2d::GradientFrame> {
let boundary: Vec<[f64; 2]> = self
.boundary
.iter()
.filter(|point| point[0].is_finite() && point[1].is_finite())
.map(|point| [point[0] as f64, point[1] as f64])
.collect();
cadkernel::geom2d::gradient_frame(
&boundary,
(angle_deg as f64).to_radians(),
shift as f64,
Tolerance::default(),
)
}
/// Indexed local-space fill mesh with even-odd loop containment.
pub(crate) fn fill_mesh(&self) -> (Vec<[f32; 2]>, Vec<u32>) {
let mut rings = Vec::new();
+16 -47
View File
@@ -262,21 +262,26 @@ 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 frame = h.gradient_frame(*angle_deg, *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 center = frame.map_or([0.0, 0.0], |frame| {
[frame.center[0] as f32, frame.center[1] as f32]
});
let radius = frame.map_or(1.0, |frame| frame.radius as f32);
(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 minimum = frame.map_or(0.0, |frame| frame.projection_min as f32);
let span = frame.map_or(1.0, |frame| frame.projection_span as f32);
(2u32, *color2, r.cos(), r.sin(), minimum, span)
}
}
HatchPattern::Pattern(fams) => {
@@ -635,42 +640,6 @@ impl LineFamilyGpu {
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Project every boundary vertex onto the gradient direction
/// `(cos θ, sin θ)` and return the (min, max) projection. Used to set
/// up the gradient's normalized parameter range. Same math as the
/// per-hatch path; duplicated here to keep this module self-contained.
fn boundary_projection_range(boundary: &[[f32; 2]], theta: f32) -> (f32, f32) {
let (cs, sn) = (theta.cos(), theta.sin());
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for &[x, y] in boundary {
if !x.is_finite() || !y.is_finite() {
continue;
}
let p = x * cs + y * sn;
if p < lo { lo = p; }
if p > hi { hi = p; }
}
if !lo.is_finite() {
return (0.0, 1.0);
}
(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.
+27 -30
View File
@@ -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.
@@ -191,7 +191,6 @@ impl TextureHatch {
}
let origin = model.world_origin;
let drift = [0.0f32, 0.0f32];
let (mesh_points, indices) = model.fill_mesh();
if mesh_points.is_empty() || indices.is_empty() {
return None;
@@ -239,33 +238,31 @@ impl TextureHatch {
});
// ── Gradient projection range (snapped-local space) ───────────────
let (grad_min, grad_range) = if mode == 2 {
let projs: Vec<f32> = model
.boundary
.iter()
.filter(|v| v[0].is_finite() && v[1].is_finite())
.map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin)
.collect();
if projs.is_empty() {
(0.0, 1.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))
}
} 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)
let base_mode = mode & 0xFF;
let frame = match &model.pattern {
HatchPattern::Gradient {
angle_deg, shift, ..
} => model.gradient_frame(*angle_deg, *shift),
_ => None,
};
let (grad_min, grad_range, radial_center) = if base_mode == 2 {
frame.map_or((0.0, 1.0, [0.0, 0.0]), |frame| {
(
frame.projection_min as f32,
frame.projection_span as f32,
[0.0, 0.0],
)
})
} else if base_mode == 3 {
frame.map_or((0.0, 1.0, [0.0, 0.0]), |frame| {
(
0.0,
frame.radius as f32,
[frame.center[0] as f32, frame.center[1] as f32],
)
})
} else {
(0.0, 1.0)
(0.0, 1.0, [0.0, 0.0])
};
// ── Pack the data texture: families | dashes ─────────────────────
@@ -355,8 +352,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],
+2
View File
@@ -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);
}
}
}