feat(plugin): harden V4 host integration

Preserve legacy wire indices and route concurrent V4 responses and background requests by correlation and stable tab ID.
This commit is contained in:
Hakan Seven committed 2026-08-15 20:17:43 +03:00
commit f247d15f6e
110 files changed
+32159 -1254

No files matched your search

+22 -2
View File
@@ -480,7 +480,15 @@ pub fn annotative_offscale_for(
}),
}
}
pub(crate) fn annotation_scale_handles_for_entity(
doc: &CadDocument,
entity_handle: Handle,
) -> Vec<Handle> {
object_scale_memberships(doc, entity_handle)
.into_iter()
.map(|(_, scale_handle)| scale_handle)
.collect()
}
pub fn scale_handle_by_name(doc: &CadDocument, name: &str) -> Option<Handle> {
doc.objects.iter().find_map(|(handle, object)| match object {
ObjectType::Scale(scale)
@@ -662,7 +670,19 @@ pub fn effective_annotation_scale_for(
if !context_annotative && (text_like || !style_annotative) {
return 1.0;
}
// Annotative TEXT/MTEXT store their paper text height as the base value.
// `fallback` is the absolute paper-to-model multiplier for the active
// annotation scale and already includes the drawing's INSUNITS conversion.
//
// Example for a metre drawing with 2 mm paper text:
// 1:1 -> 2 * 0.001 = 0.002 m
// 1:100 -> 2 * 0.100 = 0.200 m
//
// Using active/native here would only produce 1 / 100 and would lose the
// millimetre-to-metre conversion entirely.
if matches!(entity, EntityType::Text(_) | EntityType::MText(_)) {
return fallback;
}
if matches!(
entity,
EntityType::Dimension(_)
+307 -34
View File
@@ -1,6 +1,9 @@
use super::*;
use cadkernel::geom2d::{bounded_faces, Line, Tolerance};
use cadkernel::geom2d::{
bounded_faces, contains, distance_to, segment_crossing, triangulate, Curve, Line,
SegmentCrossing, Tolerance,
};
/// How far apart two points may be and still be taken for the same one.
///
@@ -10,7 +13,292 @@ use cadkernel::geom2d::{bounded_faces, Line, Tolerance};
/// enough not to weld genuinely separate corners together.
const WELD_TOLERANCE: f64 = 1.0e-6;
fn wire_segments(wire: &WireModel) -> Vec<Line> {
let mut segments = Vec::new();
let mut previous: Option<[f64; 2]> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
if !high[0].is_finite() || !high[1].is_finite() {
previous = None;
continue;
}
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let current = [
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
];
if let Some(start) = previous {
let (dx, dy) = (current[0] - start[0], current[1] - start[1]);
if dx.hypot(dy) > WELD_TOLERANCE {
segments.push(Line { start, end: current });
}
}
previous = Some(current);
}
segments
}
fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> {
let mut ring = Vec::new();
let mut index = 0usize;
for &[x, y] in model.boundary.iter() {
if x.is_finite() && y.is_finite() {
if index == wanted {
ring.push([
model.world_origin[0] + x as f64,
model.world_origin[1] + y as f64,
]);
}
} else if index == wanted {
break;
} else {
index += 1;
}
}
if ring.len() < 3 {
return None;
}
let (points, triangles) = triangulate(&ring, &[]);
if let Some(triangle) = triangles.first() {
let [a, b, c] = triangle.map(|vertex| points[vertex]);
return Some([
(a[0] + b[0] + c[0]) / 3.0,
(a[1] + b[1] + c[1]) / 3.0,
]);
}
Some([
ring.iter().map(|point| point[0]).sum::<f64>() / ring.len() as f64,
ring.iter().map(|point| point[1]).sum::<f64>() / ring.len() as f64,
])
}
fn face_curves(face: &[[f64; 2]]) -> Vec<Curve> {
face.iter()
.copied()
.zip(face.iter().copied().cycle().skip(1))
.take(face.len())
.map(|(start, end)| Curve::Line(Line { start, end }))
.collect()
}
fn matching_face(faces: &[Vec<[f64; 2]>], seed: Option<[f64; 2]>) -> Option<&Vec<[f64; 2]>> {
if faces.len() == 1 {
return faces.first();
}
let seed = seed?;
let tolerance = Tolerance::new(WELD_TOLERANCE);
if let Some(face) = faces.iter().find(|face| {
let curves = face_curves(face);
contains(&curves, seed, tolerance)
}) {
return Some(face);
}
faces.iter().min_by(|a, b| {
let nearest = |face: &Vec<[f64; 2]>| {
face_curves(face)
.iter()
.map(|curve| distance_to(curve, seed))
.fold(f64::INFINITY, f64::min)
};
nearest(a).total_cmp(&nearest(b))
})
}
pub(crate) fn ring_source_handles(
ring: &[[f64; 2]],
sources: &rustc_hash::FxHashMap<acadrust::Handle, Vec<Line>>,
) -> Vec<acadrust::Handle> {
let mut handles = rustc_hash::FxHashSet::default();
for (&start, &end) in ring
.iter()
.zip(ring.iter().cycle().skip(1))
.take(ring.len())
{
let edge = Line { start, end };
let edge_length = (end[0] - start[0]).hypot(end[1] - start[1]);
for (&handle, lines) in sources {
if lines.iter().any(|line| {
matches!(
segment_crossing(edge, *line, Tolerance::new(WELD_TOLERANCE)),
SegmentCrossing::Overlap { a, .. }
if (a[1] - a[0]).abs() * edge_length > WELD_TOLERANCE
)
}) {
handles.insert(handle);
}
}
}
let mut handles: Vec<_> = handles.into_iter().collect();
handles.sort_by_key(|handle| handle.value());
handles
}
pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec<acadrust::EntityType> {
rings
.iter()
.filter_map(|ring| {
let mut points = Vec::with_capacity(ring.len());
for &point in ring {
if point.iter().all(|value| value.is_finite())
&& points.last() != Some(&point)
{
points.push(point);
}
}
if points.len() > 1 && points.first() == points.last() {
points.pop();
}
if points.len() < 3 {
return None;
}
let mut polyline = acadrust::entities::LwPolyline::new();
polyline.is_closed = true;
polyline.vertices = points
.into_iter()
.map(|[x, y]| {
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(x, y))
})
.collect();
Some(acadrust::EntityType::LwPolyline(polyline))
})
.collect()
}
impl Scene {
fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec<Line> {
self.wire_models_for(handles)
.iter()
.flat_map(wire_segments)
.collect()
}
fn associative_hatch_dependents(
&self,
changed: &rustc_hash::FxHashSet<Handle>,
) -> Vec<Handle> {
if self.associative_hatch_source_cache.borrow().is_none() {
let mut index: HashMap<Handle, Vec<Handle>> = HashMap::default();
for entity in self.document.entities() {
let EntityType::Hatch(hatch) = entity else {
continue;
};
if !hatch.is_associative {
continue;
}
for source in hatch
.paths
.iter()
.flat_map(|path| path.boundary_handles.iter().copied())
{
let dependents = index.entry(source).or_default();
if !dependents.contains(&hatch.common.handle) {
dependents.push(hatch.common.handle);
}
}
}
*self.associative_hatch_source_cache.borrow_mut() = Some(index);
}
let cache = self.associative_hatch_source_cache.borrow();
let index = cache.as_ref().expect("associative hatch index");
let mut handles = rustc_hash::FxHashSet::default();
for source in changed {
if let Some(dependents) = index.get(source) {
handles.extend(dependents.iter().copied());
}
}
handles.into_iter().collect()
}
pub(crate) fn refresh_associative_hatches(
&mut self,
changes: &[(Handle, ChangeKind)],
) -> Vec<(Handle, ChangeKind)> {
if changes.is_empty() {
return Vec::new();
}
let changed: rustc_hash::FxHashSet<_> =
changes.iter().map(|(handle, _)| *handle).collect();
let candidates: Vec<_> = self
.associative_hatch_dependents(&changed)
.into_iter()
.filter_map(|handle| {
let EntityType::Hatch(hatch) = self.document.get_entity(handle)? else {
return None;
};
Some({
let seeds = self
.hatches
.get(&hatch.common.handle)
.map(|model| {
(0..hatch.paths.len())
.map(|index| ring_seed(model, index))
.collect::<Vec<_>>()
})
.unwrap_or_default();
(handle, hatch.clone(), seeds)
})
})
.collect();
let mut refreshed = Vec::new();
for (handle, mut hatch, seeds) in candidates {
let normal = hatch.normal;
if normal.x.abs() > 1.0e-8 || normal.y.abs() > 1.0e-8 {
continue;
}
let mut modified = false;
let mut association_changed = false;
for (index, path) in hatch.paths.iter_mut().enumerate() {
if !path
.boundary_handles
.iter()
.any(|source| changed.contains(source))
{
continue;
}
let old_count = path.boundary_handles.len();
path.boundary_handles
.retain(|source| self.document.get_entity(*source).is_some());
association_changed |= path.boundary_handles.len() != old_count;
modified |= association_changed;
let segments = self.associative_boundary_segments(&path.boundary_handles);
let faces = bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE));
let Some(face) = matching_face(&faces, seeds.get(index).copied().flatten()) else {
continue;
};
path.edges = vec![acadrust::entities::hatch::BoundaryEdge::Polyline(
acadrust::entities::hatch::PolylineEdge::new(
face.iter()
.map(|point| acadrust::types::Vector2::new(point[0], point[1]))
.collect(),
true,
),
)];
modified = true;
}
hatch.is_associative = hatch
.paths
.iter()
.any(|path| !path.boundary_handles.is_empty());
if !modified {
continue;
}
if association_changed {
self.associative_hatch_source_cache.borrow_mut().take();
}
if self.is_recording_undo() {
let before = self.document.get_entity_arc(handle);
self.record_undo_before(handle, before);
}
if let Some(slot) = self.document.get_entity_mut(handle) {
*slot = EntityType::Hatch(hatch);
self.refresh_fill_model(handle);
refreshed.push((handle, ChangeKind::Modified));
}
}
refreshed
}
/// Build closed planar regions from the visible wire geometry.
///
/// Unlike `closed_outlines()`, the source entities do not need to be closed
@@ -30,41 +318,26 @@ impl Scene {
let mut segments = Vec::<Line>::new();
for wire in self.entity_wires().iter() {
let mut previous: Option<[f64; 2]> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
// NaNs delimit independent segments inside some WireModels,
// notably polylines stored as A-B | B-C | C-D.
if !high[0].is_finite() || !high[1].is_finite() {
previous = None;
continue;
}
// The wire carries its coordinates as a double-single pair, so
// both halves are needed to recover the f64 the tessellation
// produced. Reading only the high half would put every vertex
// of a survey-coordinate drawing on a grid coarser than the
// weld tolerance.
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let current = [
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
];
if let Some(start) = previous {
let (dx, dy) = (current[0] - start[0], current[1] - start[1]);
if dx.hypot(dy) > WELD_TOLERANCE {
segments.push(Line {
start,
end: current,
});
}
}
previous = Some(current);
}
segments.extend(wire_segments(wire));
}
bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE))
}
/// Tessellated boundary segments grouped by their selectable entity.
pub fn hatch_boundary_sources(
&self,
) -> rustc_hash::FxHashMap<acadrust::Handle, Vec<Line>> {
let mut sources = rustc_hash::FxHashMap::default();
for wire in self.entity_wires().iter() {
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
continue;
};
sources
.entry(handle)
.or_insert_with(Vec::new)
.extend(wire_segments(wire));
}
sources
}
}
+168 -62
View File
@@ -306,6 +306,14 @@ impl Scene {
handle
}
pub fn rename_layer(&mut self, old: &str, new: &str) -> bool {
if self.document.rename_layer(old, new).is_err() {
return false;
}
self.invalidate_dependency_index();
true
}
/// Rename a block definition: re-key its record, update the Block marker's
/// name, and repoint every INSERT that referenced the old name so all
/// instances keep resolving. Returns false if `old` is missing or
@@ -737,7 +745,12 @@ impl Scene {
let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO);
acadrust::Entity::apply_transform(&mut insert, block_to_world);
Ok(self.add_entity(EntityType::Insert(insert)))
let insert_handle = self.add_entity(EntityType::Insert(insert));
// A new block definition landed in the document; advance the block
// epoch so consumers (the block palette stale check) notice it even
// when the panel stays open. Mirrors `define_block_from_owned_entities`.
self.bump_geometry();
Ok(insert_handle)
}
/// Define a new block named `name` from `entities` (owned, not yet in the
@@ -1401,6 +1414,8 @@ impl Scene {
render_instance,
boundary: Arc::new(boundary),
boundary_wcs: None,
boundary_exterior: None,
boundary_sources: None,
pattern: model::hatch_model::HatchPattern::Solid,
name: "WIPEOUT_FILL".into(),
color,
@@ -1505,6 +1520,7 @@ impl Scene {
}
let mut boundary: Vec<[f64; 2]> = Vec::new();
let mut boundary_exterior = Vec::new();
for path in &dxf.paths {
// Skip TEXTBOX boundary paths (flag bit 3). These are text
@@ -1538,6 +1554,7 @@ impl Scene {
boundary.truncate(before_path);
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();
@@ -1550,6 +1567,11 @@ impl Scene {
if boundary.is_empty() {
return None;
}
if !boundary_exterior.iter().any(|role| *role) {
if let Some(first) = boundary_exterior.first_mut() {
*first = true;
}
}
// The batched hatch renderer keeps boundaries in a GPU storage
// buffer (no fixed length), so a hatch with many island loops must
// retain *every* loop or even-odd island detection breaks. The old
@@ -1744,6 +1766,14 @@ impl Scene {
render_instance: None,
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(),
)),
pattern,
name,
// A gradient starts from its first stop; other fills use the
@@ -2058,6 +2088,8 @@ impl Scene {
render_instance: None,
boundary: std::sync::Arc::new(boundary),
boundary_wcs: None,
boundary_exterior: None,
boundary_sources: None,
pattern: model::hatch_model::HatchPattern::Solid,
name: "SOLID".into(),
color,
@@ -2070,79 +2102,148 @@ impl Scene {
}
}
pub fn add_hatch(&mut self, model: HatchModel, layer: Option<&str>) -> Handle {
pub fn add_hatch(
&mut self,
model: HatchModel,
layer: Option<&str>,
entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>,
) -> Handle {
let mut dxf = DxfHatch::new();
dxf.is_solid = matches!(
model.pattern,
crate::scene::model::hatch_model::HatchPattern::Solid
);
// Prefer the command-supplied exact f64 boundary so a typed vertex is
// persisted without f32 quantization (issue #311). Falling back to the
// render-side `boundary`, the points arrive in local render space
// (world_offset already subtracted), so add `world_origin` back —
// otherwise the boundary wire lands `world_offset` away from the fill.
// Build one DXF boundary path per ring. The command layer separates
// the outer boundary from its holes with NaN sentinels in
// `boundary_wcs`; split on those so nested hatches (e.g. a small
// rectangle inside a big one) persist with real holes instead of a
// single self-intersecting polyline. Only the FIRST ring carries the
// external / outermost flags — every later ring is a hole and must not
// be flagged external, or DXF/DWG consumers treat the inner loop as
// another outer island rather than a hole.
if let Some(wcs) = &model.boundary_wcs {
let mut ring: Vec<Vector2> = Vec::new();
let mut first = true;
let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool| {
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));
dxf.paths.push(path);
}
};
for &[x, y] in wcs.iter() {
if x.is_finite() && y.is_finite() {
ring.push(Vector2::new(x, y));
} else {
let is_outer = first;
first = false;
push_ring(&mut ring, is_outer);
}
}
push_ring(&mut ring, first);
}
if dxf.paths.is_empty() {
let wx = model.world_origin[0];
let wy = model.world_origin[1];
let verts: Vec<Vector2> = model
// Prefer exact command geometry; otherwise reconstruct every ring from
// the render offsets without dropping its separators.
// Build one DXF path per NaN-separated ring and retain each outer/hole role.
let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() {
let [wx, wy] = model.world_origin;
model
.boundary
.iter()
.filter(|v| v[0].is_finite() && v[1].is_finite())
.map(|&[x, y]| Vector2::new(x as f64 + wx, y as f64 + wy))
.collect();
let edge = PolylineEdge::new(verts, true);
let mut path = BoundaryPath::external();
path.add_edge(BoundaryEdge::Polyline(edge));
dxf.paths.push(path);
.map(|&[x, y]| {
if x.is_finite() && y.is_finite() {
[x as f64 + wx, y as f64 + wy]
} else {
[f64::NAN, f64::NAN]
}
})
.collect()
} else {
Vec::new()
};
let wcs = model
.boundary_wcs
.as_deref()
.map(|points| points.as_slice())
.unwrap_or(reconstructed_wcs.as_slice());
let mut ring: Vec<Vector2> = Vec::new();
let mut first = true;
let mut ring_index = 0usize;
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
.boundary_sources
.as_deref()
.and_then(|sources| sources.get(index))
{
for &handle in handles {
if handle.is_valid() {
path.add_boundary_handle(handle);
}
}
}
dxf.paths.push(path);
}
};
for &[x, y] in wcs {
if x.is_finite() && y.is_finite() {
ring.push(Vector2::new(x, y));
} else {
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
first = false;
if !ring.is_empty() {
push_ring(&mut ring, is_outer, ring_index);
ring_index += 1;
}
}
}
if let Some(entry) = crate::scene::model::hatch_patterns::find(&model.name) {
dxf.pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry);
}
dxf.pattern_angle = model.angle_offset as f64;
dxf.pattern_scale = if model.scale.abs() > 1e-6 {
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
push_ring(&mut ring, is_outer, ring_index);
dxf.is_associative = dxf
.paths
.iter()
.any(|path| !path.boundary_handles.is_empty());
let pattern_scale = if model.scale.abs() > 1e-6 {
model.scale as f64
} else {
1.0
};
if let crate::scene::model::hatch_model::HatchPattern::Pattern(families) = &model.pattern {
let mut pattern = acadrust::entities::HatchPattern::new(&model.name);
let rotation = model.angle_offset as f64;
let (rotation_sin, rotation_cos) = rotation.sin_cos();
for family in families {
let family_angle = (family.angle_deg as f64).to_radians();
let angle = family_angle + rotation;
let (family_sin, family_cos) = family_angle.sin_cos();
let local_offset_x = family.dx as f64 * family_cos
- family.dy as f64 * family_sin;
let local_offset_y = family.dx as f64 * family_sin
+ family.dy as f64 * family_cos;
let base_x = family.x0 as f64 * pattern_scale;
let base_y = family.y0 as f64 * pattern_scale;
pattern.lines.push(acadrust::entities::HatchPatternLine {
angle,
base_point: Vector2::new(
model.world_origin[0]
+ base_x * rotation_cos
- base_y * rotation_sin,
model.world_origin[1]
+ base_x * rotation_sin
+ base_y * rotation_cos,
),
offset: Vector2::new(
(local_offset_x * rotation_cos
- local_offset_y * rotation_sin)
* pattern_scale,
(local_offset_x * rotation_sin
+ local_offset_y * rotation_cos)
* pattern_scale,
),
dash_lengths: family
.dashes
.iter()
.map(|dash| *dash as f64 * pattern_scale)
.collect(),
});
}
dxf.pattern = pattern;
}
dxf.pattern_angle = model.angle_offset as f64;
dxf.pattern_scale = pattern_scale;
// 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
@@ -2198,6 +2299,10 @@ impl Scene {
if let Some(layer) = layer {
entity.as_entity_mut().set_layer(layer.to_string());
}
if let Some((color, transparency)) = entity_style {
entity.common_mut().color = color;
entity.common_mut().transparency = transparency;
}
self.add_entity(entity)
}
@@ -2210,6 +2315,7 @@ impl Scene {
self.preview_text = vec![];
self.current_layout = "Model".to_string();
self.hatches = HashMap::default();
self.associative_hatch_source_cache.borrow_mut().take();
self.images = HashMap::default();
self.meshes = HashMap::default();
self.block_meshes = HashMap::default();
+54 -17
View File
@@ -33,6 +33,8 @@ mod project;
mod scene_markers;
mod selection;
pub(crate) use boundary::{boundary_entities, ring_source_handles};
// Parallel tessellation free functions live in `convert::tess` (alongside the
// other tessellation code); re-exported here so this root and sibling topic
// modules (each does `use super::*`) keep referencing them unqualified.
@@ -147,6 +149,7 @@ use acadrust::entities::{
BoundaryEdge, BoundaryPath, Hatch as DxfHatch, PolylineEdge, Solid as DxfSolid,
};
use acadrust::objects::ObjectType;
use acadrust::tables::normalize_name;
use acadrust::types::Vector2;
use acadrust::{CadDocument, EntityType, Handle, TableEntry};
use glam;
@@ -872,10 +875,12 @@ pub fn prepare_open_geometry(
scene.local_center = caches.local_center;
scene.bg_color = model_bg;
let cannoscale_value = scene.document.header.annotation_scale_value;
let unit_factor = scene.annotation_scale_unit_factor();
scene.annotation_scale = if cannoscale_value > 1e-9 {
(1.0 / cannoscale_value) as f32
((1.0 / cannoscale_value) / unit_factor) as f32
} else {
1.0
(1.0 / unit_factor) as f32
};
scene.current_layout = "Model".to_string();
let camera = scene.camera.borrow().clone();
@@ -1675,6 +1680,9 @@ pub struct Scene {
/// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate
/// only its dependants, and avoid a whole-document scan on every toggle.
dependency_index_cache: RefCell<Option<SceneDependencyIndex>>,
/// Boundary source → associative hatch handles. Source edits reuse this
/// index instead of scanning the document during every drag step.
associative_hatch_source_cache: RefCell<Option<HashMap<Handle, Vec<Handle>>>>,
/// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours
/// differently; retaining both variants prevents a full block rebuild on
@@ -1891,6 +1899,7 @@ impl Scene {
model_extents_cache: RefCell::new(None),
entity_block_map_cache: RefCell::new(None),
dependency_index_cache: RefCell::new(None),
associative_hatch_source_cache: RefCell::new(None),
block_defn_cache: RefCell::new(HashMap::default()),
entity_index_cache: RefCell::new(None),
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
@@ -2366,6 +2375,21 @@ impl Scene {
}
pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) {
if changes.iter().any(|(handle, kind)| {
matches!(kind, ChangeKind::Removed)
|| self
.document
.get_entity(*handle)
.is_some_and(|entity| matches!(entity, EntityType::Hatch(_)))
}) {
self.associative_hatch_source_cache.borrow_mut().take();
}
let mut changes = changes.to_vec();
for change in self.refresh_associative_hatches(&changes) {
if !changes.iter().any(|(handle, _)| *handle == change.0) {
changes.push(change);
}
}
if !changes.is_empty() {
// A Modified delta can change layer/style/block references as well
// as coordinates. Rebuild the reverse dependency map lazily on its
@@ -2384,14 +2408,14 @@ impl Scene {
.is_some_and(|entity| matches!(entity, EntityType::Light(_)))
});
if cached_light_changed || live_light_changed {
for (handle, _) in changes {
for &(handle, _) in &changes {
let exists = self
.document
.get_entity(*handle)
.get_entity(handle)
.is_some_and(|entity| matches!(entity, EntityType::Light(_)));
crate::entities::object_data::update_light_entity(
&mut self.object_data_cache,
*handle,
handle,
exists,
);
}
@@ -2402,7 +2426,7 @@ impl Scene {
{
let mut tm = self.tess_memo.borrow_mut();
let mut rm = self.resident_tess_memo.borrow_mut();
for &(h, k) in changes {
for &(h, k) in &changes {
// A changed or removed entity must re-tessellate; a fresh Add has
// no memo entry yet, so there is nothing to drop.
if matches!(k, ChangeKind::Modified | ChangeKind::Removed) {
@@ -2411,7 +2435,7 @@ impl Scene {
}
}
}
self.push_geometry_delta(epoch, changes.to_vec(), false);
self.push_geometry_delta(epoch, changes, false);
}
/// True when any changed handle belongs to an ordinary block definition
@@ -3179,6 +3203,8 @@ impl Scene {
world_origin: [0.0, 0.0],
boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]),
boundary_wcs: None,
boundary_exterior: None,
boundary_sources: None,
pattern: crate::scene::model::hatch_model::HatchPattern::Solid,
name: "SOLID".to_string(),
color: self.paper_bg_color,
@@ -3553,7 +3579,7 @@ impl Scene {
/// The built-in scale factors are defined assuming model and paper use
/// the same base unit. A drawing in metres therefore needs an extra
/// factor of 1000 when its paper side is measured in millimetres.
fn annotation_scale_unit_factor(&self) -> f64 {
pub(crate) fn annotation_scale_unit_factor(&self) -> f64 {
let paper_unit = if self.prefers_imperial_scales() == Some(true) {
1 // Inches
} else {
@@ -3836,14 +3862,25 @@ impl Scene {
pub fn set_annotation_scale_named(&mut self, name: &str) -> Option<Handle> {
let handle = self.scale_handle_ensuring(name)?;
let ObjectType::Scale(scale) = self.document.objects.get(&handle)? else {
return None;
let unit_factor = self.annotation_scale_unit_factor();
let (scale_name, scale_factor, multiplier) = {
let ObjectType::Scale(scale) = self.document.objects.get(&handle)? else {
return None;
};
(
scale.name.clone(),
scale.factor(),
scale.inverse_factor() / unit_factor,
)
};
let multiplier = scale.inverse_factor();
self.annotation_scale = multiplier as f32;
self.document.header.current_annotation_scale = scale.name.clone();
self.document.header.annotation_scale_value = scale.factor();
self.document.header.current_annotation_scale = scale_name;
self.document.header.annotation_scale_value = scale_factor;
self.invalidate_annotation_dependencies();
Some(handle)
}
@@ -8555,7 +8592,7 @@ impl Scene {
.document
.block_records
.iter()
.map(|record| (record.handle, record.name.to_ascii_uppercase()))
.map(|record| (record.handle, normalize_name(&record.name)))
.collect();
let membership: HashMap<Handle, Handle> = self
.document
@@ -8582,7 +8619,7 @@ impl Scene {
let EntityType::Insert(insert) = entity else {
continue;
};
let target = insert.block_name.to_ascii_uppercase();
let target = normalize_name(&insert.block_name);
let common = &insert.common;
let owner = if common.owner_handle.is_null() {
membership
@@ -8666,7 +8703,7 @@ impl Scene {
extend_category(&mut index.annotation_geometry);
}
let add = |map: &mut HashMap<String, DependencyTargets>, name: &str| {
let target = map.entry(name.to_ascii_uppercase()).or_default();
let target = map.entry(normalize_name(name)).or_default();
target.render_handles.extend(render_handles.iter().copied());
target.source_handles.insert(common.handle);
target.touches_block_definition |= inside_block;
@@ -8805,7 +8842,7 @@ impl Scene {
};
let mut combined = DependencyTargets::default();
for name in names {
let Some(target) = map.get(&name.to_ascii_uppercase()) else {
let Some(target) = map.get(&normalize_name(name)) else {
continue;
};
combined
+40 -5
View File
@@ -1,8 +1,5 @@
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
//
// The boundary is a closed polygon in world XY coordinates.
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
// no line geometry is ever tessellated on the CPU.
// CPU-side hatch fill data. The kernel triangulates the boundary and the GPU
// evaluates the pattern inside that mesh.
use std::sync::Arc;
@@ -183,6 +180,10 @@ pub struct HatchModel {
/// rebuilt from a DXF entity — `add_hatch` then reconstructs the persisted
/// vertices from `boundary` + `world_origin` instead.
pub boundary_wcs: Option<Arc<Vec<[f64; 2]>>>,
/// Per-ring DXF role, aligned with the NaN-separated boundary paths.
pub boundary_exterior: Option<Arc<Vec<bool>>>,
/// Source entity handles for each boundary ring.
pub boundary_sources: Option<Arc<Vec<Vec<acadrust::Handle>>>>,
/// Fill pattern.
pub pattern: HatchPattern,
/// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR").
@@ -207,6 +208,40 @@ pub struct HatchModel {
}
impl HatchModel {
/// 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();
let mut ring = Vec::new();
for &[x, y] in self.boundary.iter() {
if x.is_finite() && y.is_finite() {
ring.push([x as f64, y as f64]);
} else if ring.len() >= 3 {
rings.push(std::mem::take(&mut ring));
} else {
ring.clear();
}
}
if ring.len() >= 3 {
rings.push(ring);
}
let (points, triangles) = cadkernel::geom2d::triangulate_rings(&rings);
let vertices = points
.into_iter()
.map(|[x, y]| [x as f32, y as f32])
.collect();
let mut indices = Vec::with_capacity(triangles.len() * 3);
for triangle in triangles {
for index in triangle {
let Ok(index) = u32::try_from(index) else {
return (Vec::new(), Vec::new());
};
indices.push(index);
}
}
(vertices, indices)
}
/// CPU-side rasteriser for `HatchPattern::Pattern` — produces the line
/// segments inside the boundary so non-GPU consumers (PDF export,
/// `paper_canvas`, print preview) can draw the actual pattern instead
+141 -3
View File
@@ -551,11 +551,43 @@ impl Scene {
}
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
let copy_handles = self.handles_expanded_for_leader_annotations(handles);
// LEADER + attached MTEXT are a logical pair. Their entity clones must not
// retain the source extension dictionary, otherwise both copies share the
// same annotation-context objects.
let leader_pair_handles: Vec<Handle> = copy_handles
.iter()
.flat_map(|&handle| {
let annotation = match self.document.get_entity(handle) {
Some(EntityType::Leader(leader)) if !leader.annotation_handle.is_null() => {
Some(leader.annotation_handle)
}
_ => None,
};
std::iter::once(handle).chain(annotation)
})
.collect();
// Objects on a locked layer can be selected but not copied.
let clones: Vec<(Handle, EntityType)> = handles
let clones: Vec<(Handle, EntityType, Vec<Handle>)> = copy_handles
.iter()
.filter(|&&h| !self.is_layer_locked(h))
.filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e)))
.filter_map(|&h| {
let entity = self.document.get_entity(h)?.clone();
let annotation_scales = if leader_pair_handles.contains(&h) {
crate::scene::annotative::annotation_scale_handles_for_entity(
&self.document,
h,
)
} else {
Vec::new()
};
Some((h, entity, annotation_scales))
})
.collect();
// MIRRTEXT also governs the copy path (default MIRROR keeps the source
// and adds a mirrored copy): keep the copied text right-reading when the
@@ -567,7 +599,7 @@ impl Scene {
let mut new_handles = Vec::with_capacity(clones.len());
let mut handle_map = rustc_hash::FxHashMap::default();
let mut refresh_solid_handles = Vec::new();
for (src_handle, mut entity) in clones {
for (src_handle, mut entity, annotation_scales) in clones {
let text_orient = if preserve_text_orientation {
capture_text_orient(&entity)
} else {
@@ -598,9 +630,31 @@ impl Scene {
}
}
Self::reset_clone_subhandles(&mut self.document, &mut entity);
// An annotative LEADER/MTEXT pair must receive a fresh extension dictionary.
// Keeping this handle would make the copy share the source's context tree.
if !annotation_scales.is_empty() {
entity.common_mut().xdictionary_handle = None;
}
entity.common_mut().handle = Handle::NULL;
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
if !h.is_null() {
if !annotation_scales.is_empty() {
for scale_handle in annotation_scales {
crate::scene::annotative::create_annotation_context(
&mut self.document,
h,
scale_handle,
);
}
// Annotation contexts add dictionary/object records outside the entity
// delta itself, so keep undo on the safe full-snapshot path.
if self.is_recording_undo() {
self.poison_undo_recording();
}
}
// Delta-undo: a copy's before-image is "nothing" (undo erases it).
if self.is_recording_undo() {
self.record_undo_before(h, None);
@@ -640,7 +694,35 @@ impl Scene {
handle_map.insert(src_handle, h);
}
}
// A copied LEADER must reference the copied annotation, never the
// source annotation. Both entities now exist, so remap the stored handle.
let leader_links: Vec<(Handle, Handle)> = handle_map
.iter()
.filter_map(|(&source_handle, &copied_handle)| {
let EntityType::Leader(source_leader) =
self.document.get_entity(source_handle)?
else {
return None;
};
let copied_annotation = handle_map
.get(&source_leader.annotation_handle)
.copied()
.unwrap_or(Handle::NULL);
Some((copied_handle, copied_annotation))
})
.collect();
for (leader_handle, annotation_handle) in leader_links {
if let Some(EntityType::Leader(leader)) =
self.document.get_entity_mut(leader_handle)
{
leader.annotation_handle = annotation_handle;
}
let _ = self.sync_displayed_annotation_context(leader_handle);
}
// Complete group copies record their new Group objects and dictionary
// entry as targeted object deltas inside copy_complete_groups.
self.copy_complete_groups(&handle_map);
@@ -956,10 +1038,66 @@ impl Scene {
.get_entity(handle)
.and_then(crate::entities::solid3d::point_of_reference)
.map(|p| [p.x, p.y, p.z]);
// A LEADER's final vertex is the end of its horizontal landing.
// Remember its old position and linked MTEXT so the annotation can follow
// when that grip stretches the landing.
let leader_landing_before = self.document.get_entity(handle).and_then(|entity| {
let EntityType::Leader(leader) = entity else {
return None;
};
let n = leader.vertices.len();
if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() {
return None;
}
let point = leader.vertices.last()?;
Some((
leader.annotation_handle,
glam::DVec3::new(point.x, point.y, point.z),
))
});
if let Some(entity) = self.document.get_entity_mut(handle) {
view::dispatch::apply_grip(entity, grip_id, apply);
}
if let Some((annotation_handle, old_landing)) = leader_landing_before {
let new_landing = self.document.get_entity(handle).and_then(|entity| {
let EntityType::Leader(leader) = entity else {
return None;
};
let point = leader.vertices.last()?;
Some(glam::DVec3::new(point.x, point.y, point.z))
});
if let Some(new_landing) = new_landing {
let delta = new_landing - old_landing;
if delta.length_squared() > 1.0e-20 {
if self.is_recording_undo() {
if let Some(before) = self.document.get_entity_arc(annotation_handle) {
self.record_undo_before(annotation_handle, Some(before));
}
}
if let Some(annotation) = self.document.get_entity_mut(annotation_handle) {
view::dispatch::apply_transform(
annotation,
&crate::command::EntityTransform::Translate(delta),
);
}
if self.sync_displayed_annotation_context(annotation_handle) {
self.poison_undo_recording();
}
self.bump_entities(&[(
annotation_handle,
crate::scene::ChangeKind::Modified,
)]);
}
}
}
if self.sync_displayed_annotation_context(handle) {
self.poison_undo_recording();
}
+1 -1
View File
@@ -692,7 +692,7 @@ fn mesh_click_result<'a>(
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<(f64, Handle, glam::DVec3)> {
let profile = crate::perf::enabled().then(std::time::Instant::now);
let profile = crate::perf::enabled().then(iced::time::Instant::now);
let mut set_count = 0usize;
let mut bound_hits = 0usize;
let mut exact_triangles = 0usize;
+2 -3
View File
@@ -16,9 +16,8 @@ impl DeviceCapabilities {
/// Indexed wires read one per-wire constants storage buffer.
const WIRE_STORAGE_BINDINGS: u32 = 1;
/// Batched hatch uses five storage bindings in one shader stage:
/// instances, boundaries, families, dashes, and visibility.
const HATCH_STORAGE_BINDINGS: u32 = 5;
/// Batched hatch stores instances, families, dashes, and visibility.
const HATCH_STORAGE_BINDINGS: u32 = 4;
pub fn detect(device: &wgpu::Device) -> Self {
Self::from_limits(&device.limits())
+34 -28
View File
@@ -34,8 +34,8 @@ impl HatchBackendKind {
enum HatchBackend {
Storage {
resident: Option<StorageHatchBatch>,
preview: Option<StorageHatchBatch>,
resident: Vec<StorageHatchBatch>,
preview: Vec<StorageHatchBatch>,
},
Texture {
resident: Vec<TextureHatch>,
@@ -137,8 +137,8 @@ impl HatchGpu {
});
let backend = match backend_kind {
HatchBackendKind::Storage => HatchBackend::Storage {
resident: None,
preview: None,
resident: Vec::new(),
preview: Vec::new(),
},
HatchBackendKind::Texture => HatchBackend::Texture {
resident: Vec::new(),
@@ -223,28 +223,29 @@ impl HatchGpu {
queue: &wgpu::Queue,
mut is_visible: impl FnMut([f32; 4]) -> bool,
) -> usize {
let HatchBackend::Storage {
resident: Some(batch),
..
} = &mut self.backend
let HatchBackend::Storage { resident, .. } = &mut self.backend
else {
return 0;
};
for index in 0..batch.unique_source_count {
batch.source_visibility[index] = u32::from(is_visible(batch.source_aabbs[index]));
let mut updated = 0;
for batch in resident {
for index in 0..batch.unique_source_count {
batch.source_visibility[index] = u32::from(is_visible(batch.source_aabbs[index]));
}
for index in batch.unique_source_count..batch.source_visibility.len() {
batch.source_visibility[index] = 1;
}
for (index, aabb) in batch.instance_aabbs.iter().copied().enumerate() {
batch.placements[index].visible = if index == 0 && batch.unique_source_count > 0 {
1
} else {
u32::from(is_visible(aabb))
};
}
batch.upload_visibility(queue);
updated += batch.instance_aabbs.len() + batch.unique_source_count;
}
for index in batch.unique_source_count..batch.source_visibility.len() {
batch.source_visibility[index] = 1;
}
for (index, aabb) in batch.instance_aabbs.iter().copied().enumerate() {
batch.placements[index].visible = if index == 0 && batch.unique_source_count > 0 {
1
} else {
u32::from(is_visible(aabb))
};
}
batch.upload_visibility(queue);
batch.instance_aabbs.len() + batch.unique_source_count
updated
}
pub fn draw<'pass>(
@@ -258,12 +259,13 @@ impl HatchGpu {
pass.set_stencil_reference(stencil_reference);
match &self.backend {
HatchBackend::Storage { resident, preview } => {
for batch in [resident.as_ref(), preview.as_ref()].into_iter().flatten() {
for batch in resident.iter().chain(preview) {
pass.set_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, batch.placement_buffer.slice(..));
for (vertices, instances) in &batch.draws {
pass.draw(vertices.clone(), instances.clone());
pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
for (indices, instances) in &batch.draws {
pass.draw_indexed(indices.clone(), 0, instances.clone());
}
}
}
@@ -272,7 +274,11 @@ impl HatchGpu {
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, hatch.placement_buffer.slice(..));
pass.draw(0..6, 0..hatch.instance_count);
pass.set_index_buffer(
hatch.index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..hatch.index_count, 0, 0..hatch.instance_count);
}
}
}
@@ -294,11 +300,11 @@ mod tests {
#[test]
fn selects_backend_from_device_limits() {
assert_eq!(
HatchBackendKind::select(capabilities(5), false),
HatchBackendKind::select(capabilities(4), false),
HatchBackendKind::Storage
);
assert_eq!(
HatchBackendKind::select(capabilities(4), false),
HatchBackendKind::select(capabilities(3), false),
HatchBackendKind::Texture
);
}
+142 -163
View File
@@ -1,48 +1,5 @@
// Hatch rendering — the single, canonical GPU hatch renderer. One draw
// call for all hatches, per-instance data fetched from storage buffers in
// the vertex shader. There is NO per-family cap here: every pattern line
// family is uploaded (`family_count` per instance) and the fragment shader
// loops over all of them, so complex baked patterns (ROOFING, GRAVEL,
// CAROLINA-LEDGESTONE with dozens–hundreds of families) draw in full,
// matching the PDF export. Unavailable on devices that lack vertex-stage
// storage buffers. The old per-hatch renderer with the
// 16-family cap now lives in `wipeout_gpu.rs` and serves only wipeouts.
//
// Data layout — three storage buffers fed from `HatchModel`s:
//
// InstanceBuffer (binding 0) : HatchInstance[] (128 B each)
// color, color2, mode, gradient,
// pattern angle/scale, world_origin,
// boundary_offset, boundary_count,
// family_offset, family_count,
// dash_offset, dash_count, aabb,
// visibility flag (CPU writes; GPU
// skip)
// BoundaryBuffer (binding 1) : vec4<f32>[] (all boundary
// verts concatenated; NaN markers
// preserved as separators just like
// the per-hatch path)
// FamilyBuffer (binding 2) : LineFamilyGpu[] (all line
// families concatenated)
// DashBuffer (binding 3) : f32[] (all dash
// lengths concatenated)
//
// The vertex buffer holds each hatch's tessellated boundary triangles as
// per-vertex local-space positions (`local_xy`) plus a per-vertex
// `instance_index` (u32 attribute) — instance_index lets us avoid relying
// on `@builtin(instance_index)` for portability. An instance whose
// boundary failed to tessellate instead gets an AABB quad here, with
// `poly_test == 1` on its `HatchInstance` so the fragment shader still
// runs the `in_polygon` test to clip the quad to the real shape; on the
// tessellated fast path (`poly_test == 0`) the triangles already bound
// the fill and the fragment shader skips that test. When the visibility
// flag is 0, the vertex shader returns a degenerate position and the
// fragment shader runs zero times for that instance.
//
// Two storage usages — vertex shader reads InstanceBuffer + Boundary
// for the AABB / boundary range; fragment shader reads
// InstanceBuffer + Boundary + Family + Dash. Both stages share group
// 1 with `read_only` access.
// Batched hatch rendering. The kernel triangulates each boundary once; the
// fragment shader only evaluates the fill pattern inside that mesh.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu;
@@ -70,23 +27,16 @@ pub struct HatchInstance {
pub grad_range: f32, // 84
pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient)
pub visible: u32, // 92 (CPU sets to 0 to skip)
pub boundary_offset: u32, // 96 (first boundary vert index)
pub boundary_count: u32, // 100
pub family_offset: u32, // 104
pub family_count: u32, // 108
pub family_offset: u32, // 96
pub family_count: u32, // 100
/// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z
/// bias in the vertex shader so this fill orders against other types.
pub draw_depth: f32, // 112
/// 1 = run the per-fragment `in_polygon` boundary test (fallback path for
/// an instance whose boundary failed to tessellate); 0 = the rasterized
/// triangles already bound the fill, skip the test.
pub poly_test: u32, // 116
pub draw_depth: f32, // 104
/// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops.
pub grad_kind: u32, // 120
pub _pad2: u32, // 124
pub grad_kind: u32, // 108
}
const _: () = assert!(std::mem::size_of::<HatchInstance>() == 128);
const _: () = assert!(std::mem::size_of::<HatchInstance>() == 112);
/// Split a hatch's f64 world-origin anchor into double-single (high, low) f32
/// pairs so the GPU keeps sub-unit precision at UTM-scale coordinates.
@@ -120,10 +70,7 @@ pub struct LineFamilyGpu {
const _: () = assert!(std::mem::size_of::<LineFamilyGpu>() == 48);
/// Per-vertex data — tessellated boundary triangles in local (world_origin-
/// relative) space, each carrying its instance index (avoids relying on
/// `@builtin(instance_index)` across backends). Instances whose boundary
/// failed to tessellate emit an AABB quad here instead (see `build`).
/// Kernel mesh vertex in local space with its source instance.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct HatchVertex {
@@ -184,6 +131,7 @@ impl HatchPlacement {
/// hatch render pass entirely).
pub(super) struct StorageHatchBatch {
pub vertex_buffer: wgpu::Buffer,
pub index_buffer: wgpu::Buffer,
pub placement_buffer: wgpu::Buffer,
pub draws: Vec<(std::ops::Range<u32>, std::ops::Range<u32>)>,
// The four storage buffers below are referenced via `bind_group` —
@@ -191,7 +139,6 @@ pub(super) struct StorageHatchBatch {
// only direct consumer. Keep them as fields to keep ownership in
// one place; `#[allow(dead_code)]` silences the read-never warning.
#[allow(dead_code)] pub instance_buffer: wgpu::Buffer,
#[allow(dead_code)] pub boundary_buffer: wgpu::Buffer,
#[allow(dead_code)] pub family_buffer: wgpu::Buffer,
#[allow(dead_code)] pub dash_buffer: wgpu::Buffer,
/// Per-instance visibility flag (1=draw, 0=skip). Stored in its
@@ -205,8 +152,7 @@ pub(super) struct StorageHatchBatch {
#[allow(dead_code)] pub instance_count: u32,
/// CPU mirror — `update_visibility` re-uploads this whole slice
/// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches
/// per pan tick. Far cheaper than touching the 128 B-per-instance
/// data.
/// per pan tick. Far cheaper than touching the instance data.
pub placements: Vec<HatchPlacement>,
pub source_visibility: Vec<u32>,
pub source_aabbs: Vec<[f32; 4]>,
@@ -218,24 +164,76 @@ pub(super) struct StorageHatchBatch {
pub instance_aabbs: Vec<[f32; 4]>,
}
fn hatch_buffer_cost(hatch: &HatchModel) -> [u64; 7] {
let boundary = hatch.boundary.len() as u64;
let (families, dashes) = match &hatch.pattern {
HatchPattern::Pattern(families) => (
families.len() as u64,
families.iter().map(|family| family.dashes.len() as u64).sum(),
),
_ => (0, 0),
};
[
boundary.saturating_mul(std::mem::size_of::<HatchVertex>() as u64),
boundary.saturating_mul(24),
std::mem::size_of::<HatchInstance>() as u64,
families.saturating_mul(std::mem::size_of::<LineFamilyGpu>() as u64),
dashes.saturating_mul(std::mem::size_of::<f32>() as u64),
std::mem::size_of::<u32>() as u64,
std::mem::size_of::<HatchPlacement>() as u64,
]
}
impl StorageHatchBatch {
/// One-time build from the full hatch list. Re-uploaded only when
/// `geometry_epoch` advances (mirrors the existing per-hatch
/// upload trigger). Per-frame visibility flips go through
/// [`upload_visibility`].
/// Builds whole-hatch chunks within the device's buffer limits.
pub(super) fn build(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
) -> Vec<Self> {
if hatches.is_empty() {
return None;
return Vec::new();
}
let limits = device.limits();
let chunk_limit = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size as u64);
let mut ranges = Vec::new();
let mut start = 0;
let mut used = [0u64; 7];
for (index, hatch) in hatches.iter().enumerate() {
let cost = hatch_buffer_cost(hatch);
let overflow = used
.iter()
.zip(cost)
.any(|(used, cost)| used.saturating_add(cost) > chunk_limit);
if overflow && index > start {
ranges.push(start..index);
start = index;
used = [0; 7];
}
for (used, cost) in used.iter_mut().zip(cost) {
*used = used.saturating_add(cost);
}
}
ranges.push(start..hatches.len());
ranges
.into_iter()
.filter_map(|range| Self::build_chunk(device, bgl, &hatches[range]))
.collect()
}
fn build_chunk(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
let mut instances: Vec<HatchInstance> = Vec::with_capacity(hatches.len());
let mut boundary: Vec<[f32; 4]> = Vec::new();
let mut families: Vec<LineFamilyGpu> = Vec::new();
let mut dashes: Vec<f32> = Vec::new();
let mut meshes = Vec::with_capacity(hatches.len());
let mut slots = rustc_hash::FxHashMap::default();
let mut groups: Vec<Vec<&HatchModel>> = Vec::new();
for (index, hatch) in hatches.iter().enumerate() {
@@ -255,22 +253,9 @@ impl StorageHatchBatch {
for group in &groups {
let h = group[0];
let boundary_offset = boundary.len() as u32;
for &[x, y] in h.boundary.iter() {
// NaN sub-loop separators become the finite sentinel on the
// GPU: Intel drivers fold the shader NaN self-compare to
// `true`, turning separators into vertices and bleeding the
// fill outside its boundary (#386, #416).
if x.is_finite() && y.is_finite() {
boundary.push([x, y, 0.0, 0.0]);
} else {
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
boundary.push([s, s, 0.0, 0.0]);
}
}
let boundary_count = boundary.len() as u32 - boundary_offset;
let mesh = h.fill_mesh();
let has_mesh = !mesh.0.is_empty() && !mesh.1.is_empty();
meshes.push(mesh);
let family_offset = families.len() as u32;
let mut family_count = 0u32;
@@ -301,11 +286,8 @@ impl StorageHatchBatch {
dashes.push(d);
}
let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX);
// QCAD PAT local-frame convention (mirrors
// the storage batch convention: `dy` is
// the perpendicular spacing, `dx` is the along-line
// phase shift — both in family-local coords. The
// shader applies cos_off/sin_off to rotate them.
// PAT local frame: perpendicular spacing and
// along-line phase.
let perp_step = fam.dy;
let along_step = fam.dx;
// Screen-space derivative drives 1-px line width
@@ -366,39 +348,19 @@ impl StorageHatchBatch {
grad_range,
mode,
visible: 0,
boundary_offset,
boundary_count,
family_offset,
family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 },
poly_test: 1,
grad_kind,
_pad2: 0,
});
continue;
}
// Pad the AABB so the quad covers any pattern halo + the
// family origin. Mirrors the per-hatch shader's quad sizing
// logic — `diag * 0.8 + max_spacing * 2 * scale`.
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
// `perp_step.abs()` per family — uses the same QCAD local-
// frame convention as `LineFamilyGpu.perp_step` above so
// the quad padding matches what the shader will sample.
let max_spacing = match &h.pattern {
HatchPattern::Pattern(fs) => fs
.iter()
.map(|f| f.dy.abs())
.fold(0.0f32, f32::max),
_ => 5.0,
};
let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0);
let (wo_hi, wo_lo) = split_origin_ds(h.world_origin);
instances.push(HatchInstance {
color: h.color,
color2,
aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad],
aabb: [min_x, min_y, max_x, max_y],
world_origin: wo_hi,
world_origin_low: wo_lo,
angle_offset: h.angle_offset,
@@ -408,26 +370,14 @@ impl StorageHatchBatch {
grad_min,
grad_range,
mode,
visible: 1,
boundary_offset,
boundary_count,
visible: u32::from(has_mesh),
family_offset,
family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 },
// Always resolve inside/outside per fragment on the GPU (the
// boundary is uploaded below). Dropping CPU pre-triangulation
// keeps the vertex buffer at 6 verts/hatch regardless of
// boundary complexity, so a hatch-dense drawing can't overflow
// the device buffer limit. Each instance draws its AABB quad.
poly_test: 1,
grad_kind,
_pad2: 0,
});
}
// Empty fallbacks — storage buffers can't be zero-sized.
if boundary.is_empty() {
boundary.push([0.0; 4]);
}
if families.is_empty() {
families.push(LineFamilyGpu::default_filler());
}
@@ -435,21 +385,33 @@ impl StorageHatchBatch {
dashes.push(0.0);
}
// Vertex buffer — one AABB quad (two triangles, BL,BR,TL, BR,TR,TL) per
// instance; the GPU shader clips each fragment to the boundary via the
// in_polygon test (poly_test == 1). 6 verts/hatch, independent of
// boundary complexity, so the buffer can never overflow the device
// limit no matter how dense the drawing.
let mut verts: Vec<HatchVertex> = Vec::with_capacity(instances.len() * 6);
for (i, inst) in instances.iter().enumerate() {
let [xmin, ymin, xmax, ymax] = inst.aabb;
let quad = [
[xmin, ymin], [xmax, ymin], [xmin, ymax],
[xmax, ymin], [xmax, ymax], [xmin, ymax],
];
for c in quad {
verts.push(HatchVertex { local_xy: c, instance_index: i as u32 });
let mut verts = Vec::new();
let mut indices = Vec::new();
let mut mesh_ranges = Vec::with_capacity(meshes.len());
for (instance_index, (points, mesh_indices)) in meshes.into_iter().enumerate() {
let Ok(base) = u32::try_from(verts.len()) else {
return None;
};
let Ok(start) = u32::try_from(indices.len()) else {
return None;
};
verts.extend(points.into_iter().map(|local_xy| HatchVertex {
local_xy,
instance_index: instance_index as u32,
}));
for index in mesh_indices {
let Some(index) = base.checked_add(index) else {
return None;
};
indices.push(index);
}
let Ok(end) = u32::try_from(indices.len()) else {
return None;
};
mesh_ranges.push(start..end);
}
if indices.is_empty() {
return None;
}
let mut placements = Vec::new();
let mut draws = Vec::with_capacity(groups.len());
@@ -461,7 +423,10 @@ impl StorageHatchBatch {
draw_depth: 0.0,
visible: 1,
});
draws.push((0..unique_source_count as u32 * 6, 0..1));
let end = mesh_ranges[unique_source_count - 1].end;
if end > 0 {
draws.push((0..end, 0..1));
}
let mut union = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for inst in instances.iter().take(unique_source_count) {
union[0] = union[0].min(inst.aabb[0] + inst.world_origin[0]);
@@ -500,10 +465,32 @@ impl StorageHatchBatch {
inst.aabb[3] + inst.world_origin[1] + high[1],
]);
}
draws.push((
source_index as u32 * 6..source_index as u32 * 6 + 6,
placement_start..placements.len() as u32,
));
if !mesh_ranges[source_index].is_empty() {
draws.push((
mesh_ranges[source_index].clone(),
placement_start..placements.len() as u32,
));
}
}
let visibility: Vec<u32> = instances.iter().map(|instance| instance.visible).collect();
let limits = device.limits();
let buffer_fits = |count: usize, stride: usize| {
(count as u64).saturating_mul(stride as u64) <= limits.max_buffer_size
};
let storage_fits = |count: usize, stride: usize| {
(count as u64).saturating_mul(stride as u64)
<= limits.max_storage_buffer_binding_size as u64
};
if !buffer_fits(verts.len(), std::mem::size_of::<HatchVertex>())
|| !buffer_fits(indices.len(), std::mem::size_of::<u32>())
|| !buffer_fits(placements.len(), std::mem::size_of::<HatchPlacement>())
|| !storage_fits(instances.len(), std::mem::size_of::<HatchInstance>())
|| !storage_fits(families.len(), std::mem::size_of::<LineFamilyGpu>())
|| !storage_fits(dashes.len(), std::mem::size_of::<f32>())
|| !storage_fits(visibility.len(), std::mem::size_of::<u32>())
{
return None;
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -511,6 +498,11 @@ impl StorageHatchBatch {
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.index"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let placement_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.placements"),
contents: bytemuck::cast_slice(&placements),
@@ -521,11 +513,6 @@ impl StorageHatchBatch {
contents: bytemuck::cast_slice(&instances),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
});
let boundary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.boundary"),
contents: bytemuck::cast_slice(&boundary),
usage: wgpu::BufferUsages::STORAGE,
});
let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.families"),
contents: bytemuck::cast_slice(&families),
@@ -537,7 +524,6 @@ impl StorageHatchBatch {
usage: wgpu::BufferUsages::STORAGE,
});
let visibility: Vec<u32> = instances.iter().map(|i| i.visible).collect();
let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.visibility"),
contents: bytemuck::cast_slice(&visibility),
@@ -554,18 +540,14 @@ impl StorageHatchBatch {
},
wgpu::BindGroupEntry {
binding: 1,
resource: boundary_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: family_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
binding: 2,
resource: dash_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
binding: 3,
resource: visibility_buffer.as_entire_binding(),
},
],
@@ -582,10 +564,10 @@ impl StorageHatchBatch {
Some(Self {
vertex_buffer,
index_buffer,
placement_buffer,
draws,
instance_buffer,
boundary_buffer,
family_buffer,
dash_buffer,
visibility_buffer,
@@ -614,10 +596,7 @@ impl StorageHatchBatch {
);
}
/// Group-1 bind group layout — shared by the pipeline so it can be
/// constructed once at startup. All four bindings are read-only
/// storage and visible to both VS (AABB+visibility lookup) and FS
/// (boundary / family / dash sampling).
/// Shared layout for instance, family, dash, and visibility storage.
#[allow(dead_code)]
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
@@ -632,7 +611,7 @@ impl StorageHatchBatch {
};
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl"),
entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)],
entries: &[entry(0), entry(1), entry(2), entry(3)],
})
}
}
+28 -61
View File
@@ -1,14 +1,5 @@
// Storage-free hatch renderer — texture-backed, UNCAPPED.
//
// Devices without storage buffers use this per-hatch renderer. It reuses the
// storage-free hatch algorithm (see wipeout.wgsl / hatch_texture.wgsl) but packs the
// variable-length boundary / family / dash arrays into ONE RGBA32F data texture
// read via textureLoad — removing the MAX_FAMILIES / MAX_HATCH_BOUNDARY_VERTS /
// MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid,
// gradient, and arbitrarily complex line patterns — renders in compat mode.
//
// Storage-capable devices use the sibling storage backend; wipeout masks use
// wipeout_gpu.rs.
// Texture-backed hatch renderer for devices without storage buffers. Boundary
// geometry comes from the same kernel mesh as the storage backend.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
use iced::wgpu;
@@ -71,7 +62,7 @@ struct TextureHatchUniform {
color: [f32; 4], // 0
color2: [f32; 4], // 16
mode: u32, // 32
vcount: u32, // 36
_reserved: u32, // 36
angle_offset: f32, // 40
scale: f32, // 44
grad_cos: f32, // 48
@@ -90,7 +81,9 @@ struct TextureHatchUniform {
pub(super) struct TextureHatch {
pub(super) vertex_buffer: wgpu::Buffer,
pub(super) index_buffer: wgpu::Buffer,
pub(super) placement_buffer: wgpu::Buffer,
pub(super) index_count: u32,
pub(super) instance_count: u32,
pub(super) bind_group: wgpu::BindGroup,
/// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired
@@ -125,7 +118,7 @@ impl TextureHatch {
}
groups
.into_iter()
.map(|group| Self::new(device, queue, &group, bgl1))
.filter_map(|group| Self::new(device, queue, &group, bgl1))
.collect()
}
@@ -164,7 +157,7 @@ impl TextureHatch {
queue: &wgpu::Queue,
models: &[&HatchModel],
bgl1: &wgpu::BindGroupLayout,
) -> Self {
) -> Option<Self> {
let model = models[0];
// ── Decode pattern mode (mirrors WipeoutGpu::new) ─────────────────
// The gradient shape (kind + invert bit) rides in the mode's high
@@ -197,41 +190,26 @@ impl TextureHatch {
max_y = max_y.max(y);
}
let max_spacing = match &model.pattern {
HatchPattern::Pattern(families) => {
families.iter().map(|f| f.dy.abs()).fold(0.0f32, f32::max)
}
_ => 5.0,
};
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0);
// Anchor pattern phase at `world_origin` with the boundary stored raw,
// matching the storage backend — NOT WipeoutGpu,
// whose f64 origin grid-snap is dead code (wipeouts are always solid)
// and would phase-shift every line pattern relative to desktop. No drift.
let origin = model.world_origin;
let drift = [0.0f32, 0.0f32];
let (x0, x1, y0, y1) = (
min_x + drift[0] - pad,
max_x + drift[0] + pad,
min_y + drift[1] - pad,
max_y + drift[1] + pad,
);
let quad = [
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
HatchVertex { pos: [x0, y1, 0.0], _pad: 0.0 },
];
let (mesh_points, indices) = model.fill_mesh();
if mesh_points.is_empty() || indices.is_empty() {
return None;
}
let vertices: Vec<HatchVertex> = mesh_points
.into_iter()
.map(|[x, y]| HatchVertex { pos: [x, y, 0.0], _pad: 0.0 })
.collect();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.vbuf"),
contents: bytemuck::cast_slice(&quad),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.ibuf"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let base = model
.render_instance
.map_or([0.0; 3], |instance| instance.translation);
@@ -290,21 +268,8 @@ impl TextureHatch {
(0.0, 1.0)
};
// ── Pack the data texture: boundary | families | dashes ───────────
// ── Pack the data texture: families | dashes ─────────────────────
let mut texels: Vec<[f32; 4]> = Vec::new();
// Boundary section (texels 0..vcount). NaN separators become the
// finite GPU sentinel — the shader NaN self-compare is folded to
// `true` on some drivers (#386, #416).
for &[x, y] in model.boundary.iter() {
if x.is_finite() && y.is_finite() {
texels.push([x + drift[0], y + drift[1], 0.0, 0.0]);
} else {
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
texels.push([s, s, 0.0, 0.0]);
}
}
let vcount = texels.len() as u32;
// Family section (3 texels each) + a flat dash pool.
let fam_off = texels.len() as u32;
let mut n_families = 0u32;
@@ -320,7 +285,7 @@ impl TextureHatch {
0.0
};
let angle_r = fam.angle_deg.to_radians();
// QCAD PAT convention: perp_step = dy, along_step = dx.
// PAT local frame: perpendicular spacing and along-line phase.
texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]);
texels.push([fam.dx, fam.dy, fam.dy, fam.dx]);
// Counts as exact f32 (small integers → no denormal/bitcast risk).
@@ -385,7 +350,7 @@ impl TextureHatch {
color: model.color,
color2,
mode,
vcount,
_reserved: 0,
angle_offset: model.angle_offset,
// Clamp like the desktop renderer so scale==0 can't make perp_step 0
// → round(perp/0)=NaN → an invisible hatch.
@@ -433,14 +398,16 @@ impl TextureHatch {
[min_x, min_y, max_x, max_y]
};
Self {
Some(Self {
vertex_buffer,
index_buffer,
placement_buffer,
index_count: indices.len() as u32,
instance_count: placements.len() as u32,
bind_group,
world_aabb,
_uniform_buf,
_data_tex: data_tex,
}
})
}
}
+10 -10
View File
@@ -1148,7 +1148,7 @@ fn build_instanced_chunk(
parts: &[MeshBatchPart<'_>],
profile_enabled: bool,
) -> Option<(MeshBatchChunk, u64, InstancedBuildProfile)> {
let started = profile_enabled.then(std::time::Instant::now);
let started = profile_enabled.then(iced::time::Instant::now);
let first = parts.first()?;
let source = first.set.instance_source.as_ref()?;
let mesh = first.mesh;
@@ -1209,7 +1209,7 @@ fn build_instanced_chunk(
} else {
Vec::new()
};
let vertices_at = profile_enabled.then(std::time::Instant::now);
let vertices_at = profile_enabled.then(iced::time::Instant::now);
let edge_color = first
.set
.visual_style
@@ -1250,7 +1250,7 @@ fn build_instanced_chunk(
]);
}
}
let edges_at = profile_enabled.then(std::time::Instant::now);
let edges_at = profile_enabled.then(iced::time::Instant::now);
let transparent = material_is_transparent(material, color);
let face_indices = first.include_faces.then_some(first.indices.as_ref());
let opaque_indices = if transparent {
@@ -1298,7 +1298,7 @@ fn build_instanced_chunk(
}
}
}
let instances_at = profile_enabled.then(std::time::Instant::now);
let instances_at = profile_enabled.then(iced::time::Instant::now);
let triangles = if first.include_faces {
(first.indices.len() / 3) as u64 * instances.len() as u64
} else {
@@ -1332,7 +1332,7 @@ fn build_instanced_chunk(
.entry(edge_key)
.or_insert_with(|| chunk.edge_vertex_buffer.clone());
}
let uploaded_at = profile_enabled.then(std::time::Instant::now);
let uploaded_at = profile_enabled.then(iced::time::Instant::now);
Some((
chunk,
triangles,
@@ -1559,7 +1559,7 @@ pub fn build_mesh_batch_filtered(
sets: &[MeshLodSet],
handles: Option<&rustc_hash::FxHashSet<acadrust::Handle>>,
) -> (Vec<MeshBatchChunk>, u64) {
let perf_started = crate::perf::enabled().then(std::time::Instant::now);
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
// Derive the caps from the real device limit and vertex size. The previous
// fixed 6 M-vertex cap assumed 40 B/vertex, but `position_low` (RTE) grew
// MeshVertex to 52 B, so 6 M × 52 B = 312 MB blew past the 256 MB cap.
@@ -1741,7 +1741,7 @@ pub fn build_mesh_batch_filtered(
});
}
}
let prepared_at = std::time::Instant::now();
let prepared_at = iced::time::Instant::now();
let spatial_bounds = sets.iter().fold(
[
f32::INFINITY,
@@ -1828,7 +1828,7 @@ pub fn build_mesh_batch_filtered(
mesh_spatial_key(part.set, spatial_bounds),
)
});
let grouped_at = std::time::Instant::now();
let grouped_at = iced::time::Instant::now();
let ordered = direct_parts;
let mut active_key: Option<MaterialBatchKey> = None;
let mut active_material: Option<&crate::scene::model::material_model::MeshMaterial> = None;
@@ -2224,7 +2224,7 @@ pub fn build_mesh_batch_filtered(
None,
));
}
let direct_at = std::time::Instant::now();
let direct_at = iced::time::Instant::now();
let mut instanced_profile = InstancedBuildProfile::default();
let mut instanced_buffers = InstancedBufferCache::default();
for parts in instance_groups.values() {
@@ -2249,7 +2249,7 @@ pub fn build_mesh_batch_filtered(
}
}
if let Some(started) = perf_started {
let done = std::time::Instant::now();
let done = iced::time::Instant::now();
crate::perf_record!(
"[perf] mesh-build-detail prepare={:.1} group={:.1} direct={:.1} instanced={:.1}",
prepared_at.duration_since(started).as_secs_f64() * 1000.0,
+6
View File
@@ -29,6 +29,10 @@ impl Scene {
self.preview_hatches = std::sync::Arc::new(models);
}
pub fn set_command_preview_hatches(&mut self, models: Vec<HatchModel>) {
self.preview_hatches = std::sync::Arc::new(models);
}
pub fn set_area_preview_regions(&mut self, regions: &[AreaPreviewRegion]) {
let mut models = Vec::new();
for region in regions {
@@ -154,6 +158,8 @@ impl Scene {
world_origin: origin,
boundary: std::sync::Arc::new(boundary),
boundary_wcs: None,
boundary_exterior: None,
boundary_sources: None,
pattern: HatchPattern::Solid,
name: "AREA_PREVIEW".into(),
color: [0.0; 4],
+54 -3
View File
@@ -3,12 +3,46 @@ use super::*;
impl Scene {
// ── Selection ─────────────────────────────────────────────────────────
/// Treat a classic LEADER and its attached annotation as one logical object.
/// Clicking/copying/deleting either side expands to the complete pair.
pub(crate) fn handles_expanded_for_leader_annotations(
&self,
handles: &[Handle],
) -> Vec<Handle> {
let mut expanded = handles.to_vec();
for &handle in handles {
// LEADER -> annotation.
if let Some(EntityType::Leader(leader)) = self.document.get_entity(handle) {
if !leader.annotation_handle.is_null() {
expanded.push(leader.annotation_handle);
}
}
// Annotation -> LEADER.
expanded.extend(self.document.entities().filter_map(|entity| match entity {
EntityType::Leader(leader)
if !leader.annotation_handle.is_null()
&& leader.annotation_handle == handle =>
{
Some(entity.common().handle)
}
_ => None,
}));
}
expanded.sort_unstable_by_key(|handle| handle.value());
expanded.dedup();
expanded
}
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
if exclusive {
self.selected.clear();
}
self.selected.insert(handle);
self.selected.extend(handles);
self.bump_selection();
}
@@ -21,6 +55,12 @@ impl Scene {
/// only when its contents actually changed. History/file/command paths must
/// use this instead of assigning `selected` directly.
pub(crate) fn replace_selection(&mut self, selected: HashSet<Handle>) {
let handles: Vec<Handle> = selected.iter().copied().collect();
let selected: HashSet<Handle> = self
.handles_expanded_for_leader_annotations(&handles)
.into_iter()
.collect();
if self.selected != selected {
self.selected = selected;
self.bump_selection();
@@ -29,7 +69,14 @@ impl Scene {
/// Remove a single entity from the selection (Shift+click subtractive pick).
pub fn deselect_entity(&mut self, handle: Handle) {
if self.selected.remove(&handle) {
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
let mut changed = false;
for handle in handles {
changed |= self.selected.remove(&handle);
}
if changed {
self.bump_selection();
}
}
@@ -608,10 +655,14 @@ impl Scene {
// ── Erase ─────────────────────────────────────────────────────────────
pub fn erase_entities(&mut self, handles: &[Handle]) {
let erase_handles = self.handles_expanded_for_leader_annotations(handles);
let mut handle_set: HashSet<Handle> = HashSet::default();
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
let mut highlight_changed = false;
for &h in handles {
for &h in &erase_handles {
// Objects on a locked layer can't be erased.
if self.is_layer_locked(h) {
continue;
+39 -24
View File
@@ -17,11 +17,12 @@ use std::sync::Arc;
/// A script we ship a Noto subset for. [`script_of`] maps a char to one.
///
/// CJK is split by language — Chinese, Japanese and Korean each get their own
/// file. Their ideographs (Han, U+4E00–9FFF) share the same code points but
/// differ in glyph shape, so the shared block is routed by the document's
/// language (see [`set_cjk_lang_from_codepage`]); kana is always Japanese and
/// Hangul always Korean.
/// CJK is split by language — Simplified Chinese, Traditional Chinese,
/// Japanese and Korean each get their own file. Their ideographs (Han,
/// U+4E00–9FFF) share the same code points but differ in glyph shape, so the
/// shared block is routed by the document's language (see
/// [`set_cjk_lang_from_codepage`]); kana is always Japanese and Hangul always
/// Korean.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Script {
Latin,
@@ -32,6 +33,7 @@ pub enum Script {
Thai,
Devanagari,
Chinese,
TraditionalChinese,
Japanese,
Korean,
}
@@ -49,6 +51,7 @@ impl Script {
Script::Thai => "fonts/thai.ttf",
Script::Devanagari => "fonts/devanagari.ttf",
Script::Chinese => "fonts/chinese.ttf",
Script::TraditionalChinese => "fonts/traditional_chinese.ttf",
Script::Japanese => "fonts/japanese.ttf",
Script::Korean => "fonts/korean.ttf",
}
@@ -57,6 +60,7 @@ impl Script {
pub fn family(self) -> &'static str {
match self {
Script::Chinese => "Noto Sans CJK SC",
Script::TraditionalChinese => "Noto Sans CJK TC",
Script::Japanese => "Noto Sans CJK JP",
Script::Korean => "Noto Sans CJK KR",
Script::Latin
@@ -80,6 +84,7 @@ impl Script {
7 => Script::Chinese,
8 => Script::Japanese,
9 => Script::Korean,
10 => Script::TraditionalChinese,
_ => Script::Latin,
}
}
@@ -97,12 +102,13 @@ impl Script {
Script::Chinese => 7,
Script::Japanese => 8,
Script::Korean => 9,
Script::TraditionalChinese => 10,
}
}
}
/// Language used to render shared Han ideographs: 0 = Chinese, 1 = Japanese,
/// 2 = Korean. Set from the active document's code page.
/// Language used to render shared Han ideographs: 0 = Simplified Chinese,
/// 1 = Japanese, 2 = Korean, 3 = Traditional Chinese.
static CJK_LANG: AtomicU8 = AtomicU8::new(0);
static PRIMARY_SCRIPT: AtomicU8 = AtomicU8::new(0);
static FONT_GENERATION: AtomicU64 = AtomicU64::new(0);
@@ -117,22 +123,29 @@ fn bump_generation() {
#[cfg(target_arch = "wasm32")]
pub fn scripts_for_language_tag(language: &str) -> Vec<Script> {
let language = language
let full_language = language.to_ascii_lowercase().replace('_', "-");
let language = full_language
.split(['-', '_'])
.next()
.unwrap_or(language)
.unwrap_or(&full_language)
.to_ascii_lowercase();
let script = match language.as_str() {
"ar" | "fa" | "ur" => Some(Script::Arabic),
"el" => Some(Script::Greek),
"he" | "yi" => Some(Script::Hebrew),
"hi" | "mr" | "ne" => Some(Script::Devanagari),
"ja" => Some(Script::Japanese),
"ko" => Some(Script::Korean),
"ru" | "uk" | "bg" | "sr" => Some(Script::Cyrillic),
"th" => Some(Script::Thai),
"zh" => Some(Script::Chinese),
_ => None,
let script = if matches!(full_language.as_str(), "zh-tw" | "zh-hk")
|| full_language.starts_with("zh-hant")
{
Some(Script::TraditionalChinese)
} else {
match language.as_str() {
"ar" | "fa" | "ur" => Some(Script::Arabic),
"el" => Some(Script::Greek),
"he" | "yi" => Some(Script::Hebrew),
"hi" | "mr" | "ne" => Some(Script::Devanagari),
"ja" => Some(Script::Japanese),
"ko" => Some(Script::Korean),
"ru" | "uk" | "bg" | "sr" => Some(Script::Cyrillic),
"th" => Some(Script::Thai),
"zh" => Some(Script::Chinese),
_ => None,
}
};
vec![script.unwrap_or(Script::Latin)]
}
@@ -184,22 +197,24 @@ fn cjk_lang() -> Script {
match CJK_LANG.load(Ordering::Relaxed) {
1 => Script::Japanese,
2 => Script::Korean,
3 => Script::TraditionalChinese,
_ => Script::Chinese,
}
}
/// Point the shared-Han routing at a language based on a DWG/DXF code page
/// (`$DWGCODEPAGE`), e.g. `ANSI_932` → Japanese, `ANSI_949` → Korean, GB/936 or
/// anything else → Chinese. Returns `true` if the language changed (the caller
/// then clears the glyph cache and re-tessellates).
/// (`$DWGCODEPAGE`), e.g. `ANSI_932` → Japanese, `ANSI_949` → Korean and
/// `ANSI_950` → Traditional Chinese. Returns `true` if the language changed.
pub fn set_cjk_lang_from_codepage(code_page: &str) -> bool {
let c = code_page.to_ascii_uppercase();
let lang = if c.contains("932") || c.contains("SJIS") || c.contains("SHIFT") {
1 // Japanese (Shift-JIS)
} else if c.contains("949") || c.contains("KOR") || c.contains("UHC") {
2 // Korean
} else if c.contains("950") || c.contains("BIG5") {
3 // Traditional Chinese
} else {
0 // Chinese (936 / GB / 950 / Big5) or non-CJK default
0 // Simplified Chinese (936 / GB) or non-CJK default
};
CJK_LANG.swap(lang, Ordering::Relaxed) != lang
}
+1 -1
View File
@@ -3112,7 +3112,7 @@ impl Scene {
}
let context_scale = match self.document.objects.get(&scale) {
Some(acadrust::objects::ObjectType::Scale(value)) => {
value.inverse_factor() as f32
(value.inverse_factor() / self.annotation_scale_unit_factor()) as f32
}
_ => annotation_scale,
};