forked from 1b-agent/opencadstudio-upstream
Switching to a Layout re-tessellated the whole model — `memo_miss=186455`, 2034 ms — to arrive at geometry identical to what was already in the memo. The only thing that differed was colour. `local_wires_for` already knows this: it stores the raw colour and drops its background argument with `let _ = bg_color;`, noting that `Batches::finalize` applies `adapt_to_bg` last, "so the cache no longer has to rebuild on BACKGROUND / layout-switch". The tessellation memo's guard had not been told, and still mixed the background in, so every entry was discarded on a switch. `finalize` now records what its colour resolution consumed — the raw colour, the raw glyph colours, and the three flags it branches on — as `WireModel::bg_adapt`, and `resolve_colors_for_bg` replays it for another background. The guard drops the background, so a memo entry serves any layout. The resolution recomputes from the recorded raw colour rather than from the resolved one, which makes it idempotent and therefore safe over a set that mixes memo hits with freshly tessellated misses. Re-adapting the resolved colour would not be safe: `adapt_to_bg` snaps a near-white colour to pure black on a light background, and pure black comes back pure *white* on a dark one, losing the tint. A test pins that asymmetry so the raw colour is not optimised away later. The contract is tested against the real `finalize` rather than a copy of its logic: for every background pair, building under one and resolving for the other lands exactly where building under the other would have. `bg_adapt` is `None` for wires built anywhere else, and the resolution skips them, so nothing outside the block cache changes colour. It is also `None` when the resolution would be a no-op — a preserved colour with no canvas fill looks the same under every background — which keeps the common case at no cost. Ordering is unchanged: the pass runs inside the builder, before `append_scene_markers` and `apply_refedit_fade`, both of which have their own separate background dependency. `wires-build` gains `colors=` so the pass has a number of its own. Co-Authored-By: Claude Opus 5 <[email protected]>
563 lines
20 KiB
Rust
563 lines
20 KiB
Rust
//! CPU-side complex linetype tessellation.
|
|
//!
|
|
//! Walks the entity path and applies LT elements (dashes, gaps, dots, shapes),
|
|
//! returning one `WireModel` per continuous stroke.
|
|
//!
|
|
//! Coordinate convention: 2D entities live in the **XZ** plane (Y = elevation).
|
|
//! Shape X → along the linetype direction; Shape Y → perpendicular in XZ.
|
|
|
|
use crate::entities::text_support::resolve_dxf_special_chars;
|
|
use crate::io::linetypes::{ComplexLt, LtSegment};
|
|
use crate::scene::text::lff;
|
|
use crate::scene::model::wire_model::WireModel;
|
|
|
|
// ── Public entry point ────────────────────────────────────────────────────
|
|
|
|
/// Apply `lt` along `path_pts` (the entity's tessellated world-space strip).
|
|
///
|
|
/// Returns one `WireModel` per continuous stroke. Pass the entity's `name`,
|
|
/// `color`, `selected` flag, and `line_weight_px` so the WireModels inherit
|
|
/// the entity's visual properties.
|
|
/// Build the "A"-type segment list for one path: a solid begin/end dash of
|
|
/// length `align_end`, and between them the pattern's interior tiled to fill the
|
|
/// rest — starting at the element AFTER the first dash (so embedded text lands
|
|
/// in the same gaps the GPU dash shader phases to). Length-carrying elements are
|
|
/// truncated to fit exactly; zero-length glyphs (Dot / Text / Shape) pass
|
|
/// through at their interior position. Returns `None` if the interior would
|
|
/// exceed a sane repeat count (caller keeps the from-start tiling).
|
|
fn atype_segments(scaled: &[LtSeg], align_end: f32, total: f32) -> Option<Vec<LtSeg>> {
|
|
let pat_len: f32 = scaled
|
|
.iter()
|
|
.map(|s| match s {
|
|
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
|
|
_ => 0.0,
|
|
})
|
|
.sum();
|
|
if pat_len < 1e-10 || scaled.len() < 2 {
|
|
return None;
|
|
}
|
|
let ae = align_end.clamp(1e-4, total * 0.5);
|
|
let interior = total - 2.0 * ae;
|
|
if interior / pat_len > 50_000.0 {
|
|
return None; // too many repeats to materialise — keep cycling walk
|
|
}
|
|
let mut segs: Vec<LtSeg> = Vec::new();
|
|
segs.push(LtSeg::Dash(ae));
|
|
if interior > 1e-6 {
|
|
let mut filled = 0.0f32;
|
|
let mut idx = 1usize; // element after the leading dash (the gap)
|
|
while filled < interior - 1e-6 {
|
|
match &scaled[idx % scaled.len()] {
|
|
LtSeg::Dash(l) => {
|
|
let take = l.min(interior - filled);
|
|
segs.push(LtSeg::Dash(take));
|
|
filled += take;
|
|
}
|
|
LtSeg::Space(l) => {
|
|
let take = l.min(interior - filled);
|
|
segs.push(LtSeg::Space(take));
|
|
filled += take;
|
|
}
|
|
other => segs.push(other.clone()), // Dot / Text / Shape (0-length)
|
|
}
|
|
idx += 1;
|
|
}
|
|
}
|
|
segs.push(LtSeg::Dash(ae));
|
|
Some(segs)
|
|
}
|
|
|
|
pub fn apply_along(
|
|
name: &str,
|
|
path_pts: &[[f32; 3]],
|
|
lt: &ComplexLt,
|
|
scale: f32,
|
|
color: [f32; 4],
|
|
selected: bool,
|
|
line_weight_px: f32,
|
|
// Shared "A"-type reference length (the MLINE centre-line). `Some` end-aligns
|
|
// the pattern: a solid begin/end dash whose length is derived from `ref_total`
|
|
// (so every parallel element shares one interior phase and its dashes line up)
|
|
// while this element still ends on a dash at its own endpoint. `None` tiles
|
|
// the pattern from the start vertex (legacy phase, unchanged).
|
|
ref_total: Option<f32>,
|
|
) -> Vec<WireModel> {
|
|
if path_pts.len() < 2 || lt.segments.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let scaled: Vec<LtSeg> = scale_segments(<.segments, scale);
|
|
if scaled.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let pattern_len: f32 = scaled
|
|
.iter()
|
|
.map(|s| match s {
|
|
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
|
|
LtSeg::Dot | LtSeg::Shape { .. } | LtSeg::Text { .. } => 0.0,
|
|
})
|
|
.sum();
|
|
if pattern_len < 1e-10 {
|
|
return vec![];
|
|
}
|
|
|
|
// Guard against pattern blow-up. The per-segment walk below emits at least
|
|
// one stroke vertex per pattern element, so the total vertex count scales
|
|
// with `path_len / pattern_len`. On a large-extent drawing (e.g. a city/
|
|
// street map) carrying a finely-scaled complex linetype, that ratio can
|
|
// reach billions — the strokes Vec grows until the process is OOM-killed,
|
|
// single-threaded, before the drawing ever finishes loading. Past a sane
|
|
// repeat count the dashes are sub-pixel anyway, so render the base wire
|
|
// solid: returning empty here makes the caller fall back to the solid base.
|
|
const MAX_PATTERN_REPEATS: f32 = 1_000_000.0;
|
|
let path_len: f32 = path_pts
|
|
.windows(2)
|
|
.map(|w| {
|
|
let d = [w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]];
|
|
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
|
|
})
|
|
.sum();
|
|
if path_len / pattern_len > MAX_PATTERN_REPEATS {
|
|
return vec![];
|
|
}
|
|
|
|
// "A"-type end alignment (MLINE): with a shared reference length, replace the
|
|
// from-start tiling with a solid begin/end dash and a phased interior so this
|
|
// element lines up with its siblings and ends on a dash. Dash-first patterns
|
|
// only; align_end is derived from `ref_total` (shared) but the trailing dash
|
|
// lands at this element's own end (`path_len`).
|
|
let atype_segs: Option<Vec<LtSeg>> = ref_total.and_then(|rt| {
|
|
let a = match scaled.first() {
|
|
Some(LtSeg::Dash(a)) if *a > 0.0 => *a,
|
|
_ => return None,
|
|
};
|
|
if rt <= pattern_len || path_len <= pattern_len {
|
|
return None;
|
|
}
|
|
let k = ((rt - a) / pattern_len).round().max(1.0);
|
|
let align_end = (rt - k * pattern_len + a) * 0.5;
|
|
atype_segments(&scaled, align_end, path_len)
|
|
});
|
|
let atype_on = atype_segs.is_some();
|
|
let walk_segs: &[LtSeg] = atype_segs.as_deref().unwrap_or(&scaled);
|
|
|
|
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
|
|
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
|
|
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
|
|
// here; emitted as one extra text-carrying WireModel at the end.
|
|
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
|
|
|
let mut elem_idx: usize = 0;
|
|
let mut elem_consumed: f32 = 0.0;
|
|
|
|
'path: for i in 0..path_pts.len() - 1 {
|
|
let ps = path_pts[i];
|
|
let pe = path_pts[i + 1];
|
|
|
|
let dx = pe[0] - ps[0];
|
|
let dy = pe[1] - ps[1];
|
|
let dz = pe[2] - ps[2];
|
|
let seg_len = (dx * dx + dy * dy + dz * dz).sqrt();
|
|
if seg_len < 1e-10 {
|
|
continue;
|
|
}
|
|
|
|
let fwd = [dx / seg_len, dy / seg_len, dz / seg_len];
|
|
let perp = [-fwd[1], fwd[0], fwd[2]];
|
|
|
|
let mut pos = 0.0f32;
|
|
|
|
while pos < seg_len - 1e-6 {
|
|
let idx = if atype_on {
|
|
// A-type walks a finite, path-sized segment list once (no cycling);
|
|
// once consumed the whole path is drawn.
|
|
if elem_idx >= walk_segs.len() {
|
|
break 'path;
|
|
}
|
|
elem_idx
|
|
} else {
|
|
elem_idx % walk_segs.len()
|
|
};
|
|
|
|
match &walk_segs[idx] {
|
|
LtSeg::Dash(dash_len) => {
|
|
let remaining_dash = dash_len - elem_consumed;
|
|
let remaining_seg = seg_len - pos;
|
|
let advance = remaining_dash.min(remaining_seg);
|
|
|
|
let p_start = lerp(ps, pe, pos / seg_len);
|
|
let p_end = lerp(ps, pe, (pos + advance) / seg_len);
|
|
|
|
if cur_stroke.is_empty() {
|
|
cur_stroke.push(p_start);
|
|
}
|
|
cur_stroke.push(p_end);
|
|
|
|
pos += advance;
|
|
elem_consumed += advance;
|
|
if (elem_consumed - dash_len).abs() < 1e-6 {
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
}
|
|
}
|
|
|
|
LtSeg::Space(space_len) => {
|
|
let remaining = space_len - elem_consumed;
|
|
let advance = remaining.min(seg_len - pos);
|
|
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
pos += advance;
|
|
elem_consumed += advance;
|
|
if (elem_consumed - space_len).abs() < 1e-6 {
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
}
|
|
}
|
|
|
|
LtSeg::Dot => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
let p = lerp(ps, pe, pos / seg_len);
|
|
strokes.push(vec![p, p]);
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
LtSeg::Shape {
|
|
name: sh_name,
|
|
shx_file,
|
|
number,
|
|
x,
|
|
y,
|
|
scale: sh_scale,
|
|
rot_deg,
|
|
} => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let insert = lerp(ps, pe, pos / seg_len);
|
|
let insert = offset_pt(insert, fwd, perp, *x, *y);
|
|
|
|
let shape_strokes = emit_shape(
|
|
sh_name, shx_file, *number, insert, fwd, perp, *sh_scale, *rot_deg,
|
|
);
|
|
strokes.extend(shape_strokes);
|
|
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
LtSeg::Text {
|
|
text,
|
|
style,
|
|
x,
|
|
y,
|
|
scale: tx_scale,
|
|
rot_deg,
|
|
} => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let insert = lerp(ps, pe, pos / seg_len);
|
|
let insert = offset_pt(insert, fwd, perp, *x, *y);
|
|
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
|
|
let resolved = resolve_dxf_special_chars(text);
|
|
// SDF: glyph quads at the insert point (rotation baked in by
|
|
// layout_glyph_quads), collected for the text wire.
|
|
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
|
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
|
&mut atlas,
|
|
*tx_scale,
|
|
fwd_angle,
|
|
1.0, // width_factor
|
|
0.0, // oblique
|
|
// tracking = 1.0 applies the font's natural
|
|
// letter_spacing; 0.0 zeroes it and cramps the glyphs
|
|
// (every other text path passes 1.0).
|
|
1.0,
|
|
style,
|
|
false,
|
|
&resolved,
|
|
);
|
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
|
&mut text_verts,
|
|
&quads,
|
|
[insert[0] as f64, insert[1] as f64, insert[2] as f64],
|
|
1.0,
|
|
color,
|
|
0.0,
|
|
);
|
|
}
|
|
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let mut out: Vec<WireModel> = strokes
|
|
.into_iter()
|
|
.filter(|s| s.len() >= 2)
|
|
.map(|pts| WireModel {
|
|
bg_adapt: None,
|
|
point_marker: None,
|
|
taper_widths: Vec::new(),
|
|
pattern_stations: Vec::new(),
|
|
world_width: 0.0,
|
|
depth_override: None,
|
|
display_visible: true,
|
|
plot_visible: true,
|
|
fill_is_3d: false,
|
|
fill_is_2d_solid: false,
|
|
render_instance: None,
|
|
pick_tris: Vec::new(),
|
|
pick_tris_low: Vec::new(),
|
|
dash_from_start: false,
|
|
dash_align_end: None,
|
|
text_verts: Vec::new(),
|
|
name: name.to_string(),
|
|
points: pts,
|
|
points_low: Vec::new(),
|
|
color,
|
|
selected,
|
|
pattern_length: 0.0,
|
|
pattern: [0.0; 8],
|
|
line_weight_px,
|
|
snap_pts: vec![],
|
|
tangent_geoms: vec![],
|
|
aci: 0,
|
|
key_vertices: vec![],
|
|
aabb: WireModel::UNBOUNDED_AABB,
|
|
plinegen: true,
|
|
fill_tris: vec![],
|
|
fill_tris_low: Vec::new(),
|
|
})
|
|
.collect();
|
|
|
|
// Embedded linetype text (SDF): one extra wire carrying the glyph quads,
|
|
// with a glyph-bounds AABB (f64 accumulate → f32) so it stays precise at
|
|
// UTM scale. Empty points so it doesn't add stroke geometry.
|
|
if !text_verts.is_empty() {
|
|
let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
|
for v in &text_verts {
|
|
let x = v.pos[0] as f64 + v.pos_low[0] as f64;
|
|
let y = v.pos[1] as f64 + v.pos_low[1] as f64;
|
|
nx = nx.min(x);
|
|
xx = xx.max(x);
|
|
ny = ny.min(y);
|
|
xy = xy.max(y);
|
|
}
|
|
out.push(WireModel {
|
|
bg_adapt: None,
|
|
point_marker: None,
|
|
taper_widths: Vec::new(),
|
|
pattern_stations: Vec::new(),
|
|
world_width: 0.0,
|
|
depth_override: None,
|
|
display_visible: true,
|
|
plot_visible: true,
|
|
fill_is_3d: false,
|
|
fill_is_2d_solid: false,
|
|
render_instance: None,
|
|
pick_tris: Vec::new(),
|
|
pick_tris_low: Vec::new(),
|
|
dash_from_start: false,
|
|
dash_align_end: None,
|
|
text_verts,
|
|
name: name.to_string(),
|
|
points: Vec::new(),
|
|
points_low: Vec::new(),
|
|
color,
|
|
selected,
|
|
pattern_length: 0.0,
|
|
pattern: [0.0; 8],
|
|
line_weight_px,
|
|
snap_pts: vec![],
|
|
tangent_geoms: vec![],
|
|
aci: 0,
|
|
key_vertices: vec![],
|
|
aabb: [nx as f32, ny as f32, xx as f32, xy as f32],
|
|
plinegen: true,
|
|
fill_tris: vec![],
|
|
fill_tris_low: Vec::new(),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
enum LtSeg {
|
|
Dash(f32),
|
|
Space(f32),
|
|
Dot,
|
|
Shape {
|
|
name: String,
|
|
shx_file: String,
|
|
number: u16,
|
|
x: f32,
|
|
y: f32,
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
},
|
|
Text {
|
|
text: String,
|
|
style: String,
|
|
x: f32,
|
|
y: f32,
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
},
|
|
}
|
|
|
|
fn scale_segments(segs: &[LtSegment], scale: f32) -> Vec<LtSeg> {
|
|
segs.iter()
|
|
.map(|s| match s {
|
|
LtSegment::Dash(l) => LtSeg::Dash(l * scale),
|
|
LtSegment::Space(l) => LtSeg::Space(l * scale),
|
|
LtSegment::Dot => LtSeg::Dot,
|
|
LtSegment::Shape {
|
|
name,
|
|
shx_file,
|
|
number,
|
|
x,
|
|
y,
|
|
scale: sh_scale,
|
|
rot_deg,
|
|
} => LtSeg::Shape {
|
|
name: name.clone(),
|
|
shx_file: shx_file.clone(),
|
|
number: *number,
|
|
x: x * scale,
|
|
y: y * scale,
|
|
scale: *sh_scale * scale,
|
|
rot_deg: *rot_deg,
|
|
},
|
|
LtSegment::Text {
|
|
text,
|
|
style,
|
|
x,
|
|
y,
|
|
scale: tx_scale,
|
|
rot_deg,
|
|
} => LtSeg::Text {
|
|
text: text.clone(),
|
|
style: style.clone(),
|
|
x: x * scale,
|
|
y: y * scale,
|
|
scale: *tx_scale * scale,
|
|
rot_deg: *rot_deg,
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn flush(cur: &mut Vec<[f32; 3]>, strokes: &mut Vec<Vec<[f32; 3]>>) {
|
|
if !cur.is_empty() {
|
|
strokes.push(std::mem::take(cur));
|
|
}
|
|
}
|
|
|
|
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
|
[
|
|
a[0] + (b[0] - a[0]) * t,
|
|
a[1] + (b[1] - a[1]) * t,
|
|
a[2] + (b[2] - a[2]) * t,
|
|
]
|
|
}
|
|
|
|
fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [f32; 3] {
|
|
[
|
|
pt[0] + fwd[0] * dx + perp[0] * dy,
|
|
pt[1] + fwd[1] * dx + perp[1] * dy,
|
|
pt[2] + fwd[2] * dx + perp[2] * dy,
|
|
]
|
|
}
|
|
|
|
/// Transform a linetype shape into world-space strokes at the pen position:
|
|
/// the real .SHX glyph when the segment carries a resolved shape file (by
|
|
/// number for document linetypes, by name for `.lin` references), falling
|
|
/// back to the converted `ltypeshp` LFF substitute set.
|
|
fn emit_shape(
|
|
name: &str,
|
|
shx_file: &str,
|
|
number: u16,
|
|
insert: [f32; 3],
|
|
fwd: [f32; 3],
|
|
perp: [f32; 3],
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
) -> Vec<Vec<[f32; 3]>> {
|
|
if !shx_file.is_empty() {
|
|
let polys = if number > 0 {
|
|
crate::scene::text::shx::shape_polylines(shx_file, number)
|
|
} else {
|
|
crate::scene::text::shx::shape_polylines_by_name(shx_file, name)
|
|
};
|
|
if let Some(polys) = polys {
|
|
let rot_r = rot_deg.to_radians();
|
|
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
|
|
return polys
|
|
.iter()
|
|
.map(|stroke| {
|
|
stroke
|
|
.iter()
|
|
.map(|&[lx, ly]| {
|
|
let scaled_x = lx as f32 * scale;
|
|
let scaled_y = ly as f32 * scale;
|
|
let along_fwd = cos_r * scaled_x - sin_r * scaled_y;
|
|
let along_perp = sin_r * scaled_x + cos_r * scaled_y;
|
|
[
|
|
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
|
|
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
|
|
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
|
|
]
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
}
|
|
}
|
|
let shape = match lff::shape(name) {
|
|
Some(s) => s,
|
|
None => return vec![],
|
|
};
|
|
|
|
let rot_r = rot_deg.to_radians();
|
|
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
|
|
|
|
shape
|
|
.strokes
|
|
.iter()
|
|
.map(|stroke| {
|
|
stroke
|
|
.iter()
|
|
.map(|&[lx, ly]| {
|
|
let scaled_x = lx * scale;
|
|
let scaled_y = ly * scale;
|
|
// Rotate in the shape's local frame, then place along the
|
|
// line tangent (fwd) and perpendicular (perp).
|
|
let along_fwd = cos_r * scaled_x - sin_r * scaled_y;
|
|
let along_perp = sin_r * scaled_x + cos_r * scaled_y;
|
|
[
|
|
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
|
|
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
|
|
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
|
|
]
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|