fix: correct polyline arc direction, text precision, Face3D edge color, buffer chunking

- fix: polyline arc center sign was inverted (- → +) causing arcs to render on the
  wrong side in LwPolyline and Polyline2D
- fix: store glyph strokes in local space with f64 origin so world_offset subtraction
  uses f64 precision; prevents blocky text at large UTM coordinates
- fix: Face3D fill darkened to 45% so edge wires are visually distinct from fill
- fix: wire batch vertex buffer chunked to 256 MB GPU limit (was panic on large files)
- fix: gpu_face3d_edges changed from Option<WireGpu> to Vec<WireGpu> to support chunks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-29 20:58:44 +03:00
commit 1a19a841e6
11 changed files with 136 additions and 113 deletions

View file

@ -7,12 +7,20 @@ use truck_modeling::{Edge, Solid, Vertex, Wire};
use crate::entities::traits::EntityTypeOps;
use crate::scene::wire_model::{SnapHint, TangentGeom};
/// One group of glyph strokes with its world-space origin stored in f64.
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
/// world offset can be subtracted with f64 precision in tessellate.rs.
pub struct TextStroke {
pub strokes: Vec<Vec<[f32; 2]>>,
pub origin: [f64; 2],
}
#[allow(dead_code)]
pub enum TruckObject {
Point(Vertex),
Curve(Edge),
Contour(Wire),
Text(Vec<Vec<[f32; 2]>>),
Text(Vec<TextStroke>),
/// Pre-computed NaN-separated 3-D point list (leader lines, arrowheads, etc.).
Lines(Vec<[f32; 3]>),
Volume(Solid),

View file

@ -65,9 +65,10 @@ impl Face3DGpu {
if wire.key_vertices.len() < 4 {
continue;
}
let color = wire.color;
let [r, g, b, a] = wire.color;
let fill_color = [r * 0.45, g * 0.45, b * 0.45, a];
let p = &wire.key_vertices;
let v = |i: usize| Face3DVertex { position: p[i], color };
let v = |i: usize| Face3DVertex { position: p[i], color: fill_color };
// Triangle 1: p0, p1, p2
vertices.push(v(0));

View file

@ -61,7 +61,7 @@ pub struct Pipeline {
gpu_meshes: Vec<MeshGpu>,
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
gpu_face3d_fill: Option<Face3DGpu>,
gpu_face3d_edges: Option<WireGpu>,
gpu_face3d_edges: Vec<WireGpu>,
pub viewcube: ViewCubePipeline,
/// Last geometry epoch for which GPU buffers were uploaded.
/// Initialized to u64::MAX so the first frame always uploads.
@ -590,7 +590,7 @@ impl Pipeline {
gpu_images: vec![],
gpu_meshes: vec![],
gpu_face3d_fill: None,
gpu_face3d_edges: None,
gpu_face3d_edges: vec![],
viewcube,
cached_epoch: u64::MAX,
}
@ -613,7 +613,7 @@ impl Pipeline {
pub fn upload_face3d(&mut self, device: &wgpu::Device, face3d_wires: &[WireModel]) {
if face3d_wires.is_empty() {
self.gpu_face3d_fill = None;
self.gpu_face3d_edges = None;
self.gpu_face3d_edges = vec![];
return;
}
self.gpu_face3d_fill = Some(Face3DGpu::from_wires(device, face3d_wires));
@ -813,36 +813,38 @@ impl Pipeline {
}
}
// ── Pass 5b: 3DFACE edges (batched) ──────────────────────────────
if let Some(ref edges) = self.gpu_face3d_edges {
if edges.vertex_count >= 6 {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("face3d_edges.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
// ── Pass 5b: 3DFACE edges (batched, possibly multiple chunks) ────
if !self.gpu_face3d_edges.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("face3d_edges.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.wire_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_vertex_buffer(0, edges.vertex_buffer.slice(..));
pass.draw(0..edges.vertex_count, 0..1);
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.wire_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for edges in &self.gpu_face3d_edges {
if edges.vertex_count >= 6 {
pass.set_vertex_buffer(0, edges.vertex_buffer.slice(..));
pass.draw(0..edges.vertex_count, 0..1);
}
}
}

View file

@ -125,13 +125,13 @@ impl WireGpu {
Self::build(device, wire, [r, g, b, a * alpha])
}
/// Merge multiple WireModels into a single GPU buffer (1 draw call for all).
/// Merge multiple WireModels into GPU buffers chunked to fit the 256 MB GPU limit.
/// Each wire keeps its own color and pattern — they're stored per-vertex.
/// Returns None if the combined vertex list is empty.
pub fn from_batch(device: &wgpu::Device, wires: &[WireModel]) -> Option<Self> {
/// Returns an empty Vec if the combined vertex list is empty.
pub fn from_batch(device: &wgpu::Device, wires: &[WireModel]) -> Vec<Self> {
let total_segs: usize = wires.iter().map(|w| w.points.len().saturating_sub(1)).sum();
if total_segs == 0 {
return None;
return vec![];
}
let mut vertices: Vec<WireVertex> = Vec::with_capacity(total_segs * 6);
@ -186,19 +186,27 @@ impl WireGpu {
}
if vertices.is_empty() {
return None;
return vec![];
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("wire.batch.vbuf"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
// GPU max buffer size is 256 MB; chunk to stay within the limit.
const MAX_VERTS: usize = 268_435_456 / std::mem::size_of::<WireVertex>();
Some(Self {
vertex_buffer,
vertex_count: vertices.len() as u32,
})
vertices
.chunks(MAX_VERTS)
.enumerate()
.map(|(i, chunk)| {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("wire.batch.vbuf.{i}")),
contents: bytemuck::cast_slice(chunk),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
vertex_buffer,
vertex_count: chunk.len() as u32,
}
})
.collect()
}
fn build(device: &wgpu::Device, wire: &WireModel, color: [f32; 4]) -> Self {

View file

@ -64,27 +64,29 @@ pub fn tessellate(
if let Some(te) = convert(entity, document) {
match te.object {
// ── Text / MText: pre-tessellated glyph strokes ───────────────
TruckObject::Text(strokes_2d) => {
// Glyph strokes come from acad_to_truck in world-space f32.
// Subtract world_offset (f32 subtraction — acceptable precision
// for text rendering; the anchor was already cast to f32 there).
TruckObject::Text(stroke_groups) => {
// Each TextStroke keeps its strokes in glyph-local space and
// its world origin as f64. Subtract world_offset in f64 before
// casting to f32 so large UTM coordinates don't crush precision.
let [ox, oy, oz] = world_offset;
let elev = entity_z(entity) - oz as f32;
// Pack all strokes into one flat point list, separated by
// NaN sentinels so wire_gpu.rs skips disconnected segments.
let mut points: Vec<[f32; 3]> = Vec::new();
for (i, stroke) in strokes_2d.iter().enumerate() {
if stroke.len() < 2 {
continue;
}
if i > 0 && !points.is_empty() {
// NaN sentinel — wire_gpu skips any segment where
// either endpoint contains NaN.
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
for &[x, y] in stroke {
points.push([x - ox as f32, y - oy as f32, elev]);
let mut first = true;
for group in &stroke_groups {
let lx = (group.origin[0] - ox) as f32;
let ly = (group.origin[1] - oy) as f32;
for stroke in &group.strokes {
if stroke.len() < 2 {
continue;
}
if !first && !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
first = false;
for &[x, y] in stroke {
points.push([x + lx, y + ly, elev]);
}
}
}