Files
OneCAD-Draft/src/scene/paper.rs
T
sLuCHa 1a45f6bd97 fix(plot): correct hatch rendering in PDF / plot export
Hatched content — especially fills nested inside a block INSERT, such as a
title-block logo — plotted incorrectly: missing, mis-spaced, or with phantom
bars. Five related fixes make the PDF/plot output match AutoCAD.

1. Export block-internal hatch fills.
   The export collected only hatches owned directly by the layout block, so a
   hatch nested in a block INSERT was dropped and printed as bare monochrome
   outlines. The insert-explosion the viewport already does is extracted into a
   shared `exploded_insert_hatch_models()` and called from both the viewport
   (`synced_hatch_models`) and the export (`paper_canvas_hatches`), so a plot
   draws block-internal hatches identically to the screen.

2. Honour the hatch's own stored pattern-line spacing.
   `hatch_model_from_dxf` re-derived pattern spacing from the name-matched
   catalog entry x pattern_scale, ignoring the resolved line geometry the DWG
   stores on the hatch. When a drawing was authored against a different base
   spacing (imperial 0.125 vs the catalog's metric 3.175 for ANSI31), lines
   came out up to ~25x too coarse and a dense fill collapsed to a few stray
   lines. When the hatch carries its own line geometry it is now used directly
   (identity scale/angle); the catalog path remains the fallback.

3. Fill far-from-origin pattern hatches.
   `pattern_segments` clamped the ABSOLUTE scan-line index to
   +/-MAX_LINES_PER_FAMILY. A fine-spaced hatch far from the pattern origin has
   large-magnitude indices at both ends but a small span, so the clamp inverted
   the range and emitted nothing — silently dropping the fill. Cap the line
   count (span) instead of the absolute index.

4. Skip TEXTBOX boundary paths.
   TEXTBOX boundary paths (flag bit 3) are text bounding-boxes AutoCAD derives
   for island detection; they are never drawn or filled. Treating one as a fill
   boundary painted its rectangle solid — a phantom bar. It is now skipped when
   building the fill boundary.

5. Print white/ACI-7 hatch fills black on paper.
   Hatch fills arrive adapted to the dark screen background, so a white/ACI-7
   fill would vanish white-on-white on the sheet. Mirror the wire pass:
   near-white/near-yellow -> black, near-cyan -> dark blue, matching AutoCAD's
   colour-7-on-white plotting. Wipeouts keep their paper-white mask.

Adds `tests/block_hatch_export.rs` covering: block-internal hatch reaches the
export set, stored-line spacing is honoured, far-from-origin fills are not
dropped, and TEXTBOX paths are not filled.
2026-07-06 12:24:10 +02:00

500 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
impl Scene {
pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> {
self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D)
.into_iter()
.filter(|inst| inst.grid_on)
.map(|inst| (inst.screen_rect, inst.camera, inst.handle))
.collect()
}
/// The viewports to render this frame, one entry per scissor pass.
///
/// - **Model layout**: a single full-canvas instance driven by the
/// scene camera (tiled splits will append more later). `model_mode`
/// supplies its render mode (held on the tab, not the scene).
/// - **Paper layout**: one instance per content viewport entity
/// (`id > 1`, owned by the current layout block, switched on),
/// using each viewport's own camera and render mode.
pub fn active_viewports(
&self,
canvas_w: f32,
canvas_h: f32,
model_mode: acadrust::entities::ViewportRenderMode,
) -> Vec<ViewportInstance> {
if self.current_layout == "Model" {
let tiles = self.model_tiles.borrow();
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
return tiles
.iter()
.enumerate()
.map(|(i, tile)| {
// The active tile renders the live camera (orbit/pan act
// on it); inactive tiles use their stored snapshot.
let camera = if i == active {
self.camera.borrow().clone()
} else {
tile.camera.clone()
};
ViewportInstance {
handle: Handle::NULL,
tile_idx: Some(i),
screen_rect: iced::Rectangle {
x: tile.rect.x * canvas_w,
y: tile.rect.y * canvas_h,
width: tile.rect.width * canvas_w,
height: tile.rect.height * canvas_h,
},
camera,
// The active tile shows the live mode the picker
// drives; every other tile keeps its own stored
// style so editing one never disturbs the rest.
render_mode: if i == active { model_mode } else { tile.render_mode },
active: i == active,
grid_on: tile.grid_on,
paper_sheet: false,
}
})
.collect();
}
let layout_block = self.current_layout_block_handle();
let mut out: Vec<ViewportInstance> = Vec::new();
// The full-canvas sheet viewport renders the paper-space entities
// themselves — the layout's own view, drawn first so the floating
// content viewports overlay it. Its camera keeps the paper pan/zoom
// (target + ortho size) but is LOCKED to the top/plan orientation:
// paper is 2-D, so the sheet never orbits.
let mut sheet_cam = self.camera.borrow().clone();
sheet_cam.yaw = 0.0;
sheet_cam.pitch = std::f32::consts::FRAC_PI_2;
sheet_cam.rotation = view::camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0);
sheet_cam.projection = view::camera::Projection::Orthographic;
let sheet_grid_on = match self
.document
.get_entity(self.current_layout_sheet_viewport_handle())
{
Some(EntityType::Viewport(vp)) => vp.status.grid_on,
_ => false,
};
out.push(ViewportInstance {
handle: Handle::NULL,
tile_idx: None,
screen_rect: iced::Rectangle {
x: 0.0,
y: 0.0,
width: canvas_w,
height: canvas_h,
},
camera: sheet_cam,
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
active: false,
grid_on: sheet_grid_on,
paper_sheet: true,
});
for e in self.document.entities() {
let EntityType::Viewport(vp) = e else {
continue;
};
if !self.is_content_viewport_in_layout(vp, layout_block)
|| !vp.status.is_on
{
continue;
}
let h = vp.common.handle;
let (Some(screen_rect), Some(camera)) = (
self.viewport_screen_rect(h, (canvas_w, canvas_h)),
self.camera_for_viewport(h),
) else {
continue;
};
out.push(ViewportInstance {
handle: h,
tile_idx: None,
screen_rect,
camera,
render_mode: vp.render_mode,
active: self.active_viewport == Some(h),
grid_on: vp.status.grid_on,
paper_sheet: false,
});
}
out
}
/// Convert a paper-space Viewport entity's position/size into a pixel
/// `Rectangle` relative to the top-left of the canvas.
///
/// Uses the same top-down ortho transform as the GPU sheet viewport so the
/// overlay lands exactly over the drawn viewport border regardless of zoom
/// or pan level.
pub fn viewport_screen_rect(
&self,
vp_handle: Handle,
canvas_px: (f32, f32),
) -> Option<iced::Rectangle> {
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
let (canvas_w, canvas_h) = canvas_px;
if canvas_w < 1.0 || canvas_h < 1.0 {
return None;
}
let cam = self.camera.borrow();
let aspect = canvas_w / canvas_h;
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x as f32;
let ty = cam.target.y as f32;
drop(cam);
// Top-down ortho mapping matching the GPU sheet viewport's camera.
let to_px = |wx: f32, wy: f32| -> (f32, f32) {
let x = (wx - tx + half_w) / (2.0 * half_w) * canvas_w;
let y = (ty + half_h - wy) / (2.0 * half_h) * canvas_h;
(x, y)
};
let cx = vp.center.x as f32;
let cy = vp.center.y as f32;
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
let (x0, y0) = to_px(cx - hw, cy + hh); // top-left in screen
let (x1, y1) = to_px(cx + hw, cy - hh); // bottom-right in screen
let w = (x1 - x0).max(1.0);
let h = (y1 - y0).max(1.0);
Some(iced::Rectangle {
x: x0,
y: y0,
width: w,
height: h,
})
}
// ── Paper-space helpers ───────────────────────────────────────────────
/// Paper-layout hatch fills, restricted to the active layout block (used by
/// paper-space hatch hit-testing / export). The GPU-rendered
/// content viewports already draw model-block hatches inside their
/// own scissor; including those here would also draw them on the
/// paper sheet through the paper camera (huge / off-position), so
/// restrict the canvas list to entities owned by the active paper
/// layout block. Iterates the source `self.hatches` map (keyed by
/// entity handle) rather than the already-flattened arc — the
/// flattened arc carries pattern names, not handles, so filtering
/// there is unreliable.
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
let layout_block = self.current_layout_block_handle();
let layer_hidden = |layer: &str| {
self.document
.layers
.get(layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
};
let mut models: Vec<HatchModel> = Vec::new();
for (&handle, model) in self.hatches.iter() {
let Some(entity) = self.document.get_entity(handle) else {
continue;
};
let c = entity.common();
if c.invisible || layer_hidden(&c.layer) {
continue;
}
if !self.belongs_to_visible_block(handle, c.owner_handle, layout_block) {
continue;
}
let mut m = model.clone();
m.color = self.render_style(entity).0;
if let EntityType::Hatch(dxf) = entity {
// Only re-apply pattern_scale/angle for catalog-derived patterns
// (empty stored lines). A pattern built from the hatch's own
// stored lines is already final (scale 1 / angle 0).
if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern {
if dxf.pattern.lines.is_empty() {
m.angle_offset = dxf.pattern_angle as f32;
m.scale = dxf.pattern_scale as f32;
}
}
}
if self.selected.contains(&handle) {
m.color = [0.15, 0.55, 1.00, m.color[3]];
}
models.push(m);
}
// Hatch fills nested inside a block INSERT are owned by the block
// record, so the loop above — which only keeps hatches owned by
// `layout_block` — never sees them. Explode the layout's visible
// INSERTs and materialize their fills at world position, exactly as the
// viewport does, so the export carries the block's colours instead of
// bare outlines. (No selection tint on export.)
let hatch_bg = if self.current_layout != "Model" {
self.paper_bg_color
} else {
self.bg_color
};
let exploded = self.exploded_insert_hatch_models(layout_block, hatch_bg, false);
models.extend(exploded);
Arc::new(models)
}
/// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as
/// `paper_canvas_hatches` — only include wipeouts owned by the
/// active paper layout block, so model wipeouts (drawn through their
/// content viewport's GPU pipeline) don't get a second mis-projected
/// copy on the paper sheet.
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
let layout_block = self.current_layout_block_handle();
let bg_color = self.paper_bg_color;
let mut models = Vec::new();
for entity in self.document.entities() {
let EntityType::Wipeout(wo) = entity else {
continue;
};
if wo.common.invisible {
continue;
}
if self
.document
.layers
.get(&wo.common.layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
continue;
}
if !self.belongs_to_visible_block(wo.common.handle, wo.common.owner_handle, layout_block)
{
continue;
}
// Paper-block wipeouts live in paper coords — no `world_offset`.
let (fill_origin, boundary) = Self::wipeout_boundary_2d(wo);
if boundary.len() < 3 {
continue;
}
let mut fill_color = bg_color;
if self.selected.contains(&wo.common.handle) {
fill_color = [0.15, 0.55, 1.00, 0.35];
}
models.push(HatchModel {
boundary: Arc::new(boundary),
pattern: model::hatch_model::HatchPattern::Solid,
name: "WIPEOUT_FILL".into(),
color: fill_color,
angle_offset: 0.0,
scale: 1.0,
world_origin: fill_origin,
vp_scissor: None,
draw_depth: 0.0,
});
}
Arc::new(models)
}
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
/// Used by `active_viewports` to render model-space content through each
/// content viewport's own view direction and scale.
pub(super) fn camera_for_viewport(&self, vp_handle: Handle) -> Option<view::camera::Camera> {
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
// Floating-viewport–specific step: decide saved-view vs auto-fit, then
// hand the effective view to the shared `camera_from_view` decoder so
// twist / view_center / distance behave identically to a model VPORT.
//
// UTM / coordinate-shifted drawings often arrive with
// `view_target = (0, 0, 0)` and a stale `view_center` from before the
// file was geo-referenced; the saved view points at empty WCS while the
// actual model sits ~`world_offset` away. Decode the saved view first
// and keep it only if its target actually frames the model cluster.
//
// The overlap test runs on the *decoded* target (wire-space, so the
// cluster is `±cluster_half` about the origin), NOT a raw
// `view_target + view_center` sum: under a view twist `view_center` is a
// DCS offset, so the raw sum lands far from the real WCS centre and
// would wrongly trip the auto-fit — replacing the saved view_height with
// the whole-cluster fit and rendering the content at the wrong zoom.
let saved_h = vp.view_height.abs();
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
let cluster_half = self.local_extent_max.max(1.0) as f64;
// Absolute drawing centre. Geometry now reaches the scene at absolute
// (UTM) coordinates — the old code centred the overlap test and the
// auto-fit on the origin, which was right only while world_offset
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
// so a stale `(0,0,0)` saved view failed the overlap test AND the
// auto-fit aimed at empty origin → blank viewports.
// Frame the overlap test / auto-fit on the robust cluster centre (median
// of entity centroids), NOT the raw extents centre: a drawing with a
// far second cluster (e.g. a small-coordinate legend beside a UTM survey)
// has an extents centre in the empty gap, which would reject a valid
// saved view and then auto-fit onto blank space. Fall back to the extents
// centre only when no cluster centre was computed.
let (cx, cy) = if self.local_center != [0.0, 0.0] {
(self.local_center[0], self.local_center[1])
} else {
self.model_space_extents()
.map(|(mn, mx)| {
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
})
.unwrap_or((0.0, 0.0))
};
if let Some(cam) = self.camera_from_view(
vp.view_direction,
vp.view_target,
acadrust::types::Vector2 {
x: vp.view_center.x,
y: vp.view_center.y,
},
saved_h,
vp.twist_angle,
) {
let half_h = saved_h * 0.5;
let half_w = half_h * aspect_d;
let (tx, ty) = (cam.target.x as f64, cam.target.y as f64);
let overlaps = tx + half_w >= cx - cluster_half
&& tx - half_w <= cx + cluster_half
&& ty + half_h >= cy - cluster_half
&& ty - half_h <= cy + cluster_half;
if overlaps {
return Some(cam);
}
}
// Auto-fit: aim at the content cluster centre, drop the stale view_center.
let fit_h = cluster_half * 2.0 * 1.05;
let tgt = acadrust::types::Vector3 {
x: cx,
y: cy,
z: vp.view_target.z,
};
self.camera_from_view(
vp.view_direction,
tgt,
acadrust::types::Vector2::ZERO,
fit_h,
vp.twist_angle,
)
}
/// Collect model-space WireModels visible through `vp_handle`, respecting
/// global layer visibility, the viewport's per-viewport layer freeze list,
/// and the per-viewport frustum + LOD cull derived from
/// `screen_height_px` (the on-paper pixel height of this viewport).
fn model_wires_for_viewport(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Vec<WireModel> {
use rustc_hash::FxHashSet as HSet;
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => {
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
let vp_scale =
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
let anno = if vp_scale > 1e-9 {
(1.0 / vp_scale) as f32
} else {
1.0_f32
};
let aspect = if vp.height > 1e-9 {
(vp.width / vp.height) as f32
} else {
1.0_f32
};
(f, anno, aspect)
}
_ => (HSet::default(), 1.0_f32, 1.0_f32),
};
// Drive the per-viewport view_aabb / wpp from the *effective* camera
// `camera_for_viewport` produces — it folds in the auto-fit
// fallback for UTM-style files whose saved `view_target` sits at
// empty WCS. Without that, the GPU pass would frustum-cull every
// entity (saved-view rect doesn't overlap the offset-subtracted
// model cluster) and the viewport would render blank.
let Some(cam) = self.camera_for_viewport(vp_handle) else {
return Vec::new();
};
let vp_ortho_h = cam.ortho_size();
// Rotation-aware cull box: a twisted/rotated viewport sees a rotated
// rectangle in world XY, so derive the box from the camera basis.
// `None` (tilted view) disables the cull — render everything.
let view_aabb = view_cull_aabb(&cam, vp_aspect, 1.25);
// World units per on-screen pixel for LOD substitution + curve
// tolerance. Tracks the paper-zoom-driven pixel height the
// viewport currently occupies.
let wpp = if screen_height_px > 1.0 {
Some((2.0 * vp_ortho_h) / screen_height_px)
} else {
None
};
self.wires_for_block_culled(
self.model_space_block_handle(),
view_aabb,
wpp,
Some(&frozen),
Some(vp_anno_scale),
)
}
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
/// the on-paper pixel height (paper-zoom dependent), so the cache key
/// includes a quantized form of that height in addition to the
/// geometry epoch — every paper zoom step that actually changes the
/// LOD bucket invalidates this viewport's entry.
pub(crate) fn model_wires_for_viewport_arc(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Arc<Vec<WireModel>> {
// Drop sub-pixel noise so trivial paper-zoom jitter does not
// re-tessellate a 100k-entity drawing every frame; round to an
// integer pixel.
let height_key = screen_height_px.max(1.0).round() as u32;
// Hash the viewport's own view (pan + zoom + orbit) into the key.
// Editing inside the viewport (MSPACE) changes its frustum but does NOT
// bump geometry_epoch, so without this the stale frustum-culled subset
// is returned and newly-revealed lines stay invisible until the layout
// re-tessellates. Quantize to ~1 px / fine steps to ignore jitter.
let view_key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) {
let vh = vp.view_height.abs().max(1e-6);
let q = vh / (screen_height_px.max(1.0) as f64); // model units / px
(((vp.view_target.x + vp.view_center.x) / q).round() as i64).hash(&mut h);
(((vp.view_target.y + vp.view_center.y) / q).round() as i64).hash(&mut h);
((vh * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.x * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.y * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.z * 1000.0).round() as i64).hash(&mut h);
}
h.finish()
};
let key = (self.geometry_epoch, height_key, view_key);
{
let cache = self.viewport_wire_cache.borrow();
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
if *cached_key == key {
return Arc::clone(arc);
}
}
}
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
self.viewport_wire_cache
.borrow_mut()
.insert(vp_handle, (key, Arc::clone(&arc)));
arc
}
}