2026-03-22 23:07:43 +03:00
|
|
|
|
// GPU rendering primitives, shader::Program / shader::Primitive impls,
|
|
|
|
|
|
// and entity render-style helpers for the Scene.
|
|
|
|
|
|
|
|
|
|
|
|
use acadrust::tables::LineType;
|
|
|
|
|
|
use acadrust::types::{Color as AcadColor, LineWeight};
|
2026-04-26 02:36:34 +03:00
|
|
|
|
use acadrust::{CadDocument, EntityType, Handle};
|
2026-03-22 23:07:43 +03:00
|
|
|
|
use glam::Mat4;
|
|
|
|
|
|
use iced::mouse;
|
|
|
|
|
|
use iced::widget::shader::{self, Viewport};
|
2026-04-23 02:18:41 +03:00
|
|
|
|
use iced::{Rectangle, Size};
|
2026-03-22 23:07:43 +03:00
|
|
|
|
|
perf: eliminate per-frame re-tessellation and GPU re-upload in large files
Add two complementary render caches that make mouse navigation essentially
free regardless of file size:
Option A – Wire tessellation cache: entity_wires_arc() caches the
tessellated Vec<WireModel> keyed by geometry_epoch and returns an Arc,
so navigation frames (no preview wires) incur zero Vec clones and zero
tessellation work. Primitive::wires changed to Arc<Vec<WireModel>>.
Option B – GPU buffer cache: Pipeline stores cached_epoch; prepare()
skips all upload_wires/hatches/images/meshes calls when the epoch is
unchanged, writing only the 192-byte camera uniform instead.
geometry_epoch is a process-wide AtomicU64 incremented by every Scene
mutation (add/erase entity, selection, layer visibility, layout switch,
populate_*, grip, transform, copy, clear, preview wires). Using a global
counter rather than a per-scene counter prevents two tabs that happen to
have the same local bump count from sharing a cached_epoch value, which
was causing the first file's geometry to remain on screen after opening a
second file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 02:13:31 +03:00
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
2026-06-16 11:33:46 +03:00
|
|
|
|
use crate::scene::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
|
|
|
|
|
|
use crate::scene::pipeline::MultiPipeline;
|
|
|
|
|
|
use crate::scene::convert::tess_util;
|
|
|
|
|
|
use crate::scene::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, ViewportInstance, WireModel};
|
2026-03-22 23:07:43 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Camera hover state (shader::Program::State) ───────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
|
pub struct CameraState {
|
|
|
|
|
|
pub hover_region: Option<usize>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── GPU primitive ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-27 19:47:42 +03:00
|
|
|
|
/// Everything needed to render one viewport: its geometry, camera, render
|
|
|
|
|
|
/// mode, and the screen rectangle it occupies. The unified renderer carries
|
|
|
|
|
|
/// a `Vec<ViewportData>` (one per tiled / floating viewport); each gets its
|
|
|
|
|
|
/// own inner `Pipeline` instance drawn into its own rectangle.
|
2026-03-22 23:07:43 +03:00
|
|
|
|
#[derive(Debug)]
|
2026-05-27 19:47:42 +03:00
|
|
|
|
pub struct ViewportData {
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) wires: Arc<Vec<WireModel>>,
|
2026-06-05 10:57:38 +03:00
|
|
|
|
/// Live command-preview / interim / grip-drag overlay wires. Kept out of
|
|
|
|
|
|
/// the main `wires` buffer so a drag re-uploads only this small set each
|
|
|
|
|
|
/// frame, never the resident base buffer. Drawn on top in the wire pass.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) preview_wires: Arc<Vec<WireModel>>,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
/// 3DFACE entity wires — separated so they are uploaded to the dedicated
|
|
|
|
|
|
/// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) face3d_wires: Arc<Vec<WireModel>>,
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
/// SDF text-quad vertices (Phase 2b). Empty unless `OCS_TEXT_SDF` is set.
|
|
|
|
|
|
pub(in crate::scene) text_verts: Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
/// Per-entity normalized draw-order depth (handle.value() → (0,1)), used
|
|
|
|
|
|
/// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
|
|
|
|
|
|
/// depth field (84 construction sites); the bias is looked up by handle
|
|
|
|
|
|
/// at GPU-upload time from this map instead.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) draw_depths: Arc<rustc_hash::FxHashMap<u64, f32>>,
|
|
|
|
|
|
pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
|
2026-04-06 23:44:36 +03:00
|
|
|
|
/// Wipeout fills — rendered in a separate pass AFTER wires.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) wipeout_hatches: Arc<Vec<HatchModel>>,
|
|
|
|
|
|
pub(in crate::scene) images: Arc<Vec<ImageModel>>,
|
|
|
|
|
|
pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
|
|
|
|
|
|
pub(in crate::scene) uniforms: Uniforms,
|
2026-03-22 23:07:43 +03:00
|
|
|
|
/// Camera rotation matrix derived from the quaternion.
|
|
|
|
|
|
/// Used by the ViewCube pipeline — no gimbal lock.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) cam_rotation: Mat4,
|
2026-06-27 01:12:45 +03:00
|
|
|
|
/// Camera-only rotation (no UCS) for the world-fixed compass cardinals, so
|
|
|
|
|
|
/// N/E/S/W stay aligned to world even as the cube reorients with the UCS.
|
|
|
|
|
|
pub(in crate::scene) compass_rotation: Mat4,
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) hover_region: Option<usize>,
|
|
|
|
|
|
pub(in crate::scene) show_viewcube: bool,
|
2026-05-20 04:44:07 +03:00
|
|
|
|
/// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill
|
|
|
|
|
|
/// uploads short-circuit so the renderer draws only wireframe.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fill_mode: bool,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
/// Per-view "Wireframe vs Solid" toggle. When `true`, 3D face fills
|
|
|
|
|
|
/// are dropped on the upload path so 3D faces draw as edges only.
|
|
|
|
|
|
/// Hatch / wipeout uploads are deliberately *not* gated by this flag —
|
|
|
|
|
|
/// the user toggle should only affect 3D solids, not 2D fills.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) view_wireframe: bool,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
/// Whether the active render mode wants 3D mesh fills uploaded. Off
|
|
|
|
|
|
/// in `Wireframe2D` / `Wireframe3D`; on for every shaded variant. Set
|
|
|
|
|
|
/// at the same point `view_wireframe` is computed so the two stay in
|
|
|
|
|
|
/// lock-step for the gating logic in `prepare()`.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) mesh_fill: bool,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
/// Whether the active render mode wants 3D mesh / face edges
|
|
|
|
|
|
/// rendered on top of fills. Most shaded modes turn this off; the
|
|
|
|
|
|
/// `*WithEdges` variants and the pure wireframes leave it on.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) show_3d_edges: bool,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
/// HiddenLine routes 3D fills through a depth-only prepass so edges
|
|
|
|
|
|
/// occluded by closer geometry are culled by the LessEqual depth
|
|
|
|
|
|
/// test on the wire passes that follow.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) hidden_line: bool,
|
2026-07-05 09:48:49 +02:00
|
|
|
|
/// Interaction LOD: when true the (per-pixel, GPU-dominating) hatch pass is
|
|
|
|
|
|
/// skipped this frame because the view is actively being navigated. Folded
|
|
|
|
|
|
/// into the render signature so the settle frame re-renders hatches once and
|
|
|
|
|
|
/// the scene-render cache holds it. See [`Scene::navigating_lod`].
|
|
|
|
|
|
pub(in crate::scene) skip_hatch: bool,
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) geometry_epoch: u64,
|
2026-05-14 13:52:04 +03:00
|
|
|
|
/// Camera generation captured when this Primitive was assembled. Paired
|
2026-06-05 09:16:44 +03:00
|
|
|
|
/// with `geometry_epoch` so the per-frame scissor / LOD recompute runs.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) camera_generation: u64,
|
perf(render): hold model wire set static instead of re-tessellating on camera moves
The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.
Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.
Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:11 +03:00
|
|
|
|
/// Content id of `wires`. Stable across camera moves (the Model wire set is
|
|
|
|
|
|
/// held static), so `prepare` skips re-uploading the world-space wire buffer
|
|
|
|
|
|
/// when only the camera moved. Non-tile and preview/interim frames carry a
|
|
|
|
|
|
/// fresh id each time → always re-upload.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) wire_content_id: u64,
|
2026-06-19 09:33:53 +03:00
|
|
|
|
/// Selected handles only (no hover) — solid meshes tint these blue.
|
|
|
|
|
|
pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
|
|
|
|
|
|
/// Currently hovered handle — solid meshes tint it orange.
|
|
|
|
|
|
pub(in crate::scene) hover_handle: Option<acadrust::Handle>,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
/// Bumped on selection / hover change. Paired with `wire_content_id` to
|
|
|
|
|
|
/// decide when the xray overlay batch needs rebuilding.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) selection_generation: u64,
|
2026-06-10 21:20:45 +03:00
|
|
|
|
/// Signature of the *selected set* only (not hover). Gates the static-buffer
|
|
|
|
|
|
/// re-upload (hatch tint, issue #71) so a hover doesn't re-upload every
|
|
|
|
|
|
/// hatch / face3d buffer on hatch-heavy drawings.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) selected_sig: u64,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
/// Screen rectangle this viewport fills, **normalized** to the widget
|
|
|
|
|
|
/// bounds (each component in 0..1). A single full-widget view is
|
|
|
|
|
|
/// `(0, 0, 1, 1)`; tiled / floating viewports are sub-rectangles.
|
|
|
|
|
|
/// Normalized form lets `render()` derive the physical sub-clip from
|
|
|
|
|
|
/// the surface clip without needing the scale factor.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) screen_rect: Rectangle,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct Primitive {
|
|
|
|
|
|
/// One entry per viewport drawn this frame (≥1).
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) viewports: Vec<ViewportData>,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
/// Background color used to clear each viewport's MSAA buffer.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) bg_color: [f32; 4],
|
2026-06-27 11:50:11 +03:00
|
|
|
|
/// First `MultiPipeline` inner slot this primitive owns. Paper space (one
|
|
|
|
|
|
/// shader widget, many viewports) uses 0. Per-pane Model widgets each own a
|
|
|
|
|
|
/// distinct slot (= their tile index) so several shader widgets can share
|
|
|
|
|
|
/// the type-keyed pipeline storage without clobbering one another — all
|
|
|
|
|
|
/// `prepare` calls run before all `render` calls, so disjoint slots are
|
|
|
|
|
|
/// safe.
|
|
|
|
|
|
pub(in crate::scene) base_slot: usize,
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 00:08:37 +03:00
|
|
|
|
/// Flags the render pipeline consumes, derived from
|
|
|
|
|
|
/// [`acadrust::entities::ViewportRenderMode`]. Each shaded variant fills
|
|
|
|
|
|
/// 3D faces and meshes; the pure wireframes drop the fill and keep only
|
2026-05-27 05:40:39 +03:00
|
|
|
|
/// edges. `*WithEdges` variants render both. HiddenLine uses a depth
|
|
|
|
|
|
/// prepass: face/mesh fills are uploaded but routed through depth-only
|
|
|
|
|
|
/// pipelines so hidden edges drop out. `FlatShaded` vs `GouraudShaded`
|
|
|
|
|
|
/// differ in shader uniform only and produce identical fill flags here.
|
2026-05-27 00:08:37 +03:00
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
|
pub struct RenderModeFlags {
|
|
|
|
|
|
pub face3d_fill: bool,
|
|
|
|
|
|
pub mesh_fill: bool,
|
|
|
|
|
|
pub show_3d_edges: bool,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
pub hidden_line: bool,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
/// `true` for FlatShaded / FlatShadedWithEdges. The mesh shader
|
|
|
|
|
|
/// reads `Uniforms.flat_shade` and replaces the smooth per-vertex
|
|
|
|
|
|
/// normal with a per-triangle face normal so each triangle reads
|
|
|
|
|
|
/// as a single tone.
|
|
|
|
|
|
pub flat_shade: bool,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn render_mode_flags(
|
|
|
|
|
|
mode: acadrust::entities::ViewportRenderMode,
|
|
|
|
|
|
) -> RenderModeFlags {
|
|
|
|
|
|
use acadrust::entities::ViewportRenderMode as M;
|
|
|
|
|
|
match mode {
|
|
|
|
|
|
M::Wireframe2D | M::Wireframe3D => RenderModeFlags {
|
|
|
|
|
|
face3d_fill: false,
|
|
|
|
|
|
mesh_fill: false,
|
|
|
|
|
|
show_3d_edges: true,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
hidden_line: false,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
flat_shade: false,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
},
|
|
|
|
|
|
M::HiddenLine => RenderModeFlags {
|
|
|
|
|
|
face3d_fill: true,
|
|
|
|
|
|
mesh_fill: true,
|
|
|
|
|
|
show_3d_edges: true,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
hidden_line: true,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
flat_shade: false,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
},
|
2026-05-27 05:57:19 +03:00
|
|
|
|
M::FlatShaded => RenderModeFlags {
|
2026-05-27 00:08:37 +03:00
|
|
|
|
face3d_fill: true,
|
|
|
|
|
|
mesh_fill: true,
|
|
|
|
|
|
show_3d_edges: false,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
hidden_line: false,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
flat_shade: true,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
},
|
2026-05-27 05:57:19 +03:00
|
|
|
|
M::GouraudShaded => RenderModeFlags {
|
|
|
|
|
|
face3d_fill: true,
|
|
|
|
|
|
mesh_fill: true,
|
|
|
|
|
|
show_3d_edges: false,
|
|
|
|
|
|
hidden_line: false,
|
|
|
|
|
|
flat_shade: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
M::FlatShadedWithEdges => RenderModeFlags {
|
2026-05-27 00:08:37 +03:00
|
|
|
|
face3d_fill: true,
|
|
|
|
|
|
mesh_fill: true,
|
|
|
|
|
|
show_3d_edges: true,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
hidden_line: false,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
flat_shade: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
M::GouraudShadedWithEdges => RenderModeFlags {
|
|
|
|
|
|
face3d_fill: true,
|
|
|
|
|
|
mesh_fill: true,
|
|
|
|
|
|
show_3d_edges: true,
|
|
|
|
|
|
hidden_line: false,
|
|
|
|
|
|
flat_shade: false,
|
2026-05-27 00:08:37 +03:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 23:07:43 +03:00
|
|
|
|
// ── shader::Primitive impl ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
impl shader::Primitive for Primitive {
|
2026-05-27 19:47:42 +03:00
|
|
|
|
type Pipeline = MultiPipeline;
|
2026-03-22 23:07:43 +03:00
|
|
|
|
|
|
|
|
|
|
fn prepare(
|
|
|
|
|
|
&self,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
pipeline: &mut MultiPipeline,
|
2026-03-22 23:07:43 +03:00
|
|
|
|
device: &iced::wgpu::Device,
|
|
|
|
|
|
queue: &iced::wgpu::Queue,
|
2026-04-23 21:39:43 +03:00
|
|
|
|
bounds: &Rectangle,
|
2026-03-22 23:07:43 +03:00
|
|
|
|
viewport: &Viewport,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let phys = viewport.physical_size();
|
2026-04-08 12:20:47 +03:00
|
|
|
|
let full_size = Size::new(phys.width, phys.height);
|
|
|
|
|
|
let scale = viewport.scale_factor() as f32;
|
2026-06-27 11:50:11 +03:00
|
|
|
|
pipeline.ensure_len(device, queue, self.base_slot + self.viewports.len());
|
2026-05-27 19:47:42 +03:00
|
|
|
|
|
|
|
|
|
|
for (i, vp) in self.viewports.iter().enumerate() {
|
2026-06-27 11:50:11 +03:00
|
|
|
|
let inner = &mut pipeline.inners[self.base_slot + i];
|
2026-05-28 12:15:00 +03:00
|
|
|
|
// The MSAA / depth / resolve textures are always sized to the
|
|
|
|
|
|
// FULL viewport rectangle (not the on-canvas-visible portion)
|
|
|
|
|
|
// so the camera matrices render at consistent aspect / scale.
|
|
|
|
|
|
// The blit step picks the visible sub-rectangle out via the
|
|
|
|
|
|
// shader's UV crop uniform, which lets partially off-canvas
|
|
|
|
|
|
// viewports composite to their visible surface area without
|
|
|
|
|
|
// drift.
|
2026-05-27 19:47:42 +03:00
|
|
|
|
let clip_size = Size::new(
|
|
|
|
|
|
(vp.screen_rect.width * bounds.width * scale).ceil().max(1.0) as u32,
|
|
|
|
|
|
(vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32,
|
|
|
|
|
|
);
|
|
|
|
|
|
inner.ensure_depth_texture(device, clip_size);
|
|
|
|
|
|
inner.viewcube.ensure_depth_texture(device, full_size);
|
2026-05-28 12:15:00 +03:00
|
|
|
|
// Compute the UV crop for this viewport. `screen_rect` is in
|
|
|
|
|
|
// normalized canvas units (0..1) but may extend negative or
|
|
|
|
|
|
// beyond 1 when the viewport hangs off the canvas. The on-
|
|
|
|
|
|
// canvas portion in viewport-local UV is straightforward to
|
|
|
|
|
|
// derive from how much sticks out on each side.
|
|
|
|
|
|
let sr = vp.screen_rect;
|
|
|
|
|
|
let (uo_x, us_x) = uv_crop_axis(sr.x, sr.width);
|
|
|
|
|
|
let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height);
|
|
|
|
|
|
inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]);
|
2026-05-27 19:47:42 +03:00
|
|
|
|
inner.upload_uniforms(queue, &vp.uniforms);
|
perf(render): cache rendered scene, skip re-raster on unchanged frames
Every cursor movement over the viewport re-rendered the entire GPU scene
(all wires, hatches, meshes) even when nothing visible changed: iced redraws
the whole window on each input message, and the shader's draw/prepare/render
runs every time. On large drawings one such redraw exceeds the frame budget,
so moving the mouse or panning stutters — and it gets worse the bigger the
drawing.
The pipeline already resolves each frame into a persistent, clip-sized
resolve texture and blits that to the surface. This hashes the inputs that
determine the rendered image (camera uniforms, geometry/selection
generations, wire content id, render-mode flags, clip size, live preview
coordinates) into a per-viewport signature. When a frame's signature matches
the previous one the image is pixel-identical, so `prepare` skips the buffer
uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus
the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move
becomes a single fullscreen blit rather than an O(N) re-rasterization.
The ViewCube is excluded from the signature (it renders in its own always-on
pass) so cube hover still tracks while the scene is cached. Any real change —
edit (geometry_epoch), select/hover (selection_generation), camera move or
render-mode toggle — differs the signature and forces a full render, so
nothing goes stale. Navigation still renders every frame (the view genuinely
changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
|
|
|
|
|
|
|
|
|
|
// ── Scene-render cache ────────────────────────────────────────
|
|
|
|
|
|
// A pure cursor move — or any frame where the view, geometry,
|
|
|
|
|
|
// selection and live preview are all unchanged — produces a
|
|
|
|
|
|
// pixel-identical image. The resolve texture still holds it, so we
|
|
|
|
|
|
// skip every geometry pass + the MSAA resolve (in `Pipeline::render`
|
|
|
|
|
|
// via `skip_geometry`) and its per-frame O(N) scissor / LOD
|
|
|
|
|
|
// recompute below, letting the frame reduce to a single blit. This
|
|
|
|
|
|
// is the main fix for the per-mouse-move stall that scales with
|
|
|
|
|
|
// drawing size. The ViewCube is excluded from the signature and
|
|
|
|
|
|
// keeps updating in its own always-on pass, so cube hover still
|
|
|
|
|
|
// tracks while the scene is cached.
|
|
|
|
|
|
let sig = render_signature(vp, clip_size.width, clip_size.height);
|
|
|
|
|
|
let skip = inner.render_sig != u64::MAX && sig == inner.render_sig;
|
|
|
|
|
|
inner.render_sig = sig;
|
|
|
|
|
|
inner.skip_geometry = skip;
|
2026-07-05 09:48:49 +02:00
|
|
|
|
// Interaction LOD: skip the hatch draw this frame while navigating.
|
|
|
|
|
|
inner.skip_hatch_frame = vp.skip_hatch;
|
perf(render): cache rendered scene, skip re-raster on unchanged frames
Every cursor movement over the viewport re-rendered the entire GPU scene
(all wires, hatches, meshes) even when nothing visible changed: iced redraws
the whole window on each input message, and the shader's draw/prepare/render
runs every time. On large drawings one such redraw exceeds the frame budget,
so moving the mouse or panning stutters — and it gets worse the bigger the
drawing.
The pipeline already resolves each frame into a persistent, clip-sized
resolve texture and blits that to the surface. This hashes the inputs that
determine the rendered image (camera uniforms, geometry/selection
generations, wire content id, render-mode flags, clip size, live preview
coordinates) into a per-viewport signature. When a frame's signature matches
the previous one the image is pixel-identical, so `prepare` skips the buffer
uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus
the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move
becomes a single fullscreen blit rather than an O(N) re-rasterization.
The ViewCube is excluded from the signature (it renders in its own always-on
pass) so cube hover still tracks while the scene is cached. Any real change —
edit (geometry_epoch), select/hover (selection_generation), camera move or
render-mode toggle — differs the signature and forces a full render, so
nothing goes stale. Navigation still renders every frame (the view genuinely
changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
|
|
|
|
if skip {
|
|
|
|
|
|
if vp.show_viewcube {
|
|
|
|
|
|
inner.viewcube.upload(
|
|
|
|
|
|
queue,
|
|
|
|
|
|
vp.cam_rotation,
|
|
|
|
|
|
vp.compass_rotation,
|
|
|
|
|
|
(vp.screen_rect.width * bounds.width) as u32,
|
|
|
|
|
|
(vp.screen_rect.height * bounds.height) as u32,
|
|
|
|
|
|
vp.hover_region,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-10 21:20:45 +03:00
|
|
|
|
// Third component is the *selected-set* signature (not
|
|
|
|
|
|
// selection_generation, which also bumps on hover) so a rollover
|
|
|
|
|
|
// doesn't re-upload the static hatch / face3d buffers.
|
|
|
|
|
|
let cur_key = (vp.geometry_epoch, vp.camera_generation, vp.selected_sig);
|
2026-05-27 19:47:42 +03:00
|
|
|
|
let fill_mode = vp.fill_mode;
|
|
|
|
|
|
// 3D face fill requires *both* the doc-level FILLMODE *and* the
|
|
|
|
|
|
// per-view Solid toggle. Hatches / wipeouts deliberately ignore
|
|
|
|
|
|
// the view toggle so 2D fills stay on even when the user picks
|
|
|
|
|
|
// the Wireframe overlay style.
|
|
|
|
|
|
let face3d_fill_active = fill_mode && !vp.view_wireframe;
|
|
|
|
|
|
if cur_key != inner.cached_epoch {
|
2026-06-09 22:47:48 +03:00
|
|
|
|
// Hatches carry a selected-tint, so re-upload on a geometry OR
|
|
|
|
|
|
// a selection change (issue #71); images / meshes only need a
|
|
|
|
|
|
// geometry change.
|
|
|
|
|
|
let geo_changed = vp.geometry_epoch != inner.cached_epoch.0;
|
2026-06-10 21:20:45 +03:00
|
|
|
|
let sel_changed = vp.selected_sig != inner.cached_epoch.2;
|
2026-06-09 22:47:48 +03:00
|
|
|
|
if geo_changed || sel_changed {
|
2026-05-27 19:47:42 +03:00
|
|
|
|
if fill_mode {
|
|
|
|
|
|
inner.upload_hatches(device, &vp.hatches[..]);
|
|
|
|
|
|
inner.upload_wipeouts(device, &vp.wipeout_hatches[..]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
inner.upload_hatches(device, &[]);
|
|
|
|
|
|
inner.upload_wipeouts(device, &[]);
|
|
|
|
|
|
}
|
2026-06-09 22:47:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
if geo_changed {
|
2026-05-27 19:47:42 +03:00
|
|
|
|
inner.upload_images(device, queue, &vp.images[..]);
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
inner.upload_text(device, queue, &vp.text_verts[..]);
|
2026-05-20 04:44:07 +03:00
|
|
|
|
}
|
2026-06-30 22:46:29 +03:00
|
|
|
|
inner.cached_epoch = cur_key;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Face3D edge/fill buffers are world-space and selection-independent
|
|
|
|
|
|
// (upload_face3d takes no selection input), so they only change with
|
|
|
|
|
|
// the geometry or the 3D-fill toggle — never on a pan/orbit. Gating
|
|
|
|
|
|
// here on `(geometry_epoch, face3d_fill_active)` instead of inside the
|
|
|
|
|
|
// `cur_key` block (which carries `camera_generation`) stops a camera
|
|
|
|
|
|
// move from re-walking every wire to rebuild the Face3D fill buffer.
|
|
|
|
|
|
let face3d_key = (vp.geometry_epoch, face3d_fill_active);
|
|
|
|
|
|
if face3d_key != inner.cached_face3d_key {
|
2026-05-27 19:47:42 +03:00
|
|
|
|
inner.upload_face3d(
|
|
|
|
|
|
device,
|
|
|
|
|
|
&vp.face3d_wires[..],
|
|
|
|
|
|
&vp.wires[..],
|
|
|
|
|
|
!face3d_fill_active,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
&vp.draw_depths,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
);
|
2026-06-30 22:46:29 +03:00
|
|
|
|
inner.cached_face3d_key = face3d_key;
|
2026-05-27 19:47:42 +03:00
|
|
|
|
}
|
2026-06-05 09:16:44 +03:00
|
|
|
|
// Wire buffers are world-space, so a camera move alone doesn't
|
|
|
|
|
|
// change them — only the view_proj uniform (uploaded every frame).
|
perf(render): hold model wire set static instead of re-tessellating on camera moves
The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.
Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.
Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:11 +03:00
|
|
|
|
// Gate the upload on the wire content id instead of the camera tick:
|
|
|
|
|
|
// the Model wire set is held static, so its id is unchanged across
|
|
|
|
|
|
// camera moves and the vertex re-pack + GPU write is skipped. Kept
|
|
|
|
|
|
// independent of the `cur_key` block so a preview/interim wire change
|
|
|
|
|
|
// still uploads even when the camera didn't move.
|
2026-06-05 09:16:44 +03:00
|
|
|
|
if vp.wire_content_id != inner.cached_wire_id {
|
|
|
|
|
|
inner.upload_wires(device, &vp.wires[..], &vp.draw_depths);
|
|
|
|
|
|
inner.cached_wire_id = vp.wire_content_id;
|
|
|
|
|
|
}
|
2026-06-05 10:04:22 +03:00
|
|
|
|
// Selection xray overlay — rebuilt when the selection changes or the
|
|
|
|
|
|
// underlying wires changed. A pick bumps only selection_generation,
|
|
|
|
|
|
// so this refreshes without re-tessellating or re-uploading the main
|
|
|
|
|
|
// wire buffers.
|
|
|
|
|
|
let sel_key = (vp.wire_content_id, vp.selection_generation);
|
|
|
|
|
|
if sel_key != inner.cached_selection {
|
|
|
|
|
|
inner.upload_selected_wires(
|
|
|
|
|
|
device,
|
|
|
|
|
|
&vp.wires[..],
|
2026-06-19 09:33:53 +03:00
|
|
|
|
&vp.selected_handles,
|
|
|
|
|
|
vp.hover_handle,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
&vp.draw_depths,
|
|
|
|
|
|
);
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
// Text highlight rides the same selection key: a pick / rollover
|
|
|
|
|
|
// recolours the selected / hovered glyphs without touching the
|
|
|
|
|
|
// base text buffer.
|
|
|
|
|
|
inner.upload_text_highlight(
|
|
|
|
|
|
device,
|
|
|
|
|
|
&vp.wires[..],
|
|
|
|
|
|
&vp.selected_handles,
|
|
|
|
|
|
vp.hover_handle,
|
|
|
|
|
|
);
|
2026-06-05 10:04:22 +03:00
|
|
|
|
inner.cached_selection = sel_key;
|
|
|
|
|
|
}
|
2026-06-30 22:46:29 +03:00
|
|
|
|
// Batched solid meshes — geometry-only, so they ride the geometry
|
|
|
|
|
|
// epoch alone and stay resident across camera moves and selection /
|
|
|
|
|
|
// hover changes (no per-pick rebuild of the whole solid set).
|
|
|
|
|
|
if vp.geometry_epoch != inner.cached_mesh_batch_epoch {
|
|
|
|
|
|
inner.upload_mesh_batch(device, &vp.meshes[..]);
|
|
|
|
|
|
inner.cached_mesh_batch_epoch = vp.geometry_epoch;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Selection / hover highlight overlay — tinted copies of just the
|
|
|
|
|
|
// picked solids, rebuilt only when the highlight set (or geometry)
|
|
|
|
|
|
// changes. Drawn over the static batch so the base never re-packs.
|
|
|
|
|
|
let hl_key = (vp.geometry_epoch, vp.selection_generation);
|
|
|
|
|
|
if hl_key != inner.cached_highlight_key {
|
|
|
|
|
|
inner.upload_mesh_highlight(
|
2026-06-19 09:33:53 +03:00
|
|
|
|
device,
|
|
|
|
|
|
&vp.meshes[..],
|
|
|
|
|
|
&vp.selected_handles,
|
|
|
|
|
|
vp.hover_handle,
|
|
|
|
|
|
);
|
2026-06-30 22:46:29 +03:00
|
|
|
|
inner.cached_highlight_key = hl_key;
|
2026-06-19 09:33:53 +03:00
|
|
|
|
}
|
2026-06-05 10:57:38 +03:00
|
|
|
|
// Live overlay (command preview / interim / grip drag) — small and
|
|
|
|
|
|
// refreshed every frame it's present, so a drag never re-uploads
|
|
|
|
|
|
// the resident base wire buffer.
|
|
|
|
|
|
inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths);
|
2026-06-24 20:14:41 +03:00
|
|
|
|
// Cull / scissor / LOD project AABBs relative-to-eye (matching the
|
|
|
|
|
|
// GPU's RTE path) so the math stays precise at UTM-scale coords.
|
|
|
|
|
|
let view_rot = vp.uniforms.view_rot;
|
|
|
|
|
|
let eye = glam::DVec3::new(
|
|
|
|
|
|
vp.uniforms.eye_high[0] as f64 + vp.uniforms.eye_low[0] as f64,
|
|
|
|
|
|
vp.uniforms.eye_high[1] as f64 + vp.uniforms.eye_low[1] as f64,
|
|
|
|
|
|
vp.uniforms.eye_high[2] as f64 + vp.uniforms.eye_low[2] as f64,
|
|
|
|
|
|
);
|
|
|
|
|
|
inner.compute_wire_scissors(view_rot, eye, clip_size.width, clip_size.height);
|
|
|
|
|
|
inner.compute_wipeout_scissors(view_rot, eye, clip_size.width, clip_size.height);
|
|
|
|
|
|
inner.compute_image_scissors(view_rot, eye, clip_size.width, clip_size.height);
|
|
|
|
|
|
inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height);
|
|
|
|
|
|
inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height);
|
|
|
|
|
|
inner.compute_mesh_lod(view_rot, eye, clip_size.width, clip_size.height);
|
2026-05-27 19:47:42 +03:00
|
|
|
|
if vp.show_viewcube {
|
|
|
|
|
|
inner.viewcube.upload(
|
|
|
|
|
|
queue,
|
|
|
|
|
|
vp.cam_rotation,
|
2026-06-27 01:12:45 +03:00
|
|
|
|
vp.compass_rotation,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
(vp.screen_rect.width * bounds.width) as u32,
|
|
|
|
|
|
(vp.screen_rect.height * bounds.height) as u32,
|
|
|
|
|
|
vp.hover_region,
|
|
|
|
|
|
);
|
2026-05-14 13:52:04 +03:00
|
|
|
|
}
|
2026-04-24 01:31:52 +03:00
|
|
|
|
}
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render(
|
|
|
|
|
|
&self,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
pipeline: &MultiPipeline,
|
2026-03-22 23:07:43 +03:00
|
|
|
|
encoder: &mut iced::wgpu::CommandEncoder,
|
|
|
|
|
|
target: &iced::wgpu::TextureView,
|
|
|
|
|
|
clip: &Rectangle<u32>,
|
|
|
|
|
|
) {
|
2026-05-28 12:15:00 +03:00
|
|
|
|
let cw = clip.width as f32;
|
|
|
|
|
|
let ch = clip.height as f32;
|
|
|
|
|
|
let clip_right = clip.x + clip.width;
|
|
|
|
|
|
let clip_bottom = clip.y + clip.height;
|
2026-05-27 19:47:42 +03:00
|
|
|
|
for (i, vp) in self.viewports.iter().enumerate() {
|
2026-06-27 11:50:11 +03:00
|
|
|
|
let Some(inner) = pipeline.inners.get(self.base_slot + i) else {
|
2026-05-27 19:47:42 +03:00
|
|
|
|
break;
|
|
|
|
|
|
};
|
2026-05-28 12:15:00 +03:00
|
|
|
|
// Where the viewport would land on the surface in absolute
|
|
|
|
|
|
// pixels (i32 because either edge may stick off the canvas).
|
|
|
|
|
|
let vp_full_x = clip.x as i32 + (vp.screen_rect.x * cw) as i32;
|
|
|
|
|
|
let vp_full_y = clip.y as i32 + (vp.screen_rect.y * ch) as i32;
|
|
|
|
|
|
let vp_full_w = (vp.screen_rect.width * cw).max(1.0) as i32;
|
|
|
|
|
|
let vp_full_h = (vp.screen_rect.height * ch).max(1.0) as i32;
|
|
|
|
|
|
// Intersect with the surface clip — that's the slice we blit.
|
|
|
|
|
|
let dest_x = vp_full_x.max(clip.x as i32);
|
|
|
|
|
|
let dest_y = vp_full_y.max(clip.y as i32);
|
|
|
|
|
|
let dest_right = (vp_full_x + vp_full_w).min(clip_right as i32);
|
|
|
|
|
|
let dest_bottom = (vp_full_y + vp_full_h).min(clip_bottom as i32);
|
|
|
|
|
|
if dest_right <= dest_x || dest_bottom <= dest_y {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let surface_dest = Rectangle {
|
|
|
|
|
|
x: dest_x as u32,
|
|
|
|
|
|
y: dest_y as u32,
|
|
|
|
|
|
width: (dest_right - dest_x) as u32,
|
|
|
|
|
|
height: (dest_bottom - dest_y) as u32,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
};
|
2026-05-28 12:15:00 +03:00
|
|
|
|
let vp_size = Size::new(vp_full_w.max(1) as u32, vp_full_h.max(1) as u32);
|
2026-05-27 19:47:42 +03:00
|
|
|
|
// `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip
|
|
|
|
|
|
// the draw path so meshes use the wireframe pipeline + the
|
|
|
|
|
|
// pre-built triangle-edge index buffer.
|
|
|
|
|
|
let mesh_wireframe = !vp.mesh_fill;
|
|
|
|
|
|
inner.render(
|
|
|
|
|
|
encoder,
|
|
|
|
|
|
target,
|
2026-05-28 12:15:00 +03:00
|
|
|
|
vp_size,
|
|
|
|
|
|
surface_dest,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
self.bg_color,
|
|
|
|
|
|
mesh_wireframe,
|
|
|
|
|
|
vp.hidden_line,
|
|
|
|
|
|
vp.show_3d_edges,
|
|
|
|
|
|
);
|
2026-05-28 12:15:00 +03:00
|
|
|
|
// The ViewCube renders directly to the surface at the full
|
|
|
|
|
|
// viewport rect. Skip it when the viewport's top-right corner
|
|
|
|
|
|
// (where the cube sits) is off-canvas — wgpu's `set_viewport`
|
|
|
|
|
|
// rejects negative origins, and a clamped cube would scale
|
|
|
|
|
|
// distortedly. The active viewport is normally fully visible.
|
|
|
|
|
|
if vp.show_viewcube
|
|
|
|
|
|
&& vp_full_x >= clip.x as i32
|
|
|
|
|
|
&& vp_full_y >= clip.y as i32
|
|
|
|
|
|
&& vp_full_x + vp_full_w <= clip_right as i32
|
|
|
|
|
|
&& vp_full_y + vp_full_h <= clip_bottom as i32
|
|
|
|
|
|
{
|
|
|
|
|
|
let vp_clip = Rectangle {
|
|
|
|
|
|
x: vp_full_x as u32,
|
|
|
|
|
|
y: vp_full_y as u32,
|
|
|
|
|
|
width: vp_full_w as u32,
|
|
|
|
|
|
height: vp_full_h as u32,
|
|
|
|
|
|
};
|
2026-05-27 19:47:42 +03:00
|
|
|
|
inner.viewcube.render(encoder, target, vp_clip);
|
|
|
|
|
|
}
|
2026-04-24 01:31:52 +03:00
|
|
|
|
}
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf(render): cache rendered scene, skip re-raster on unchanged frames
Every cursor movement over the viewport re-rendered the entire GPU scene
(all wires, hatches, meshes) even when nothing visible changed: iced redraws
the whole window on each input message, and the shader's draw/prepare/render
runs every time. On large drawings one such redraw exceeds the frame budget,
so moving the mouse or panning stutters — and it gets worse the bigger the
drawing.
The pipeline already resolves each frame into a persistent, clip-sized
resolve texture and blits that to the surface. This hashes the inputs that
determine the rendered image (camera uniforms, geometry/selection
generations, wire content id, render-mode flags, clip size, live preview
coordinates) into a per-viewport signature. When a frame's signature matches
the previous one the image is pixel-identical, so `prepare` skips the buffer
uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus
the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move
becomes a single fullscreen blit rather than an O(N) re-rasterization.
The ViewCube is excluded from the signature (it renders in its own always-on
pass) so cube hover still tracks while the scene is cached. Any real change —
edit (geometry_epoch), select/hover (selection_generation), camera move or
render-mode toggle — differs the signature and forces a full render, so
nothing goes stale. Navigation still renders every frame (the view genuinely
changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
|
|
|
|
/// Hash of everything that determines one viewport's rendered scene image.
|
|
|
|
|
|
/// Two consecutive frames with the same signature are pixel-identical, so the
|
|
|
|
|
|
/// second may skip the geometry passes and re-blit the resolve texture (see the
|
|
|
|
|
|
/// scene-render cache in `Primitive::prepare` / `Pipeline::render`).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Deliberately EXCLUDES `hover_region` — the ViewCube highlight renders in its
|
|
|
|
|
|
/// own always-on pass, so cube hover must not force a full scene re-render. The
|
|
|
|
|
|
/// live preview IS included (its coordinates), so a rubber-band tracking the
|
|
|
|
|
|
/// cursor still renders, and the frame where the preview clears erases it
|
|
|
|
|
|
/// instead of freezing the last overlay on screen.
|
|
|
|
|
|
fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 {
|
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
|
let mut h = rustc_hash::FxHasher::default();
|
|
|
|
|
|
// Camera + per-view shading flags all live in the uniforms (view_rot, eye
|
|
|
|
|
|
// high/low, viewport size, lineweight, flat_shade, transparency) — hashing
|
|
|
|
|
|
// the raw POD bytes captures every pan / zoom / orbit / twist and toggle in
|
|
|
|
|
|
// one shot. Identical camera state recomputes to identical bits, so a still
|
|
|
|
|
|
// view never spuriously misses the cache.
|
|
|
|
|
|
bytemuck::bytes_of(&vp.uniforms).hash(&mut h);
|
|
|
|
|
|
vp.geometry_epoch.hash(&mut h);
|
|
|
|
|
|
vp.selection_generation.hash(&mut h);
|
|
|
|
|
|
vp.selected_sig.hash(&mut h);
|
|
|
|
|
|
vp.wire_content_id.hash(&mut h);
|
|
|
|
|
|
vp.fill_mode.hash(&mut h);
|
|
|
|
|
|
vp.view_wireframe.hash(&mut h);
|
|
|
|
|
|
vp.mesh_fill.hash(&mut h);
|
|
|
|
|
|
vp.show_3d_edges.hash(&mut h);
|
|
|
|
|
|
vp.hidden_line.hash(&mut h);
|
2026-07-05 09:48:49 +02:00
|
|
|
|
// Interaction-LOD hatch suppression: differs the signature so the settle
|
|
|
|
|
|
// frame (skip_hatch flips false) re-renders with hatches and re-caches.
|
|
|
|
|
|
vp.skip_hatch.hash(&mut h);
|
perf(render): cache rendered scene, skip re-raster on unchanged frames
Every cursor movement over the viewport re-rendered the entire GPU scene
(all wires, hatches, meshes) even when nothing visible changed: iced redraws
the whole window on each input message, and the shader's draw/prepare/render
runs every time. On large drawings one such redraw exceeds the frame budget,
so moving the mouse or panning stutters — and it gets worse the bigger the
drawing.
The pipeline already resolves each frame into a persistent, clip-sized
resolve texture and blits that to the surface. This hashes the inputs that
determine the rendered image (camera uniforms, geometry/selection
generations, wire content id, render-mode flags, clip size, live preview
coordinates) into a per-viewport signature. When a frame's signature matches
the previous one the image is pixel-identical, so `prepare` skips the buffer
uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus
the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move
becomes a single fullscreen blit rather than an O(N) re-rasterization.
The ViewCube is excluded from the signature (it renders in its own always-on
pass) so cube hover still tracks while the scene is cached. Any real change —
edit (geometry_epoch), select/hover (selection_generation), camera move or
render-mode toggle — differs the signature and forces a full render, so
nothing goes stale. Navigation still renders every frame (the view genuinely
changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
|
|
|
|
clip_w.hash(&mut h);
|
|
|
|
|
|
clip_h.hash(&mut h);
|
|
|
|
|
|
// Live overlay (command preview / interim / grip drag). Small — a handful
|
|
|
|
|
|
// of wires — so hashing its coordinates is cheap and catches the endpoint
|
|
|
|
|
|
// moving with the cursor as well as the preview appearing / clearing.
|
|
|
|
|
|
for w in vp.preview_wires.iter() {
|
|
|
|
|
|
w.points.len().hash(&mut h);
|
|
|
|
|
|
for p in &w.points {
|
|
|
|
|
|
p[0].to_bits().hash(&mut h);
|
|
|
|
|
|
p[1].to_bits().hash(&mut h);
|
|
|
|
|
|
p[2].to_bits().hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
h.finish()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 12:15:00 +03:00
|
|
|
|
/// On-canvas-visible UV crop on one axis. `pos` and `size` are in the
|
|
|
|
|
|
/// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)`
|
|
|
|
|
|
/// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit
|
|
|
|
|
|
/// shader — identity `(0.0, 1.0)` for fully on-canvas viewports.
|
|
|
|
|
|
fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) {
|
|
|
|
|
|
if size <= 0.0 {
|
|
|
|
|
|
return (0.0, 1.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
let left_off = (-pos).max(0.0);
|
|
|
|
|
|
let right_off = (pos + size - 1.0).max(0.0);
|
|
|
|
|
|
let visible = (size - left_off - right_off).max(0.0);
|
|
|
|
|
|
(left_off / size, visible / size)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 18:12:10 +03:00
|
|
|
|
/// Apply a clip-space crop to `view_proj` so the sub-rect of the original
|
|
|
|
|
|
/// view defined by UV offset `(uo, vo)` + scale `(us, vs)` is remapped to
|
|
|
|
|
|
/// NDC `[-1, 1]^2`. Identity transform when the sub-rect is the whole
|
|
|
|
|
|
/// view (`uo=vo=0`, `us=vs=1`). Used by viewports that hang off the
|
|
|
|
|
|
/// canvas — the camera frustum stays at full-vp aspect, but only the
|
|
|
|
|
|
/// visible portion lands in the MSAA target.
|
|
|
|
|
|
fn crop_view_proj(view_proj: glam::Mat4, uo: f32, vo: f32, us: f32, vs: f32) -> glam::Mat4 {
|
|
|
|
|
|
// Build the matrix that maps the visible clip-space sub-rect
|
|
|
|
|
|
// x ∈ [2uo - 1, 2(uo+us) - 1]
|
|
|
|
|
|
// y ∈ [1 - 2(vo+vs), 1 - 2vo]
|
|
|
|
|
|
// back to NDC [-1, 1]^2. (Texture v is top-down → camera y flips.)
|
|
|
|
|
|
let us = us.max(1e-6);
|
|
|
|
|
|
let vs = vs.max(1e-6);
|
|
|
|
|
|
let sx = 1.0 / us;
|
|
|
|
|
|
let sy = 1.0 / vs;
|
|
|
|
|
|
let tx = (1.0 - 2.0 * uo - us) / us;
|
|
|
|
|
|
let ty = -(1.0 - 2.0 * vo - vs) / vs;
|
|
|
|
|
|
let crop = glam::Mat4::from_cols_array(&[
|
|
|
|
|
|
sx, 0.0, 0.0, 0.0, // col 0
|
|
|
|
|
|
0.0, sy, 0.0, 0.0, // col 1
|
|
|
|
|
|
0.0, 0.0, 1.0, 0.0, // col 2
|
|
|
|
|
|
tx, ty, 0.0, 1.0, // col 3
|
|
|
|
|
|
]);
|
|
|
|
|
|
crop * view_proj
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 23:07:43 +03:00
|
|
|
|
// ── Render-style helpers (impl Scene) ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
impl Scene {
|
2026-04-06 14:37:23 +03:00
|
|
|
|
/// Returns (entity_color, pattern_length, pattern, line_weight_px, aci).
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn render_style(&self, e: &EntityType) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
2026-04-30 10:00:46 +03:00
|
|
|
|
let (color, pl, pat, lw, aci) = render_style_for(&self.document, e);
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let bg = if self.current_layout == "Model" {
|
|
|
|
|
|
self.bg_color
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.paper_bg_color
|
|
|
|
|
|
};
|
2026-07-01 13:10:39 +03:00
|
|
|
|
// Objects on a locked layer are dimmed toward the background so they
|
|
|
|
|
|
// read as "not editable" (they stay visible and snappable).
|
|
|
|
|
|
let adapted = adapt_to_bg(color, bg);
|
|
|
|
|
|
let final_color = if layer_locked(&self.document, e) {
|
|
|
|
|
|
crate::scene::cache::block_cache::fade_toward_bg(adapted, bg)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
adapted
|
|
|
|
|
|
};
|
|
|
|
|
|
(final_color, pl, pat, lw, aci)
|
2026-04-26 02:36:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 23:07:43 +03:00
|
|
|
|
|
2026-07-01 13:10:39 +03:00
|
|
|
|
/// Whether an entity sits on a locked layer (via the document's layer table).
|
|
|
|
|
|
/// Document-only so it is safe from the parallel tessellation path.
|
|
|
|
|
|
pub(in crate::scene) fn layer_locked(document: &CadDocument, e: &EntityType) -> bool {
|
|
|
|
|
|
document
|
|
|
|
|
|
.layers
|
|
|
|
|
|
.get(&e.common().layer)
|
|
|
|
|
|
.map(|l| l.is_locked())
|
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 02:36:34 +03:00
|
|
|
|
// ── Document-only render-style helpers (no &self, safe to call from parallel contexts) ──
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolves the effective linetype name for an entity, falling back to the
|
|
|
|
|
|
/// layer's linetype when the entity's own linetype is "ByLayer".
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn linetype_name_for<'a>(document: &'a CadDocument, e: &'a EntityType) -> &'a str {
|
2026-04-26 02:36:34 +03:00
|
|
|
|
let elt = &e.common().linetype;
|
|
|
|
|
|
if elt.is_empty() || elt.eq_ignore_ascii_case("bylayer") {
|
|
|
|
|
|
document
|
|
|
|
|
|
.layers
|
|
|
|
|
|
.get(&e.common().layer)
|
|
|
|
|
|
.map(|l| l.line_type.as_str())
|
|
|
|
|
|
.unwrap_or("Continuous")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
elt.as_str()
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
2026-04-26 02:36:34 +03:00
|
|
|
|
}
|
2026-03-22 23:07:43 +03:00
|
|
|
|
|
2026-04-26 02:36:34 +03:00
|
|
|
|
/// Returns `(entity_color, pattern_length, pattern, line_weight_px, aci)` for
|
2026-04-26 22:07:24 +03:00
|
|
|
|
/// an entity, resolving ByLayer color and linetype from the document.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn render_style_for(
|
2026-04-26 02:36:34 +03:00
|
|
|
|
document: &CadDocument,
|
|
|
|
|
|
e: &EntityType,
|
|
|
|
|
|
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
|
|
|
|
|
let layer_name = &e.common().layer;
|
|
|
|
|
|
let (entity_color, aci) = {
|
|
|
|
|
|
let ec = &e.common().color;
|
|
|
|
|
|
let resolved = if *ec == AcadColor::ByLayer {
|
|
|
|
|
|
document
|
2026-03-22 23:07:43 +03:00
|
|
|
|
.layers
|
2026-04-26 02:36:34 +03:00
|
|
|
|
.get(layer_name)
|
|
|
|
|
|
.map(|l| &l.color)
|
|
|
|
|
|
.unwrap_or(&AcadColor::WHITE)
|
2026-03-22 23:07:43 +03:00
|
|
|
|
} else {
|
2026-04-26 02:36:34 +03:00
|
|
|
|
ec
|
|
|
|
|
|
};
|
|
|
|
|
|
let aci = match resolved {
|
|
|
|
|
|
AcadColor::Index(i) => *i,
|
|
|
|
|
|
_ => 0,
|
|
|
|
|
|
};
|
refactor(tess): move per-entity tessellation into entities/
Each entity's tessellation now lives alongside its definition in
entities/*.rs, attached to the entity type via a local trait:
LegacyTess — Viewport / Insert / Hatch / Ole2Frame
(fallback path for entities not on the truck pipeline)
LeaderTess — Leader
MultiLeaderTess — MultiLeader
DimensionTess — Dimension (carries its dim-only helpers too:
~1700 lines of dim_geometry, dim_text_*, format_*,
tolerance, suppression, etc.)
scene/tessellate.rs keeps only the cross-entity dispatcher plus the
shared dim machinery reused by leader/multileader/dimension
(ArrowKind, DimGeom, append_arrow, arrow_from_block, add_segment,
add_polyline, normalized_or, push_tri, color_or_inherit, entity_z,
offset_snap_pts). Cross-entity arc helpers + aci_to_rgba moved to
scene/tess_util.rs.
scene/tessellate.rs shrinks from ~3300 to 809 lines. Compile clean,
all 9 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:12:27 +03:00
|
|
|
|
let [r, g, b, _] = tess_util::aci_to_rgba(resolved);
|
2026-04-26 02:36:34 +03:00
|
|
|
|
let alpha = 1.0 - e.common().transparency.as_percent() as f32;
|
|
|
|
|
|
([r, g, b, alpha], aci)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let lt_name = linetype_name_for(document, e);
|
2026-05-01 21:13:56 +03:00
|
|
|
|
// Effective scale = global LTSCALE × per-entity scale (both default to 1.0).
|
|
|
|
|
|
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
|
2026-04-26 02:36:34 +03:00
|
|
|
|
let (pattern_length, pattern) = resolve_pattern(&document.line_types, lt_name, lt_scale);
|
|
|
|
|
|
|
|
|
|
|
|
let line_weight_px = {
|
2026-05-23 09:45:24 +03:00
|
|
|
|
// LWDISPLAY is no longer evaluated here — the toggle is now applied in
|
|
|
|
|
|
// the wire shader via `Uniforms.lwdisplay_enable`, so we always bake the
|
|
|
|
|
|
// entity's resolved (layer-inherited) weight. Toggling lineweight
|
|
|
|
|
|
// visibility costs only a uniform write, not a retessellate.
|
|
|
|
|
|
let ew = &e.common().line_weight;
|
|
|
|
|
|
let resolved = match ew {
|
|
|
|
|
|
LineWeight::ByLayer | LineWeight::ByBlock | LineWeight::Default => document
|
|
|
|
|
|
.layers
|
|
|
|
|
|
.get(layer_name)
|
|
|
|
|
|
.map(|l| &l.line_weight)
|
|
|
|
|
|
.unwrap_or(&LineWeight::Default),
|
|
|
|
|
|
_ => ew,
|
|
|
|
|
|
};
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
lineweight_to_px(resolved)
|
2026-04-26 02:36:34 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
(entity_color, pattern_length, pattern, line_weight_px, aci)
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
/// Resolved render style used as the inheritance source for a block child's
|
|
|
|
|
|
/// ByBlock properties (the INSERT's own style) or its layer-0 properties (the
|
|
|
|
|
|
/// INSERT's *layer* style). Bundled so it threads through the block-expansion
|
|
|
|
|
|
/// call chain as a single value.
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
|
pub struct InheritStyle {
|
|
|
|
|
|
pub color: [f32; 4],
|
|
|
|
|
|
pub pat_len: f32,
|
|
|
|
|
|
pub pat: [f32; 8],
|
|
|
|
|
|
pub lw_px: f32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Convert a concrete (already layer-resolved) lineweight to display pixels.
|
|
|
|
|
|
pub(crate) fn lineweight_to_px(lw: &LineWeight) -> f32 {
|
|
|
|
|
|
const MM_TO_PX: f32 = 96.0 / 25.4;
|
|
|
|
|
|
// CAD apps display model-space lineweights larger than their true physical
|
|
|
|
|
|
// size so the gradations stay legible on screen — at true scale a 0.5 mm
|
|
|
|
|
|
// line is ~2 px and is indistinguishable from thinner weights (which all
|
|
|
|
|
|
// floor to 1 px). Apply the same legibility boost so weights are pronounced
|
|
|
|
|
|
// and tell apart, matching other DWG editors. (#147)
|
|
|
|
|
|
const LWT_DISPLAY_BOOST: f32 = 2.0;
|
|
|
|
|
|
lw.millimeters()
|
|
|
|
|
|
.map(|mm| (mm as f32 * MM_TO_PX * LWT_DISPLAY_BOOST).max(1.0))
|
|
|
|
|
|
.unwrap_or(1.0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolve a layer's own color / linetype / lineweight to concrete render
|
|
|
|
|
|
/// values — what a fully-ByLayer entity on that layer would draw as. Used for
|
|
|
|
|
|
/// the layer-0 block rule: a block child on layer "0" inherits the block
|
|
|
|
|
|
/// reference's layer through this. Color is returned RAW (background adaptation
|
|
|
|
|
|
/// happens at emit time). Falls back to white / Continuous / 1 px when the
|
|
|
|
|
|
/// layer is missing.
|
|
|
|
|
|
pub(crate) fn layer_render_style(document: &CadDocument, layer_name: &str) -> InheritStyle {
|
|
|
|
|
|
let layer = document.layers.get(layer_name);
|
|
|
|
|
|
let color = layer.map(|l| &l.color).unwrap_or(&AcadColor::WHITE);
|
|
|
|
|
|
let [r, g, b, _] = tess_util::aci_to_rgba(color);
|
|
|
|
|
|
let lt_name = layer.map(|l| l.line_type.as_str()).unwrap_or("Continuous");
|
|
|
|
|
|
let lt_scale = document.header.linetype_scale as f32;
|
|
|
|
|
|
let (pat_len, pat) = resolve_pattern(&document.line_types, lt_name, lt_scale);
|
|
|
|
|
|
let lw = layer.map(|l| &l.line_weight).unwrap_or(&LineWeight::Default);
|
|
|
|
|
|
InheritStyle {
|
|
|
|
|
|
color: [r, g, b, 1.0],
|
|
|
|
|
|
pat_len,
|
|
|
|
|
|
pat,
|
|
|
|
|
|
lw_px: lineweight_to_px(lw),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Like `render_style_for` but resolves a block sub-entity's inherited
|
|
|
|
|
|
/// properties: ByBlock inherits the INSERT's style, and (the layer-0 rule) a
|
|
|
|
|
|
/// sub-entity on layer "0" with ByLayer properties inherits the INSERT's
|
|
|
|
|
|
/// *layer* style (`l0`). Explicit properties always win. Call this for
|
|
|
|
|
|
/// exploded block sub-entities so color/linetype/lineweight propagate right.
|
2026-05-23 23:26:14 +03:00
|
|
|
|
pub(crate) fn render_style_for_block_sub(
|
2026-04-26 22:07:24 +03:00
|
|
|
|
document: &CadDocument,
|
|
|
|
|
|
e: &EntityType,
|
|
|
|
|
|
insert_color: [f32; 4],
|
|
|
|
|
|
insert_pat_len: f32,
|
|
|
|
|
|
insert_pat: [f32; 8],
|
|
|
|
|
|
insert_lw_px: f32,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
l0: InheritStyle,
|
2026-04-26 22:07:24 +03:00
|
|
|
|
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
|
|
|
|
|
let (color, pat_len, pat, lw_px, aci) = render_style_for(document, e);
|
|
|
|
|
|
let common = e.common();
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
let on_l0 = common.layer == "0";
|
2026-04-26 22:07:24 +03:00
|
|
|
|
|
|
|
|
|
|
let final_color = if common.color == AcadColor::ByBlock {
|
|
|
|
|
|
insert_color
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
} else if on_l0 && common.color == AcadColor::ByLayer {
|
|
|
|
|
|
// Inherit the insert layer's RGB but keep the child's own transparency.
|
|
|
|
|
|
[l0.color[0], l0.color[1], l0.color[2], color[3]]
|
2026-04-26 22:07:24 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
color
|
|
|
|
|
|
};
|
|
|
|
|
|
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
let lt_bylayer =
|
|
|
|
|
|
common.linetype.is_empty() || common.linetype.eq_ignore_ascii_case("bylayer");
|
2026-04-26 22:07:24 +03:00
|
|
|
|
let (final_pat_len, final_pat) = if common.linetype.eq_ignore_ascii_case("byblock") {
|
|
|
|
|
|
(insert_pat_len, insert_pat)
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
} else if on_l0 && lt_bylayer {
|
|
|
|
|
|
(l0.pat_len, l0.pat)
|
2026-04-26 22:07:24 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
(pat_len, pat)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let final_lw = if matches!(common.line_weight, LineWeight::ByBlock) {
|
|
|
|
|
|
insert_lw_px
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
} else if on_l0 && matches!(common.line_weight, LineWeight::ByLayer | LineWeight::Default) {
|
|
|
|
|
|
l0.lw_px
|
2026-04-26 22:07:24 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
lw_px
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
(final_color, final_pat_len, final_pat, final_lw, aci)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 10:00:46 +03:00
|
|
|
|
/// Adapt white→black or black→white based on background luminance.
|
|
|
|
|
|
/// White entities on light backgrounds become black, black entities on dark
|
|
|
|
|
|
/// backgrounds become white. All other colors pass through unchanged.
|
2026-05-23 23:26:14 +03:00
|
|
|
|
pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
2026-04-30 10:00:46 +03:00
|
|
|
|
let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
|
|
|
|
|
|
let is_white = color[0] > 0.95 && color[1] > 0.95 && color[2] > 0.95;
|
|
|
|
|
|
let is_black = color[0] < 0.05 && color[1] < 0.05 && color[2] < 0.05;
|
|
|
|
|
|
if is_white && lum > 0.5 {
|
|
|
|
|
|
[0.0, 0.0, 0.0, color[3]]
|
|
|
|
|
|
} else if is_black && lum <= 0.5 {
|
|
|
|
|
|
[1.0, 1.0, 1.0, color[3]]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
color
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 02:18:41 +03:00
|
|
|
|
// ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──
|
|
|
|
|
|
|
|
|
|
|
|
impl Scene {
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
/// Gather the SDF glyph quads carried on a viewport's wire set into one
|
|
|
|
|
|
/// flat vertex list for the text render pass. The tessellator attaches the
|
|
|
|
|
|
/// quads to each entity's own wire (and the block-expand loop transforms
|
|
|
|
|
|
/// block-instance quads to world), so gathering is a cheap walk. Cached on
|
|
|
|
|
|
/// `wire_content_id` — the wire-buffer content id — so an unchanged wire
|
|
|
|
|
|
/// set (pan / zoom) is walked once, not every frame; the id changes when
|
|
|
|
|
|
/// geometry or selection rebuilds the wires, re-tinting selected glyphs.
|
|
|
|
|
|
/// Empty when SDF text is disabled.
|
|
|
|
|
|
pub(in crate::scene) fn gather_text_verts(
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
&self,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
wires: &[WireModel],
|
|
|
|
|
|
wire_content_id: u64,
|
2026-07-07 08:29:18 +03:00
|
|
|
|
) -> std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>> {
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
if !crate::scene::text::sdf_atlas::sdf_text_enabled() {
|
|
|
|
|
|
return Arc::new(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
{
|
|
|
|
|
|
let cache = self.sdf_text_cache.borrow();
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
if let Some((id, verts)) = cache.as_ref() {
|
|
|
|
|
|
if *id == wire_content_id {
|
2026-07-07 08:29:18 +03:00
|
|
|
|
return verts.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
let mut out: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
|
|
|
|
|
for w in wires {
|
|
|
|
|
|
if !w.text_verts.is_empty() {
|
|
|
|
|
|
out.extend_from_slice(&w.text_verts);
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
let verts = Arc::new(out);
|
|
|
|
|
|
*self.sdf_text_cache.borrow_mut() = Some((wire_content_id, verts.clone()));
|
|
|
|
|
|
verts
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 19:55:49 +03:00
|
|
|
|
/// Build the unified multi-viewport `Primitive` for the current layout.
|
|
|
|
|
|
/// Model layout → one full-window viewport (more once tiled); paper
|
|
|
|
|
|
/// layout → one viewport per floating content viewport. Each entry is
|
|
|
|
|
|
/// rendered into its own screen rectangle by its own inner pipeline.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn build_viewports(
|
2026-05-27 19:55:49 +03:00
|
|
|
|
&self,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
model_render_mode: acadrust::entities::ViewportRenderMode,
|
2026-05-28 09:45:19 +03:00
|
|
|
|
_hover_region: Option<usize>,
|
2026-05-27 19:55:49 +03:00
|
|
|
|
) -> Primitive {
|
2026-05-28 09:45:19 +03:00
|
|
|
|
// Hover comes from the scene cell driven by the app-level
|
|
|
|
|
|
// `CursorMoved` handler — the cube overlay sits above the shader
|
|
|
|
|
|
// and would otherwise mask the move event from `Program::update`.
|
|
|
|
|
|
let hover_region = self.viewcube_hover.get();
|
2026-05-27 19:55:49 +03:00
|
|
|
|
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
|
|
|
|
|
|
if bounds.height > 0.0 {
|
|
|
|
|
|
self.set_render_aspect(bounds.width / bounds.height);
|
|
|
|
|
|
self.set_render_pixel_scale(bounds.width, bounds.height);
|
|
|
|
|
|
}
|
|
|
|
|
|
let canvas = (bounds.width.max(1.0), bounds.height.max(1.0));
|
|
|
|
|
|
let instances = self.active_viewports(canvas.0, canvas.1, model_render_mode);
|
2026-05-28 10:03:01 +03:00
|
|
|
|
// Transparent clear — outside drawn geometry the resolve texture
|
2026-06-02 20:33:51 +03:00
|
|
|
|
// stays at alpha=0, so the alpha-blended blit reveals the container
|
|
|
|
|
|
// background (model bg, or the desk colour in a paper layout).
|
2026-05-28 10:03:01 +03:00
|
|
|
|
let bg_color = [0.0, 0.0, 0.0, 0.0];
|
2026-05-27 19:55:49 +03:00
|
|
|
|
let viewports: Vec<ViewportData> = instances
|
|
|
|
|
|
.iter()
|
2026-05-28 18:12:10 +03:00
|
|
|
|
.filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region))
|
2026-05-27 19:55:49 +03:00
|
|
|
|
.collect();
|
2026-06-02 20:33:51 +03:00
|
|
|
|
// Empty viewports → blit nothing; the container background (model bg
|
|
|
|
|
|
// or the paper desk colour) stays visible.
|
2026-06-27 11:50:11 +03:00
|
|
|
|
Primitive {
|
|
|
|
|
|
viewports,
|
|
|
|
|
|
bg_color,
|
|
|
|
|
|
base_slot: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build a single-pane Model primitive: the viewport for tile `tile_idx`,
|
|
|
|
|
|
/// filling the shader widget's own `bounds` (= the pane rectangle the
|
|
|
|
|
|
/// `pane_grid` laid out). Each Model pane is its own shader widget, so the
|
|
|
|
|
|
/// camera matrices use the pane aspect for free and the primitive owns
|
|
|
|
|
|
/// pipeline slot `tile_idx`. The active tile renders the live camera /
|
|
|
|
|
|
/// render-mode; the rest use their stored snapshot.
|
|
|
|
|
|
pub(in crate::scene) fn build_viewport_for_pane(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
tile_idx: usize,
|
|
|
|
|
|
model_render_mode: acadrust::entities::ViewportRenderMode,
|
|
|
|
|
|
) -> Primitive {
|
|
|
|
|
|
let hover_region = self.viewcube_hover.get();
|
|
|
|
|
|
let canvas = (bounds.width.max(1.0), bounds.height.max(1.0));
|
|
|
|
|
|
let bg_color = [0.0, 0.0, 0.0, 0.0];
|
|
|
|
|
|
let tiles = self.model_tiles.borrow();
|
|
|
|
|
|
let Some(tile) = tiles.get(tile_idx) else {
|
|
|
|
|
|
return Primitive {
|
|
|
|
|
|
viewports: vec![],
|
|
|
|
|
|
bg_color,
|
|
|
|
|
|
base_slot: tile_idx,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
let active = self.active_model_tile.get();
|
|
|
|
|
|
let is_active = tile_idx == active;
|
|
|
|
|
|
let camera = if is_active {
|
|
|
|
|
|
self.camera.borrow().clone()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tile.camera.clone()
|
|
|
|
|
|
};
|
|
|
|
|
|
let inst = ViewportInstance {
|
|
|
|
|
|
handle: Handle::NULL,
|
|
|
|
|
|
tile_idx: Some(tile_idx),
|
|
|
|
|
|
// Fills the whole widget (= pane); normalized rect is (0,0,1,1).
|
|
|
|
|
|
screen_rect: Rectangle {
|
|
|
|
|
|
x: 0.0,
|
|
|
|
|
|
y: 0.0,
|
|
|
|
|
|
width: canvas.0,
|
|
|
|
|
|
height: canvas.1,
|
|
|
|
|
|
},
|
|
|
|
|
|
camera,
|
|
|
|
|
|
render_mode: if is_active {
|
|
|
|
|
|
model_render_mode
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tile.render_mode
|
|
|
|
|
|
},
|
|
|
|
|
|
active: is_active,
|
|
|
|
|
|
grid_on: tile.grid_on,
|
|
|
|
|
|
paper_sheet: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
let viewports = self
|
|
|
|
|
|
.viewport_data_for(&inst, canvas, hover_region)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
Primitive {
|
|
|
|
|
|
viewports,
|
|
|
|
|
|
bg_color,
|
|
|
|
|
|
base_slot: tile_idx,
|
|
|
|
|
|
}
|
2026-05-27 19:55:49 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build one `ViewportData` from a `ViewportInstance`: gathers the
|
|
|
|
|
|
/// viewport's geometry (full model for the Model view / `Handle::NULL`,
|
|
|
|
|
|
/// or the layer-frozen subset for a paper viewport), its camera
|
|
|
|
|
|
/// uniforms, and the normalized screen rectangle.
|
|
|
|
|
|
fn viewport_data_for(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
inst: &ViewportInstance,
|
|
|
|
|
|
canvas: (f32, f32),
|
|
|
|
|
|
hover_region: Option<usize>,
|
2026-05-28 18:12:10 +03:00
|
|
|
|
) -> Option<ViewportData> {
|
2026-05-27 19:55:49 +03:00
|
|
|
|
let flags = render_mode_flags(inst.render_mode);
|
|
|
|
|
|
let view_wireframe = !flags.face3d_fill;
|
|
|
|
|
|
|
2026-05-28 18:12:10 +03:00
|
|
|
|
// Clip the viewport rect to the canvas; size the per-viewport MSAA
|
|
|
|
|
|
// / depth / resolve textures to that visible portion. Sizing them
|
|
|
|
|
|
// to the full vp rect would blow past wgpu's per-dimension texture
|
|
|
|
|
|
// limit (8192 on common GPUs) once paper-space zoom grows the rect
|
|
|
|
|
|
// far enough off the canvas.
|
|
|
|
|
|
let full = inst.screen_rect;
|
|
|
|
|
|
if full.width <= 0.0 || full.height <= 0.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let visible_x = full.x.max(0.0);
|
|
|
|
|
|
let visible_y = full.y.max(0.0);
|
|
|
|
|
|
let visible_x_end = (full.x + full.width).min(canvas.0);
|
|
|
|
|
|
let visible_y_end = (full.y + full.height).min(canvas.1);
|
|
|
|
|
|
let visible_w = (visible_x_end - visible_x).max(0.0);
|
|
|
|
|
|
let visible_h = (visible_y_end - visible_y).max(0.0);
|
|
|
|
|
|
if visible_w < 1.0 || visible_h < 1.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let uo = ((visible_x - full.x) / full.width).clamp(0.0, 1.0);
|
|
|
|
|
|
let vo = ((visible_y - full.y) / full.height).clamp(0.0, 1.0);
|
|
|
|
|
|
let us = (visible_w / full.width).clamp(0.0, 1.0);
|
|
|
|
|
|
let vs = (visible_h / full.height).clamp(0.0, 1.0);
|
|
|
|
|
|
|
perf(render): hold model wire set static instead of re-tessellating on camera moves
The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.
Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.
Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:11 +03:00
|
|
|
|
// Model tiles all share one resident, camera-independent wire set
|
|
|
|
|
|
// (`model_tile_wires_arc` holds it static). `tile_wire_gen` is that
|
|
|
|
|
|
// set's content id: stable across camera moves, so the GPU wire upload
|
|
|
|
|
|
// and the Face3D split below are skipped every frame the geometry is
|
|
|
|
|
|
// unchanged. The paper-space sources have no stable id and force a
|
|
|
|
|
|
// re-upload.
|
2026-06-05 09:16:44 +03:00
|
|
|
|
let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx {
|
2026-05-28 20:13:23 +03:00
|
|
|
|
let aspect = if full.height > 0.0 {
|
|
|
|
|
|
full.width / full.height
|
|
|
|
|
|
} else {
|
|
|
|
|
|
1.0
|
|
|
|
|
|
};
|
2026-06-05 09:16:44 +03:00
|
|
|
|
let arc = self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height);
|
|
|
|
|
|
(arc, Some(self.last_model_wire_gen.get()))
|
2026-06-02 20:05:51 +03:00
|
|
|
|
} else if inst.paper_sheet {
|
|
|
|
|
|
// The sheet renders the paper block's own entities + viewport
|
|
|
|
|
|
// borders — NOT the projected viewport content (the GPU content
|
|
|
|
|
|
// viewports draw that themselves).
|
2026-06-05 09:16:44 +03:00
|
|
|
|
(self.paper_sheet_wires_arc(), None)
|
2026-05-28 20:13:23 +03:00
|
|
|
|
} else if inst.handle == acadrust::Handle::NULL {
|
2026-06-05 09:16:44 +03:00
|
|
|
|
(self.entity_wires_arc(), None)
|
2026-05-27 19:55:49 +03:00
|
|
|
|
} else {
|
2026-06-05 09:16:44 +03:00
|
|
|
|
(self.model_wires_for_viewport_arc(inst.handle, full.height), None)
|
|
|
|
|
|
};
|
2026-06-05 10:57:38 +03:00
|
|
|
|
// Wire-buffer content id for the upload gate. Preview / interim wires
|
|
|
|
|
|
// are NOT part of this buffer anymore (they go in a separate per-frame
|
|
|
|
|
|
// overlay buffer below), so the base id is the stable tile content gen
|
|
|
|
|
|
// — a drag no longer re-uploads the whole base wire set every move.
|
|
|
|
|
|
// Non-tile paths have no stable id and force a re-upload.
|
2026-06-05 09:16:44 +03:00
|
|
|
|
let wire_content_id = match tile_wire_gen {
|
2026-06-05 10:57:38 +03:00
|
|
|
|
Some(g) => g,
|
|
|
|
|
|
None => {
|
2026-06-05 09:16:44 +03:00
|
|
|
|
let n = self.wire_force_nonce.get().wrapping_add(1);
|
|
|
|
|
|
self.wire_force_nonce.set(n);
|
|
|
|
|
|
n | (1u64 << 63)
|
|
|
|
|
|
}
|
2026-05-27 19:55:49 +03:00
|
|
|
|
};
|
2026-06-05 09:23:52 +03:00
|
|
|
|
// Split Face3D wires from the rest. The split is content-only (keyed
|
perf(render): hold model wire set static instead of re-tessellating on camera moves
The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.
Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.
Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:11 +03:00
|
|
|
|
// by the wire-set content id), so while the geometry is unchanged it's
|
|
|
|
|
|
// memoized rather than re-walking every wire (handle lookup + clone)
|
|
|
|
|
|
// each frame. Non-tile paths have no stable id and split inline.
|
2026-06-05 09:23:52 +03:00
|
|
|
|
let (face3d_wires, other_arc) = match tile_wire_gen {
|
|
|
|
|
|
Some(gen) => {
|
|
|
|
|
|
let cached = {
|
|
|
|
|
|
let c = self.split_cache.borrow();
|
|
|
|
|
|
c.as_ref().filter(|(g, ..)| *g == gen).map(|(_, f, o)| (f.clone(), o.clone()))
|
|
|
|
|
|
};
|
|
|
|
|
|
cached.unwrap_or_else(|| {
|
|
|
|
|
|
let (f, o) = split_face3d_wires(&base_arc, &self.document);
|
|
|
|
|
|
let (fa, oa) = (Arc::new(f), Arc::new(o));
|
|
|
|
|
|
*self.split_cache.borrow_mut() = Some((gen, fa.clone(), oa.clone()));
|
|
|
|
|
|
(fa, oa)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
let (f, o) = split_face3d_wires(&base_arc, &self.document);
|
|
|
|
|
|
(Arc::new(f), Arc::new(o))
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-05 10:57:38 +03:00
|
|
|
|
// Base wire set — the cached `other` Arc directly, never cloned to
|
|
|
|
|
|
// append overlays. Preview / interim wires ride in their own small
|
|
|
|
|
|
// per-frame buffer so the (potentially huge) base buffer stays resident
|
|
|
|
|
|
// and unchanged while a command preview or grip drag is live.
|
|
|
|
|
|
let all_wires = other_arc;
|
|
|
|
|
|
let preview_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
|
|
|
|
|
|
Arc::new(Vec::new())
|
2026-05-27 19:55:49 +03:00
|
|
|
|
} else {
|
2026-06-05 10:57:38 +03:00
|
|
|
|
let mut v: Vec<WireModel> = Vec::with_capacity(self.preview_wires.len() + 1);
|
2026-05-27 19:55:49 +03:00
|
|
|
|
if let Some(iw) = &self.interim_wire {
|
|
|
|
|
|
v.push(iw.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
v.extend(self.preview_wires.iter().cloned());
|
|
|
|
|
|
Arc::new(v)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 18:12:10 +03:00
|
|
|
|
// Build the camera at the *full* viewport's aspect so the ortho
|
|
|
|
|
|
// frustum matches what the viewport entity stores, then post-
|
|
|
|
|
|
// multiply by a clip-space "zoom into the visible sub-rect" that
|
|
|
|
|
|
// maps the visible portion to NDC [-1, 1]. Geometry passes
|
|
|
|
|
|
// rasterize into a visible-sized MSAA, so `viewport_size` (used
|
|
|
|
|
|
// by the wire shader to extrude line thickness in screen pixels)
|
|
|
|
|
|
// must be the visible size — but `world_per_pixel` is invariant
|
|
|
|
|
|
// under cropping (full_h cancels with vs) so the value computed
|
|
|
|
|
|
// from the full bounds is the one we want.
|
|
|
|
|
|
let full_bounds = Rectangle {
|
2026-05-27 19:55:49 +03:00
|
|
|
|
x: 0.0,
|
|
|
|
|
|
y: 0.0,
|
2026-05-28 18:12:10 +03:00
|
|
|
|
width: full.width.max(1.0),
|
|
|
|
|
|
height: full.height.max(1.0),
|
2026-05-27 19:55:49 +03:00
|
|
|
|
};
|
|
|
|
|
|
let mut uniforms =
|
2026-05-28 18:12:10 +03:00
|
|
|
|
Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display);
|
2026-06-24 20:14:41 +03:00
|
|
|
|
// Crop the rotation-only RTE view-projection to the visible sub-rect.
|
2026-06-22 20:34:47 +03:00
|
|
|
|
uniforms.view_rot = crop_view_proj(uniforms.view_rot, uo, vo, us, vs);
|
2026-05-28 18:12:10 +03:00
|
|
|
|
uniforms.viewport_size = [visible_w, visible_h];
|
2026-05-27 19:55:49 +03:00
|
|
|
|
uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 };
|
2026-06-04 23:25:09 +03:00
|
|
|
|
uniforms.transparency_enable = if self.transparency_display { 1.0 } else { 0.0 };
|
2026-05-27 19:55:49 +03:00
|
|
|
|
|
2026-05-28 18:12:10 +03:00
|
|
|
|
// `screen_rect` carries the *visible* sub-rectangle in normalized
|
|
|
|
|
|
// canvas coords — that's what `Pipeline::prepare` uses to size
|
|
|
|
|
|
// the per-viewport textures and what `Primitive::render` uses to
|
|
|
|
|
|
// pick the surface destination. The UV crop uniform reads as
|
|
|
|
|
|
// identity here, since the texture already covers exactly the
|
|
|
|
|
|
// visible portion.
|
2026-05-27 19:55:49 +03:00
|
|
|
|
let screen_rect = Rectangle {
|
2026-05-28 18:12:10 +03:00
|
|
|
|
x: visible_x / canvas.0,
|
|
|
|
|
|
y: visible_y / canvas.1,
|
|
|
|
|
|
width: visible_w / canvas.0,
|
|
|
|
|
|
height: visible_h / canvas.1,
|
2026-05-27 19:55:49 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-03 00:36:28 +03:00
|
|
|
|
// The paper sheet instance renders only the paper layout block's own
|
|
|
|
|
|
// fills (plus a synthetic white fill for the printable area) — NOT the
|
|
|
|
|
|
// model-block hatches. Those belong inside the floating content
|
|
|
|
|
|
// viewports; rendering them on the full-canvas sheet would let them
|
|
|
|
|
|
// bleed past the viewport borders whenever model coords overlap the
|
|
|
|
|
|
// paper area. Content viewports keep the full set (the model camera +
|
|
|
|
|
|
// per-viewport scissor place / clip them correctly).
|
|
|
|
|
|
let (hatches, wipeout_hatches) = if inst.paper_sheet {
|
2026-06-02 20:25:50 +03:00
|
|
|
|
let mut v: Vec<HatchModel> = Vec::new();
|
|
|
|
|
|
if let Some(sheet) = self.paper_sheet_fill() {
|
|
|
|
|
|
v.push(sheet);
|
|
|
|
|
|
}
|
2026-06-03 00:36:28 +03:00
|
|
|
|
v.extend(self.paper_canvas_hatches().iter().cloned());
|
|
|
|
|
|
(Arc::new(v), self.paper_canvas_wipeouts())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(self.hatch_models_arc(), self.wipeout_models_arc())
|
|
|
|
|
|
};
|
|
|
|
|
|
let images = if inst.paper_sheet {
|
|
|
|
|
|
self.paper_sheet_images()
|
2026-06-02 20:05:51 +03:00
|
|
|
|
} else {
|
2026-06-03 00:36:28 +03:00
|
|
|
|
self.images_arc()
|
2026-06-02 20:05:51 +03:00
|
|
|
|
};
|
2026-07-03 23:29:18 +03:00
|
|
|
|
// The paper sheet shows the layout's own 2-D content (fills, borders,
|
|
|
|
|
|
// annotation) — never the model's 3-D solids. Those are drawn inside
|
|
|
|
|
|
// the floating content viewports, whose model camera + per-viewport
|
|
|
|
|
|
// scissor place and clip them correctly. Feeding the model mesh set to
|
|
|
|
|
|
// the sheet piles every solid onto the paper origin, because the sheet
|
|
|
|
|
|
// camera works in paper coordinates, not model space — the same reason
|
|
|
|
|
|
// the sheet excludes model hatches and wires above.
|
|
|
|
|
|
let meshes = if inst.paper_sheet {
|
|
|
|
|
|
Arc::new(Vec::new())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.meshes_arc()
|
|
|
|
|
|
};
|
2026-06-02 20:05:51 +03:00
|
|
|
|
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
// SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each
|
|
|
|
|
|
// entity's own wire (produced by the tessellator, transformed for
|
|
|
|
|
|
// block instances by the block-expand loop), so here we simply gather
|
|
|
|
|
|
// them from this viewport's wire set. This covers model text, block-
|
|
|
|
|
|
// internal text and the paper sheet's own annotation alike — each set
|
|
|
|
|
|
// draws only the text that belongs to it. Cached on the wire content
|
|
|
|
|
|
// id so an unchanged wire set is not re-walked every frame.
|
|
|
|
|
|
let text_verts = self.gather_text_verts(&all_wires, wire_content_id);
|
2026-05-28 18:12:10 +03:00
|
|
|
|
Some(ViewportData {
|
2026-05-27 19:55:49 +03:00
|
|
|
|
wires: all_wires,
|
2026-06-05 10:57:38 +03:00
|
|
|
|
preview_wires,
|
2026-06-05 09:23:52 +03:00
|
|
|
|
face3d_wires,
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
text_verts,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
draw_depths: self.draw_depth_map(),
|
2026-06-02 20:05:51 +03:00
|
|
|
|
hatches,
|
|
|
|
|
|
wipeout_hatches,
|
|
|
|
|
|
images,
|
2026-07-03 23:29:18 +03:00
|
|
|
|
meshes,
|
2026-05-27 19:55:49 +03:00
|
|
|
|
uniforms,
|
2026-06-22 10:10:47 +03:00
|
|
|
|
cam_rotation: inst.camera.view_rotation_mat() * self.viewcube_ucs_mat(),
|
2026-06-27 01:12:45 +03:00
|
|
|
|
compass_rotation: inst.camera.view_rotation_mat(),
|
2026-05-27 19:55:49 +03:00
|
|
|
|
// Only the active viewport gets the hovered-region highlight.
|
|
|
|
|
|
hover_region: if inst.active { hover_region } else { None },
|
|
|
|
|
|
show_viewcube: inst.active,
|
|
|
|
|
|
fill_mode: self.document.header.fill_mode,
|
|
|
|
|
|
view_wireframe,
|
|
|
|
|
|
mesh_fill: flags.mesh_fill,
|
|
|
|
|
|
show_3d_edges: flags.show_3d_edges,
|
|
|
|
|
|
hidden_line: flags.hidden_line,
|
2026-07-05 09:48:49 +02:00
|
|
|
|
// Interaction LOD: suppress the costly hatch pass while the view is
|
|
|
|
|
|
// actively moving; the scene-render cache holds the full-quality
|
|
|
|
|
|
// (hatched) frame once it settles. Only applied to the on-screen
|
|
|
|
|
|
// Model / paper content — the paper *sheet* keeps its fills.
|
|
|
|
|
|
skip_hatch: !inst.paper_sheet && self.navigating_lod(),
|
2026-05-27 19:55:49 +03:00
|
|
|
|
geometry_epoch: self.geometry_epoch,
|
|
|
|
|
|
camera_generation: self.camera_generation,
|
2026-06-05 09:16:44 +03:00
|
|
|
|
wire_content_id,
|
2026-06-19 09:33:53 +03:00
|
|
|
|
selected_handles: Arc::new(self.selected.iter().copied().collect()),
|
|
|
|
|
|
hover_handle: self.hover_highlight,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
selection_generation: self.selection_generation,
|
2026-06-10 21:20:45 +03:00
|
|
|
|
selected_sig: self.selected_set_sig(),
|
2026-05-27 19:55:49 +03:00
|
|
|
|
screen_rect,
|
2026-05-28 18:12:10 +03:00
|
|
|
|
})
|
2026-05-27 19:55:49 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 02:18:41 +03:00
|
|
|
|
/// Update viewcube hover state from cursor position within `bounds`.
|
2026-05-28 09:45:19 +03:00
|
|
|
|
///
|
|
|
|
|
|
/// The cube draws in the top-right of the *active model tile* (which fills
|
|
|
|
|
|
/// the canvas when there is a single tile), so the hover hit-test maps the
|
|
|
|
|
|
/// cursor into that tile's local space and uses the tile's dimensions.
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn update_viewcube_state(
|
2026-04-23 02:18:41 +03:00
|
|
|
|
&self,
|
|
|
|
|
|
state: &mut CameraState,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
cursor: mouse::Cursor,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let pos = cursor.position_in(bounds);
|
2026-06-22 10:10:47 +03:00
|
|
|
|
let cam_rotation = self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat();
|
2026-04-23 02:18:41 +03:00
|
|
|
|
if let Some(p) = pos {
|
2026-05-28 09:45:19 +03:00
|
|
|
|
let tile = self.active_model_tile_bounds(bounds.width, bounds.height);
|
2026-04-23 02:18:41 +03:00
|
|
|
|
state.hover_region = hover_id(
|
2026-05-28 09:45:19 +03:00
|
|
|
|
p.x - tile.x,
|
|
|
|
|
|
p.y - tile.y,
|
|
|
|
|
|
tile.width,
|
|
|
|
|
|
tile.height,
|
2026-04-23 02:18:41 +03:00
|
|
|
|
cam_rotation,
|
|
|
|
|
|
VIEWCUBE_PX,
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.hover_region = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 11:33:46 +03:00
|
|
|
|
pub(in crate::scene) fn viewcube_mouse_interaction(&self, state: &CameraState) -> mouse::Interaction {
|
2026-04-23 02:18:41 +03:00
|
|
|
|
if state.hover_region.is_some() {
|
|
|
|
|
|
mouse::Interaction::Pointer
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mouse::Interaction::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 23:07:43 +03:00
|
|
|
|
// ── Linetype pattern helper ───────────────────────────────────────────────
|
|
|
|
|
|
|
refactor(tess): move per-entity tessellation into entities/
Each entity's tessellation now lives alongside its definition in
entities/*.rs, attached to the entity type via a local trait:
LegacyTess — Viewport / Insert / Hatch / Ole2Frame
(fallback path for entities not on the truck pipeline)
LeaderTess — Leader
MultiLeaderTess — MultiLeader
DimensionTess — Dimension (carries its dim-only helpers too:
~1700 lines of dim_geometry, dim_text_*, format_*,
tolerance, suppression, etc.)
scene/tessellate.rs keeps only the cross-entity dispatcher plus the
shared dim machinery reused by leader/multileader/dimension
(ArrowKind, DimGeom, append_arrow, arrow_from_block, add_segment,
add_polyline, normalized_or, push_tri, color_or_inherit, entity_z,
offset_snap_pts). Cross-entity arc helpers + aci_to_rgba moved to
scene/tess_util.rs.
scene/tessellate.rs shrinks from ~3300 to 809 lines. Compile clean,
all 9 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:12:27 +03:00
|
|
|
|
pub(crate) fn resolve_pattern(
|
2026-03-22 23:07:43 +03:00
|
|
|
|
table: &acadrust::tables::Table<LineType>,
|
|
|
|
|
|
name: &str,
|
|
|
|
|
|
scale: f32,
|
|
|
|
|
|
) -> (f32, [f32; 8]) {
|
|
|
|
|
|
let solid = (0.0, [0.0f32; 8]);
|
|
|
|
|
|
if name.eq_ignore_ascii_case("continuous")
|
|
|
|
|
|
|| name.eq_ignore_ascii_case("bylayer")
|
|
|
|
|
|
|| name.eq_ignore_ascii_case("byblock")
|
|
|
|
|
|
|| name.is_empty()
|
|
|
|
|
|
{
|
|
|
|
|
|
return solid;
|
|
|
|
|
|
}
|
|
|
|
|
|
let lt = match table.get(name) {
|
|
|
|
|
|
Some(lt) => lt,
|
|
|
|
|
|
None => return solid,
|
|
|
|
|
|
};
|
|
|
|
|
|
if lt.is_continuous() || lt.elements.is_empty() {
|
|
|
|
|
|
return solid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 17:37:24 +03:00
|
|
|
|
// Keep dots (element length exactly 0) as 0.0 so the shader can render
|
|
|
|
|
|
// them as a fixed ~1 px mark; trailing array slots stay 0.0 padding and
|
|
|
|
|
|
// the shader tells the two apart by position (a 0.0 before the last
|
|
|
|
|
|
// non-zero element is a dot, trailing 0.0s are padding). The old code
|
|
|
|
|
|
// encoded dots as `0.01 * scale` — a tiny world-length dash that went
|
|
|
|
|
|
// sub-pixel at normal zoom and dragged the pattern's `min_elem` below one
|
|
|
|
|
|
// pixel, so the dash LOD collapsed dotted / dash-dot lines to solid (or,
|
|
|
|
|
|
// at larger LTSCALE, left only invisible sub-pixel dots between big
|
|
|
|
|
|
// gaps). (#149)
|
2026-03-22 23:07:43 +03:00
|
|
|
|
let mut pat = [0.0f32; 8];
|
|
|
|
|
|
let mut pat_len = 0.0f32;
|
|
|
|
|
|
for (i, el) in lt.elements.iter().take(8).enumerate() {
|
2026-06-25 17:37:24 +03:00
|
|
|
|
// positive = dash, negative = gap, exactly 0 = dot.
|
|
|
|
|
|
let v = el.length as f32 * scale;
|
|
|
|
|
|
pat[i] = v;
|
|
|
|
|
|
pat_len += v.abs();
|
2026-03-22 23:07:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
if pat_len < 1e-6 {
|
|
|
|
|
|
return solid;
|
|
|
|
|
|
}
|
|
|
|
|
|
(pat_len, pat)
|
|
|
|
|
|
}
|
2026-04-28 21:53:31 +03:00
|
|
|
|
|
|
|
|
|
|
/// Partition a wire list into (face3d_wires, other_wires).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Uses a document handle lookup so no changes to WireModel are needed.
|
|
|
|
|
|
/// O(N) per geometry epoch — acceptable since it runs once per epoch.
|
|
|
|
|
|
fn split_face3d_wires(
|
|
|
|
|
|
wires: &[WireModel],
|
|
|
|
|
|
document: &acadrust::CadDocument,
|
|
|
|
|
|
) -> (Vec<WireModel>, Vec<WireModel>) {
|
|
|
|
|
|
let mut face3d = Vec::new();
|
|
|
|
|
|
let mut others = Vec::new();
|
|
|
|
|
|
for w in wires {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let is_face3d = w
|
|
|
|
|
|
.name
|
|
|
|
|
|
.parse::<u64>()
|
2026-04-28 21:53:31 +03:00
|
|
|
|
.ok()
|
|
|
|
|
|
.and_then(|v| document.get_entity(Handle::new(v)))
|
|
|
|
|
|
.map(|e| matches!(e, EntityType::Face3D(_)))
|
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
if is_face3d {
|
|
|
|
|
|
face3d.push(w.clone());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
others.push(w.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(face3d, others)
|
|
|
|
|
|
}
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Layer-0 block inheritance (#221) ──────────────────────────────────────
|
|
|
|
|
|
// A block child on layer "0" with ByLayer properties inherits the block
|
|
|
|
|
|
// reference's *layer*; every other layer is "sticky" (keeps its own layer);
|
|
|
|
|
|
// ByBlock inherits the insert's own style; explicit properties always win.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod layer0_inherit_tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use acadrust::entities::Line;
|
|
|
|
|
|
use acadrust::tables::Layer;
|
|
|
|
|
|
use acadrust::types::{Color, Transparency};
|
|
|
|
|
|
|
|
|
|
|
|
// ACI: 1 = red, 3 = green, 7 = white. Distinct, so the assertions below
|
|
|
|
|
|
// can tell "inherited the insert layer" from "kept layer 0".
|
|
|
|
|
|
fn doc() -> CadDocument {
|
|
|
|
|
|
let mut d = CadDocument::new();
|
|
|
|
|
|
let mut walls = Layer::new("Walls");
|
|
|
|
|
|
walls.color = Color::Index(1); // red
|
|
|
|
|
|
d.layers.add_or_replace(walls);
|
|
|
|
|
|
let mut zero = Layer::new("0");
|
|
|
|
|
|
zero.color = Color::Index(7); // white
|
|
|
|
|
|
d.layers.add_or_replace(zero);
|
|
|
|
|
|
let mut other = Layer::new("Other");
|
|
|
|
|
|
other.color = Color::Index(3); // green
|
|
|
|
|
|
d.layers.add_or_replace(other);
|
|
|
|
|
|
d
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn child(layer: &str, color: Color) -> EntityType {
|
|
|
|
|
|
let mut l = Line::new();
|
|
|
|
|
|
l.common.layer = layer.to_string();
|
|
|
|
|
|
l.common.color = color;
|
|
|
|
|
|
EntityType::Line(l)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resolve(d: &CadDocument, e: &EntityType, ins: [f32; 4]) -> [f32; 4] {
|
|
|
|
|
|
// Insert sits on "Walls"; its layer style is the layer-0 target.
|
|
|
|
|
|
let l0 = layer_render_style(d, "Walls");
|
|
|
|
|
|
render_style_for_block_sub(d, e, ins, l0.pat_len, l0.pat, l0.lw_px, l0).0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn layer0_bylayer_inherits_insert_layer() {
|
|
|
|
|
|
let d = doc();
|
|
|
|
|
|
let walls = layer_render_style(&d, "Walls").color;
|
|
|
|
|
|
let zero = layer_render_style(&d, "0").color;
|
|
|
|
|
|
let c = resolve(&d, &child("0", Color::ByLayer), walls);
|
|
|
|
|
|
assert_eq!(&c[..3], &walls[..3], "layer-0 child must show the insert's layer (Walls)");
|
|
|
|
|
|
assert_ne!(&c[..3], &zero[..3], "layer-0 child must NOT show layer 0's own color");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn nonzero_layer_is_sticky() {
|
|
|
|
|
|
let d = doc();
|
|
|
|
|
|
let walls = layer_render_style(&d, "Walls").color;
|
|
|
|
|
|
let other = layer_render_style(&d, "Other").color;
|
|
|
|
|
|
let c = resolve(&d, &child("Other", Color::ByLayer), walls);
|
|
|
|
|
|
assert_eq!(&c[..3], &other[..3], "a child on a normal layer keeps its own layer");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn byblock_inherits_insert_color() {
|
|
|
|
|
|
let d = doc();
|
|
|
|
|
|
let ins = [0.2, 0.4, 0.6, 1.0];
|
|
|
|
|
|
let c = resolve(&d, &child("0", Color::ByBlock), ins);
|
|
|
|
|
|
assert_eq!(&c[..3], &ins[..3], "ByBlock child uses the insert's color");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 23:41:13 +03:00
|
|
|
|
// A *top-level* (non-block-child) entity on layer 0 with ByLayer colour
|
|
|
|
|
|
// resolves layer 0's own colour and follows it when the layer is recoloured.
|
|
|
|
|
|
// Regression guard for the issue 231 layer-0 repaint path.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn toplevel_layer0_bylayer_follows_layer_color() {
|
|
|
|
|
|
let mut d = doc();
|
|
|
|
|
|
let e = child("0", Color::ByLayer);
|
|
|
|
|
|
let before = render_style_for(&d, &e).0;
|
|
|
|
|
|
if let Some(l) = d.layers.get_mut("0") {
|
|
|
|
|
|
l.color = Color::Index(3); // recolour layer 0 -> green
|
|
|
|
|
|
}
|
|
|
|
|
|
let after = render_style_for(&d, &e).0;
|
|
|
|
|
|
let green = tess_util::aci_to_rgba(&Color::Index(3));
|
|
|
|
|
|
assert_eq!(&after[..3], &green[..3], "top-level layer-0 ByLayer must follow layer 0's colour");
|
|
|
|
|
|
assert_ne!(&before[..3], &after[..3], "colour must change after recolour");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn explicit_color_wins_even_on_layer0() {
|
|
|
|
|
|
let d = doc();
|
|
|
|
|
|
let walls = layer_render_style(&d, "Walls").color;
|
|
|
|
|
|
let green = tess_util::aci_to_rgba(&Color::Index(3));
|
|
|
|
|
|
let c = resolve(&d, &child("0", Color::Index(3)), walls);
|
|
|
|
|
|
assert_eq!(&c[..3], &green[..3], "an explicit color must win even on layer 0");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn layer0_preserves_child_transparency() {
|
|
|
|
|
|
let d = doc();
|
|
|
|
|
|
let walls = layer_render_style(&d, "Walls").color;
|
|
|
|
|
|
let mut l = Line::new();
|
|
|
|
|
|
l.common.layer = "0".to_string();
|
|
|
|
|
|
l.common.color = Color::ByLayer;
|
|
|
|
|
|
l.common.transparency = Transparency::from_percent(0.5); // 50% transparent
|
|
|
|
|
|
let c = resolve(&d, &EntityType::Line(l), walls);
|
|
|
|
|
|
assert_eq!(&c[..3], &walls[..3], "RGB inherited from the insert layer");
|
|
|
|
|
|
assert!((c[3] - 0.5).abs() < 0.02, "child's own 50% transparency is kept, got {}", c[3]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|