forked from 1b-agent/opencadstudio-upstream
4953 lines
212 KiB
Rust
4953 lines
212 KiB
Rust
// 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};
|
||
use acadrust::{CadDocument, EntityType, Handle};
|
||
use glam::Mat4;
|
||
use iced::mouse;
|
||
use iced::widget::shader::{self, Viewport};
|
||
use iced::{Rectangle, Size};
|
||
|
||
use std::sync::Arc;
|
||
|
||
use crate::scene::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
|
||
use crate::scene::pipeline::MultiPipeline;
|
||
use crate::scene::convert::tess_util;
|
||
use crate::scene::model::visual_style_model::{
|
||
resolve_visual_style_handle, MeshVisualStyle,
|
||
};
|
||
use crate::scene::{
|
||
vp_effective_scale, Camera, HatchModel, ImageModel, MeshLodSet, NavPerfSample, Scene,
|
||
SceneLight, Uniforms, ViewportInstance, WireModel,
|
||
};
|
||
|
||
const DISPLAY_STYLE_CACHE_LIMIT: usize = if cfg!(target_arch = "wasm32") { 4 } else { 24 };
|
||
const MM_TO_PX: f32 = 96.0 / 25.4;
|
||
|
||
// ── Camera hover state (shader::Program::State) ───────────────────────────
|
||
|
||
#[derive(Clone, Default)]
|
||
pub struct CameraState {
|
||
pub hover_region: Option<usize>,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct ViewportLightingSettings {
|
||
force_default: bool,
|
||
default_type: i16,
|
||
ambient: [f32; 3],
|
||
sun_handle: Handle,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct ViewportDisplaySettings {
|
||
visual_style: Option<MeshVisualStyle>,
|
||
brightness: f32,
|
||
contrast: f32,
|
||
background: ViewportBackgroundSettings,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct ViewportBackgroundSettings {
|
||
base: [f32; 4],
|
||
colors: [[f32; 4]; 5],
|
||
params: [f32; 4],
|
||
image_params: [f32; 4],
|
||
image_transform: [f32; 4],
|
||
environment_params: [f32; 4],
|
||
fog_color: [f32; 4],
|
||
fog_params: [f32; 4],
|
||
fog_distances: [f32; 4],
|
||
image: Option<crate::scene::model::image_model::DecodedImage>,
|
||
environment: Option<crate::scene::model::image_model::DecodedImage>,
|
||
}
|
||
|
||
impl ViewportBackgroundSettings {
|
||
fn canvas(color: [f32; 4]) -> Self {
|
||
Self {
|
||
base: color,
|
||
colors: [[0.0; 4]; 5],
|
||
params: [0.0; 4],
|
||
image_params: [0.0; 4],
|
||
image_transform: [0.0, 0.0, 1.0, 1.0],
|
||
environment_params: [0.0, 0.0, 0.25, 0.35],
|
||
fog_color: [0.0, 0.0, 0.0, 1.0],
|
||
fog_params: [0.0; 4],
|
||
fog_distances: [0.0, 1.0, 0.0, 0.0],
|
||
image: None,
|
||
environment: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ViewportDisplaySettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
visual_style: None,
|
||
brightness: 0.0,
|
||
contrast: 0.0,
|
||
background: ViewportBackgroundSettings::canvas([0.0; 4]),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ViewportLightingSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
force_default: true,
|
||
default_type: 1,
|
||
ambient: [0.18; 3],
|
||
sun_handle: Handle::NULL,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── GPU primitive ─────────────────────────────────────────────────────────
|
||
|
||
/// 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.
|
||
#[derive(Debug)]
|
||
pub struct ViewportData {
|
||
/// Stable identity of the source viewport (its entity handle / tile index /
|
||
/// sheet role). The renderer addresses pipeline slots by list index but
|
||
/// drops off-canvas viewports, so this lets the slot detect when it has been
|
||
/// reused by a different viewport and reset its (index-addressed) caches.
|
||
pub(in crate::scene) instance_id: u64,
|
||
/// Weak resident source. Scene owns the strong Arc; retaining another one
|
||
/// in the previous shader Primitive prevented the next UI event from
|
||
/// splicing a changed entity in place and forced a full rebuild.
|
||
pub(in crate::scene) wires: std::sync::Weak<Vec<WireModel>>,
|
||
/// This content viewport's non-rectangular clip boundary (paper layouts
|
||
/// only), as a polygon already projected into the viewport's render-target
|
||
/// NDC. The GPU stamps it into the stencil so content is clipped to the
|
||
/// shape. Empty for rectangular viewports, the paper sheet, and Model space.
|
||
pub(in crate::scene) clip_boundary_ndc: Arc<Vec<[f32; 2]>>,
|
||
/// 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.
|
||
pub(in crate::scene) preview_wires: Arc<Vec<WireModel>>,
|
||
/// Non-current scale representations of selected or hovered annotative
|
||
/// entities. Uploaded with the xray highlight instead of the resident set.
|
||
pub(in crate::scene) annotation_context_wires: Arc<Vec<WireModel>>,
|
||
/// One/few live hatch models for grip editing. Uploaded through a separate
|
||
/// tiny GPU batch so the resident hatch buffer remains untouched.
|
||
pub(in crate::scene) preview_hatches: Arc<Vec<HatchModel>>,
|
||
/// 3DFACE entity wires — separated so they are uploaded to the dedicated
|
||
/// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
|
||
pub(in crate::scene) face3d_wires: Arc<Vec<WireModel>>,
|
||
/// 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>>,
|
||
/// Live grip-drag / command-preview glyph quads. Kept out of the epoch-cached
|
||
/// `text_verts` and uploaded to a per-frame buffer, so text dragged by a grip
|
||
/// stays visible even though it's hidden from the base text set (issue #316).
|
||
pub(in crate::scene) preview_text_verts:
|
||
Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>,
|
||
/// 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.
|
||
/// See `Scene::draw_depth_generation`. Cached uploads that bake a depth are
|
||
/// only reusable while this is unchanged.
|
||
pub(in crate::scene) draw_depth_generation: u64,
|
||
pub(in crate::scene) draw_depths:
|
||
std::sync::Weak<rustc_hash::FxHashMap<u64, [f32; 2]>>,
|
||
pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
|
||
/// Wipeout fills — rendered in a separate pass AFTER wires.
|
||
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) background_image:
|
||
Option<crate::scene::model::image_model::DecodedImage>,
|
||
pub(in crate::scene) environment_image:
|
||
Option<crate::scene::model::image_model::DecodedImage>,
|
||
pub(in crate::scene) uniforms: Uniforms,
|
||
/// World-space camera forward — the parallel view direction. DISPSILH
|
||
/// silhouettes use this alone (not the eye position), so the outline follows
|
||
/// the view angle and doesn't shift with pan / perspective foreshortening.
|
||
pub(in crate::scene) view_dir: glam::Vec3,
|
||
/// Camera rotation matrix derived from the quaternion.
|
||
/// Used by the ViewCube pipeline — no gimbal lock.
|
||
pub(in crate::scene) cam_rotation: Mat4,
|
||
/// 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,
|
||
pub(in crate::scene) hover_region: Option<usize>,
|
||
pub(in crate::scene) show_viewcube: bool,
|
||
/// Set when REDRAW/REDRAWALL requested a forced re-rasterize of this
|
||
/// viewport this frame — bypasses the scene-render cache even if the
|
||
/// signature is unchanged. Consumed (reset) in `prepare`.
|
||
pub(in crate::scene) force_rasterize: bool,
|
||
/// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill
|
||
/// uploads short-circuit so the renderer draws only wireframe.
|
||
pub(in crate::scene) fill_mode: bool,
|
||
/// 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.
|
||
pub(in crate::scene) view_wireframe: bool,
|
||
/// Legacy planar SOLID interiors remain filled in the optimized 2-D
|
||
/// wireframe, but become outlines in the 3-D wireframe. HATCH uses its
|
||
/// own pipeline and is intentionally unaffected.
|
||
pub(in crate::scene) show_2d_solid_fills: bool,
|
||
/// 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()`.
|
||
pub(in crate::scene) mesh_fill: bool,
|
||
/// 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.
|
||
pub(in crate::scene) show_3d_edges: bool,
|
||
/// Draw view-dependent silhouette outlines on curved solid faces.
|
||
/// HiddenLine enables them as part of the visual style; wireframe modes
|
||
/// continue to follow the document's DISPSILH setting.
|
||
pub(in crate::scene) display_silhouette: bool,
|
||
/// 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.
|
||
pub(in crate::scene) hidden_line: bool,
|
||
/// 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,
|
||
/// True when this viewport must not paint a canvas of its own. A floating
|
||
/// viewport on a paper layout sits ON the sheet: anything it painted first
|
||
/// would cover the page it is supposed to be a window in.
|
||
pub(in crate::scene) skip_background: bool,
|
||
pub(in crate::scene) geometry_epoch: u64,
|
||
/// Camera generation captured when this Primitive was assembled. Paired
|
||
/// with `geometry_epoch` so the per-frame scissor / LOD recompute runs.
|
||
pub(in crate::scene) camera_generation: u64,
|
||
/// 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.
|
||
pub(in crate::scene) wire_content_id: u64,
|
||
/// GPU wire-arena handoff (`OCS_WIRE_GPU_PATCH`): `(prev_gen, changed)` when
|
||
/// this Model set reached `wire_content_id` by an incremental resident patch,
|
||
/// so `prepare` can patch just those entities' slabs. `None` ⇒ full build.
|
||
pub(in crate::scene) wire_patch: Option<(u64, Arc<crate::scene::WireGpuPatch>)>,
|
||
/// Selected handles only (no hover) — solid meshes tint these blue.
|
||
pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
|
||
/// Currently hovered selectable unit — solid meshes tint it orange.
|
||
pub(in crate::scene) hover_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
|
||
/// Bumped on selection / hover change. Paired with `wire_content_id` to
|
||
/// decide when the xray overlay batch needs rebuilding.
|
||
pub(in crate::scene) selection_generation: u64,
|
||
/// 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.
|
||
pub(in crate::scene) selected_sig: u64,
|
||
/// 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.
|
||
pub(in crate::scene) screen_rect: Rectangle,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct Primitive {
|
||
/// One entry per viewport drawn this frame (≥1).
|
||
pub(in crate::scene) viewports: Vec<ViewportData>,
|
||
/// Background color used to clear each viewport's MSAA buffer.
|
||
pub(in crate::scene) bg_color: [f32; 4],
|
||
/// Active Iced theme text colour for GPU-rendered ViewCube labels.
|
||
pub(in crate::scene) viewcube_text_color: [f32; 4],
|
||
/// Color used to draw the selection highlight overlay wires.
|
||
pub(in crate::scene) selection_color: [f32; 4],
|
||
/// Whether selection visual effect (glow/highlight) is enabled.
|
||
pub(in crate::scene) selection_effect: bool,
|
||
/// One input-to-render sample, carried only when PERF tracing is enabled.
|
||
pub(in crate::scene) nav_perf: Option<NavPerfSample>,
|
||
}
|
||
|
||
/// 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
|
||
/// edges. The optimized 2-D wireframe retains planar SOLID interiors and
|
||
/// entity draw order; the 3-D wireframe uses true depth and outlines them.
|
||
/// `*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.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct RenderModeFlags {
|
||
pub face3d_fill: bool,
|
||
pub mesh_fill: bool,
|
||
pub show_3d_edges: bool,
|
||
pub hidden_line: bool,
|
||
pub show_2d_solid_fills: bool,
|
||
/// `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,
|
||
}
|
||
|
||
pub fn render_mode_flags(
|
||
mode: acadrust::entities::ViewportRenderMode,
|
||
) -> RenderModeFlags {
|
||
use acadrust::entities::ViewportRenderMode as M;
|
||
match mode {
|
||
M::Wireframe2D => RenderModeFlags {
|
||
face3d_fill: false,
|
||
mesh_fill: false,
|
||
show_3d_edges: true,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: false,
|
||
},
|
||
M::Wireframe3D => RenderModeFlags {
|
||
face3d_fill: false,
|
||
mesh_fill: false,
|
||
show_3d_edges: true,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: false,
|
||
flat_shade: false,
|
||
},
|
||
M::HiddenLine => RenderModeFlags {
|
||
face3d_fill: true,
|
||
mesh_fill: true,
|
||
show_3d_edges: true,
|
||
hidden_line: true,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: false,
|
||
},
|
||
M::FlatShaded => RenderModeFlags {
|
||
face3d_fill: true,
|
||
mesh_fill: true,
|
||
show_3d_edges: false,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: true,
|
||
},
|
||
M::GouraudShaded => RenderModeFlags {
|
||
face3d_fill: true,
|
||
mesh_fill: true,
|
||
show_3d_edges: false,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: false,
|
||
},
|
||
M::FlatShadedWithEdges => RenderModeFlags {
|
||
face3d_fill: true,
|
||
mesh_fill: true,
|
||
show_3d_edges: true,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: true,
|
||
},
|
||
M::GouraudShadedWithEdges => RenderModeFlags {
|
||
face3d_fill: true,
|
||
mesh_fill: true,
|
||
show_3d_edges: true,
|
||
hidden_line: false,
|
||
show_2d_solid_fills: true,
|
||
flat_shade: false,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── shader::Primitive impl ────────────────────────────────────────────────
|
||
|
||
impl shader::Primitive for Primitive {
|
||
type Pipeline = MultiPipeline;
|
||
|
||
fn prepare(
|
||
&self,
|
||
pipeline: &mut MultiPipeline,
|
||
device: &iced::wgpu::Device,
|
||
queue: &iced::wgpu::Queue,
|
||
bounds: &Rectangle,
|
||
viewport: &Viewport,
|
||
) {
|
||
let nav_prepare_started = iced::time::Instant::now();
|
||
let errors_at_entry = crate::scene::pipeline::gpu_errors_seen();
|
||
let recovering = pipeline.gpu_error_epoch != errors_at_entry;
|
||
if recovering {
|
||
pipeline.wire_buffer_cache.clear();
|
||
pipeline.block_geometry.clear();
|
||
pipeline.gpu_error_epoch = errors_at_entry;
|
||
}
|
||
let scale = viewport.scale_factor() as f32;
|
||
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
|
||
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
|
||
for (i, vp) in self.viewports.iter().enumerate() {
|
||
let inner = &mut pipeline.inners[slots[i]];
|
||
inner.sync_gpu_error_epoch();
|
||
// Do not latch rejected uploads as current content.
|
||
let slot_errors_before = crate::scene::pipeline::gpu_errors_seen();
|
||
// Pipeline slots are addressed by list index, but off-canvas
|
||
// viewports are dropped from the list — so a slot can be reused by a
|
||
// DIFFERENT viewport across frames (e.g. the first viewport scrolls
|
||
// off the canvas and the second slides into its slot). When that
|
||
// happens every cache key below belongs to the previous occupant;
|
||
// reset them so wires, text, hatches, meshes and Face3D all
|
||
// re-upload for the new viewport instead of showing the previous
|
||
// one's (differently frustum-culled) content — which otherwise makes
|
||
// the surviving viewport's text/geometry vanish.
|
||
if inner.slot_id != vp.instance_id {
|
||
inner.slot_id = vp.instance_id;
|
||
inner.forget_cached_keys();
|
||
}
|
||
// Round texture coverage, retaining fractional raster placement.
|
||
let placement = physical_viewport(
|
||
Rectangle {
|
||
x: (bounds.x + vp.screen_rect.x * bounds.width) * scale,
|
||
y: (bounds.y + vp.screen_rect.y * bounds.height) * scale,
|
||
width: vp.screen_rect.width * bounds.width * scale,
|
||
height: vp.screen_rect.height * bounds.height * scale,
|
||
},
|
||
viewport.physical_size(),
|
||
);
|
||
let clip_size = placement.size;
|
||
inner.viewport = Some(placement);
|
||
inner.ensure_depth_texture(device, clip_size);
|
||
let viewcube_side =
|
||
(crate::scene::VIEWCUBE_RENDER_PX.ceil() * scale).ceil().max(1.0) as u32;
|
||
inner.viewcube.ensure_depth_texture(device, Size::new(viewcube_side, viewcube_side));
|
||
inner.upload_blit_uv(queue, placement.uv_offset, placement.uv_scale);
|
||
inner.upload_background_images(
|
||
device,
|
||
queue,
|
||
vp.background_image.as_ref(),
|
||
vp.environment_image.as_ref(),
|
||
);
|
||
inner.upload_uniforms(device, queue, &vp.uniforms);
|
||
|
||
// ── 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, &placement);
|
||
// REDRAW bypass: pin render_sig to force a full pass this frame
|
||
// even if the signature is otherwise unchanged. The request is
|
||
// one-shot per viewport (consumed by the builder that produced
|
||
// this `ViewportData`), so this frame only.
|
||
if vp.force_rasterize {
|
||
inner.render_sig = u64::MAX;
|
||
}
|
||
let skip = inner.render_sig != u64::MAX && sig == inner.render_sig;
|
||
inner.render_sig = sig;
|
||
inner.skip_geometry = skip;
|
||
// Interaction LOD: skip the hatch draw this frame while navigating.
|
||
inner.skip_hatch_frame = vp.skip_hatch;
|
||
inner.skip_background = vp.skip_background;
|
||
if skip {
|
||
if vp.show_viewcube {
|
||
inner.viewcube.upload(
|
||
queue,
|
||
vp.cam_rotation,
|
||
vp.compass_rotation,
|
||
vp.hover_region,
|
||
self.viewcube_text_color,
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
let Some(vp_wires) = vp.wires.upgrade() else {
|
||
inner.render_sig = u64::MAX;
|
||
inner.skip_geometry = true;
|
||
continue;
|
||
};
|
||
let Some(draw_depths) = vp.draw_depths.upgrade() else {
|
||
inner.render_sig = u64::MAX;
|
||
inner.skip_geometry = true;
|
||
continue;
|
||
};
|
||
// 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);
|
||
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;
|
||
let solid_fill_active = fill_mode && vp.show_2d_solid_fills;
|
||
let view_dir_key = [
|
||
vp.view_dir.x.to_bits(),
|
||
vp.view_dir.y.to_bits(),
|
||
vp.view_dir.z.to_bits(),
|
||
];
|
||
let solid_visibility_key = if !solid_fill_active {
|
||
0
|
||
} else if inner.cached_solid_visibility.0 == vp.wire_content_id
|
||
&& inner.cached_solid_visibility.1 == view_dir_key
|
||
{
|
||
inner.cached_solid_visibility.2
|
||
} else {
|
||
crate::scene::pipeline::face3d_gpu::planar_solid_visibility_key(
|
||
&vp_wires,
|
||
vp.view_dir,
|
||
)
|
||
};
|
||
inner.cached_solid_visibility =
|
||
(vp.wire_content_id, view_dir_key, solid_visibility_key);
|
||
let fill_changed = inner.cached_fill_mode != fill_mode;
|
||
let hatch_changed = inner
|
||
.cached_hatch_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.hatches));
|
||
let wipeout_changed = inner
|
||
.cached_wipeout_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.wipeout_hatches));
|
||
if hatch_changed || fill_changed {
|
||
inner.upload_hatches(
|
||
device,
|
||
queue,
|
||
if fill_mode { &vp.hatches[..] } else { &[] },
|
||
);
|
||
inner.cached_hatch_source = Some(Arc::clone(&vp.hatches));
|
||
}
|
||
let preview_hatch_changed = inner
|
||
.cached_preview_hatch_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.preview_hatches));
|
||
if preview_hatch_changed || fill_changed {
|
||
inner.upload_preview_hatches(
|
||
device,
|
||
queue,
|
||
&vp.preview_hatches[..],
|
||
);
|
||
inner.cached_preview_hatch_source =
|
||
Some(Arc::clone(&vp.preview_hatches));
|
||
}
|
||
if wipeout_changed || fill_changed {
|
||
inner.upload_wipeouts(
|
||
device,
|
||
queue,
|
||
if fill_mode {
|
||
&vp.wipeout_hatches[..]
|
||
} else {
|
||
&[]
|
||
},
|
||
);
|
||
inner.cached_wipeout_source = Some(Arc::clone(&vp.wipeout_hatches));
|
||
}
|
||
if inner
|
||
.cached_image_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.images))
|
||
{
|
||
inner.upload_images(device, queue, &vp.images[..]);
|
||
inner.cached_image_source = Some(Arc::clone(&vp.images));
|
||
}
|
||
if inner
|
||
.cached_text_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.text_verts))
|
||
|| vp.wire_content_id != inner.cached_wire_id
|
||
{
|
||
inner.upload_text(
|
||
device,
|
||
queue,
|
||
&vp.text_verts[..],
|
||
&vp_wires[..],
|
||
&draw_depths,
|
||
);
|
||
inner.cached_text_source = Some(Arc::clone(&vp.text_verts));
|
||
}
|
||
inner.cached_fill_mode = fill_mode;
|
||
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
|
||
// on its category sources plus the stable wire content id avoids
|
||
// rebuilding it when another entity category alone changes. Never
|
||
// retain `vp.wires` here: Scene needs unique ownership to splice a
|
||
// one-entity edit into the resident set.
|
||
let face_pass_unchanged = vp
|
||
.wire_patch
|
||
.as_ref()
|
||
.is_some_and(|(_, patch)| !patch.face_pass_changed);
|
||
let face3d_changed = inner
|
||
.cached_face3d_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.face3d_wires))
|
||
|| inner
|
||
.cached_face3d_depth_source
|
||
.as_ref()
|
||
.and_then(std::sync::Weak::upgrade)
|
||
.map_or(true, |source| !Arc::ptr_eq(&source, &draw_depths))
|
||
|| (inner.cached_face3d_key.0 != vp.wire_content_id
|
||
&& !face_pass_unchanged);
|
||
if face3d_changed
|
||
|| face3d_fill_active != inner.cached_face3d_key.1
|
||
|| solid_fill_active != inner.cached_face3d_key.2
|
||
|| solid_visibility_key != inner.cached_face3d_key.3
|
||
{
|
||
inner.upload_face3d(
|
||
device,
|
||
queue,
|
||
&vp.face3d_wires[..],
|
||
&vp_wires[..],
|
||
!face3d_fill_active,
|
||
solid_fill_active,
|
||
vp.view_dir,
|
||
&draw_depths,
|
||
);
|
||
inner.cached_face3d_source = Some(Arc::clone(&vp.face3d_wires));
|
||
inner.cached_face3d_depth_source = Some(Arc::downgrade(&draw_depths));
|
||
}
|
||
inner.cached_face3d_key = (
|
||
vp.wire_content_id,
|
||
face3d_fill_active,
|
||
solid_fill_active,
|
||
solid_visibility_key,
|
||
);
|
||
// Wire buffers are world-space, so a camera move alone doesn't
|
||
// change them — only the view_proj uniform (uploaded every frame).
|
||
// 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.
|
||
if vp.wire_content_id != inner.cached_wire_id {
|
||
// Persistent per-entity wire arena (OCS_WIRE_GPU_PATCH): patch
|
||
// just the changed entities' instance slabs instead of rebuilding
|
||
// the whole wire buffer. Only for the scissor-free, mesh-free
|
||
// (single-batch) Model set; scissored paper viewports and mixed
|
||
// 2D/3D sets fall through to the shared batched path below.
|
||
let mut arena_served = false;
|
||
let _perf = crate::perf::enabled();
|
||
let _t0 = iced::time::Instant::now();
|
||
let mut _patched = false;
|
||
// Storage arenas preserve the existing per-slot fast path.
|
||
// Packed arenas start only after the first edit (cold-open keeps
|
||
// the exact-sized shared buffer), and one slot owns each shared
|
||
// content id so split panes do not duplicate 1.5× headroom.
|
||
let packed_arena_owner = self.viewports[..i]
|
||
.iter()
|
||
.all(|other| other.wire_content_id != vp.wire_content_id);
|
||
let use_wire_arena = crate::scene::wire_gpu_patch_enabled()
|
||
&& (inner.wire_const_bgl.is_some()
|
||
|| ((vp.wire_patch.is_some()
|
||
|| inner.wire_arena_id != u64::MAX)
|
||
&& packed_arena_owner));
|
||
if use_wire_arena {
|
||
use crate::scene::pipeline::wire_arena::{
|
||
self, PersistentWireArena as WireArena,
|
||
};
|
||
let const_bgl = inner.wire_const_bgl.as_ref();
|
||
let base_ok = vp
|
||
.wire_patch
|
||
.as_ref()
|
||
.map_or(false, |(base, patch)| {
|
||
inner.wire_arena_id == *base && !patch.changes.is_empty()
|
||
});
|
||
let patch = vp.wire_patch.as_ref().map(|(_, patch)| patch);
|
||
if _perf {
|
||
crate::perf_record!(
|
||
"[perf] arena-base ok={} held={} patch={:?} changes={}",
|
||
base_ok,
|
||
inner.wire_arena_id,
|
||
vp.wire_patch.as_ref().map(|(base, _)| *base),
|
||
patch.map_or(0, |p| p.changes.len()),
|
||
);
|
||
}
|
||
|
||
// Split only changed runs on a patch. The previous path
|
||
// scanned/parses all resident wires repeatedly here even
|
||
// though WireArena emits just one changed entity.
|
||
let mut regular_changed: rustc_hash::FxHashMap<
|
||
acadrust::Handle,
|
||
Vec<&crate::scene::WireModel>,
|
||
> = rustc_hash::FxHashMap::default();
|
||
let mut mesh_changed: rustc_hash::FxHashMap<
|
||
acadrust::Handle,
|
||
Vec<&crate::scene::WireModel>,
|
||
> = rustc_hash::FxHashMap::default();
|
||
if let Some(patch) = patch {
|
||
for &(handle, _) in patch.changes.iter() {
|
||
let run = patch
|
||
.runs
|
||
.get(&handle)
|
||
.map(|wires| wires.as_slice())
|
||
.unwrap_or(&[]);
|
||
let (regular, mesh) = wire_arena::split_wires(run);
|
||
regular_changed.insert(handle, regular);
|
||
mesh_changed.insert(handle, mesh);
|
||
}
|
||
}
|
||
let fallback_touched = |mesh_edge: bool| {
|
||
patch.map_or(true, |patch| {
|
||
patch.changes.iter().any(|(handle, _)| {
|
||
inner.wire_arena_fallback_handles.contains(handle)
|
||
|| if mesh_edge {
|
||
mesh_changed.get(handle).is_some_and(|run| !run.is_empty())
|
||
} else {
|
||
regular_changed
|
||
.get(handle)
|
||
.is_some_and(|run| !run.is_empty())
|
||
}
|
||
})
|
||
})
|
||
};
|
||
// Snapshot before any arena allocation, so a rejected
|
||
// upload cannot claim this content id below.
|
||
let arena_errors_before = crate::scene::pipeline::gpu_errors_seen();
|
||
let reg_ok = base_ok
|
||
&& if let Some(arena) = inner.wire_arena.as_mut() {
|
||
let patch = patch.unwrap();
|
||
arena.patch(
|
||
queue,
|
||
&patch.changes,
|
||
®ular_changed,
|
||
patch.new_handles_are_suffix,
|
||
&draw_depths,
|
||
)
|
||
} else {
|
||
inner.wire_arena_fallback_kind == Some(false)
|
||
&& !fallback_touched(false)
|
||
};
|
||
let mesh_ok = base_ok
|
||
&& if let Some(arena) = inner.wire_arena_mesh.as_mut() {
|
||
let patch = patch.unwrap();
|
||
arena.patch(
|
||
queue,
|
||
&patch.changes,
|
||
&mesh_changed,
|
||
patch.new_handles_are_suffix,
|
||
&draw_depths,
|
||
)
|
||
} else {
|
||
inner.wire_arena_fallback_kind == Some(true)
|
||
&& !fallback_touched(true)
|
||
};
|
||
if !reg_ok || !mesh_ok {
|
||
// Initial upload or a patch that outgrew arena capacity:
|
||
// only then pay the full regular/mesh split.
|
||
let (regular, mesh) = wire_arena::split_wires(&vp_wires);
|
||
if !reg_ok {
|
||
inner.wire_arena = WireArena::build(
|
||
device,
|
||
queue,
|
||
®ular,
|
||
&draw_depths,
|
||
const_bgl,
|
||
false,
|
||
);
|
||
if inner.wire_arena.is_none() && !regular.is_empty() {
|
||
inner.wire_arena_fallback = std::sync::Arc::new(
|
||
crate::scene::pipeline::WireGpu::from_run_refs(
|
||
device,
|
||
queue,
|
||
®ular,
|
||
&draw_depths,
|
||
false,
|
||
const_bgl,
|
||
),
|
||
);
|
||
inner.wire_arena_fallback_kind = Some(false);
|
||
inner.wire_arena_fallback_handles = regular
|
||
.iter()
|
||
.filter_map(|wire| {
|
||
wire.name
|
||
.parse::<u64>()
|
||
.ok()
|
||
.map(acadrust::Handle::new)
|
||
})
|
||
.collect();
|
||
} else if inner.wire_arena_fallback_kind == Some(false) {
|
||
inner.wire_arena_fallback =
|
||
std::sync::Arc::new(Vec::new());
|
||
inner.wire_arena_fallback_kind = None;
|
||
inner.wire_arena_fallback_handles.clear();
|
||
}
|
||
}
|
||
if !mesh_ok {
|
||
inner.wire_arena_mesh = WireArena::build(
|
||
device,
|
||
queue,
|
||
&mesh,
|
||
&draw_depths,
|
||
const_bgl,
|
||
true,
|
||
);
|
||
if inner.wire_arena_mesh.is_none() && !mesh.is_empty() {
|
||
inner.wire_arena_fallback = std::sync::Arc::new(
|
||
crate::scene::pipeline::WireGpu::from_run_refs(
|
||
device,
|
||
queue,
|
||
&mesh,
|
||
&draw_depths,
|
||
true,
|
||
const_bgl,
|
||
),
|
||
);
|
||
inner.wire_arena_fallback_kind = Some(true);
|
||
inner.wire_arena_fallback_handles = mesh
|
||
.iter()
|
||
.filter_map(|wire| {
|
||
wire.name
|
||
.parse::<u64>()
|
||
.ok()
|
||
.map(acadrust::Handle::new)
|
||
})
|
||
.collect();
|
||
} else if inner.wire_arena_fallback_kind == Some(true) {
|
||
inner.wire_arena_fallback =
|
||
std::sync::Arc::new(Vec::new());
|
||
inner.wire_arena_fallback_kind = None;
|
||
inner.wire_arena_fallback_handles.clear();
|
||
}
|
||
}
|
||
}
|
||
_patched = reg_ok && mesh_ok;
|
||
|
||
let regular_ready = inner.wire_arena.is_some()
|
||
|| inner.wire_arena_fallback_kind == Some(false);
|
||
let mesh_ready = inner.wire_arena_mesh.is_some()
|
||
|| inner.wire_arena_fallback_kind == Some(true);
|
||
if regular_ready
|
||
&& mesh_ready
|
||
&& (inner.wire_arena.is_some() || inner.wire_arena_mesh.is_some())
|
||
{
|
||
let mut gpus = if inner.wire_arena_fallback_kind == Some(false) {
|
||
inner.wire_arena_fallback.as_ref().clone()
|
||
} else {
|
||
inner
|
||
.wire_arena
|
||
.as_ref()
|
||
.map(WireArena::wire_gpus)
|
||
.unwrap_or_default()
|
||
};
|
||
if inner.wire_arena_fallback_kind == Some(true) {
|
||
gpus.extend(inner.wire_arena_fallback.iter().cloned());
|
||
} else if let Some(arena) = inner.wire_arena_mesh.as_ref() {
|
||
gpus.extend(arena.wire_gpus());
|
||
}
|
||
inner.gpu_wires = std::sync::Arc::new(gpus);
|
||
// Retain analytical uploads when a patch changes neither
|
||
// their contributors nor their baked draw depths.
|
||
let analytical_untouched = _patched
|
||
&& inner.partition_depth_generation == vp.draw_depth_generation
|
||
&& patch.is_some_and(|patch| {
|
||
patch.changes.iter().all(|(handle, _)| {
|
||
!inner.partition_contributors.contains(handle)
|
||
&& !patch.runs.get(handle).is_some_and(|run| {
|
||
run.iter()
|
||
.any(wire_arena::feeds_analytical_uploads)
|
||
})
|
||
})
|
||
});
|
||
let t_part = _perf.then(iced::time::Instant::now);
|
||
let partitioned = (!analytical_untouched)
|
||
.then(|| wire_arena::partition_wires(&vp_wires, &draw_depths));
|
||
let part_ms = t_part
|
||
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||
let t_blk = _perf.then(iced::time::Instant::now);
|
||
let mut blk_ms = 0.0f64;
|
||
let mut curve_ms = 0.0f64;
|
||
// `None` means the three uploads this slot holds are
|
||
// still the answer, so they are left alone.
|
||
if let Some(partitioned) = &partitioned {
|
||
inner.gpu_block_wires = std::sync::Arc::new(
|
||
inner.upload_block_wires(
|
||
device,
|
||
queue,
|
||
&partitioned.instanced,
|
||
&draw_depths,
|
||
&mut pipeline.block_geometry,
|
||
),
|
||
);
|
||
blk_ms = t_blk
|
||
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||
let t_curve = _perf.then(iced::time::Instant::now);
|
||
inner.gpu_circles = std::sync::Arc::new(
|
||
inner.upload_circles_from_instances(
|
||
device,
|
||
queue,
|
||
&partitioned.circle_instances,
|
||
),
|
||
);
|
||
inner.gpu_ellipses = std::sync::Arc::new(
|
||
inner.upload_ellipses_from_instances(
|
||
device,
|
||
queue,
|
||
&partitioned.ellipse_instances,
|
||
),
|
||
);
|
||
inner
|
||
.partition_contributors
|
||
.clone_from(&partitioned.contributors);
|
||
inner.partition_depth_generation = vp.draw_depth_generation;
|
||
curve_ms = t_curve
|
||
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||
}
|
||
if _perf {
|
||
// Report counts only when partitioning ran.
|
||
match &partitioned {
|
||
Some(partitioned) => crate::perf_record!(
|
||
"[perf] arena-post partition={part_ms:.1}ms \
|
||
blocks={blk_ms:.1}ms curves={curve_ms:.1}ms skipped=false wires={} instanced={} \
|
||
circles={} ellipses={}",
|
||
vp_wires.len(),
|
||
partitioned.instanced.len(),
|
||
partitioned.circle_instances.len(),
|
||
partitioned.ellipse_instances.len(),
|
||
),
|
||
None => crate::perf_record!(
|
||
"[perf] arena-post skipped=true wires={} \
|
||
retained_contributors={}",
|
||
vp_wires.len(),
|
||
inner.partition_contributors.len(),
|
||
),
|
||
}
|
||
}
|
||
if _patched {
|
||
wire_arena::patch_handle_index(
|
||
&mut inner.wire_handle_index,
|
||
&patch.unwrap().index_edits,
|
||
);
|
||
} else {
|
||
inner.wire_handle_index =
|
||
wire_arena::build_handle_index(&vp_wires[..]);
|
||
}
|
||
// Only claim the content id when the device accepted
|
||
// the upload; otherwise the slot believes it holds this
|
||
// content and never rebuilds it.
|
||
inner.wire_arena_id =
|
||
if crate::scene::pipeline::gpu_errors_seen() == arena_errors_before {
|
||
vp.wire_content_id
|
||
} else {
|
||
u64::MAX
|
||
};
|
||
arena_served = true;
|
||
} else {
|
||
inner.wire_arena = None;
|
||
inner.wire_arena_mesh = None;
|
||
inner.wire_arena_fallback = std::sync::Arc::new(Vec::new());
|
||
inner.wire_arena_fallback_kind = None;
|
||
inner.wire_arena_fallback_handles.clear();
|
||
inner.wire_arena_id = u64::MAX;
|
||
}
|
||
} else if inner.wire_const_bgl.is_none() {
|
||
// This packed slot is no longer the owner of its shared
|
||
// content. Drop stale arena state before the shared-cache
|
||
// buffer is installed; otherwise the camera-cull refresh
|
||
// below could resurrect its old draw ranges.
|
||
inner.wire_arena = None;
|
||
inner.wire_arena_mesh = None;
|
||
inner.wire_arena_fallback = std::sync::Arc::new(Vec::new());
|
||
inner.wire_arena_fallback_kind = None;
|
||
inner.wire_arena_fallback_handles.clear();
|
||
inner.wire_arena_id = u64::MAX;
|
||
}
|
||
// Share one copy of the resident wire buffers across every slot
|
||
// (and every pane — one MultiPipeline backs them all) rendering
|
||
// this content id: build on a cache miss, then hand out Arc
|
||
// clones. Two paper viewports showing the same model, or four
|
||
// Model tiles, upload the wire vertices once between them.
|
||
// `.cloned()` releases the immutable cache borrow before the
|
||
// miss branch takes a mutable one.
|
||
if !arena_served {
|
||
let cached = pipeline
|
||
.wire_buffer_cache
|
||
.get(&vp.wire_content_id)
|
||
.cloned();
|
||
let built = match cached {
|
||
Some(entry) => entry,
|
||
None => {
|
||
// Release superseded buffers before allocating replacements.
|
||
// Other panes retain their own references to shared geometry.
|
||
inner.gpu_wires = std::sync::Arc::new(Vec::new());
|
||
inner.gpu_block_wires = std::sync::Arc::new(Vec::new());
|
||
inner.gpu_circles = std::sync::Arc::new(Vec::new());
|
||
inner.gpu_ellipses = std::sync::Arc::new(Vec::new());
|
||
let held_before = pipeline.wire_buffer_cache.len();
|
||
pipeline.wire_buffer_cache.retain(|_, (w, b, _, c, e)| {
|
||
std::sync::Arc::strong_count(w) > 1
|
||
|| std::sync::Arc::strong_count(b) > 1
|
||
|| std::sync::Arc::strong_count(c) > 1
|
||
|| std::sync::Arc::strong_count(e) > 1
|
||
});
|
||
if _perf {
|
||
crate::perf_record!(
|
||
"[perf] wire-cache evicted={} held={}",
|
||
held_before - pipeline.wire_buffer_cache.len(),
|
||
pipeline.wire_buffer_cache.len(),
|
||
);
|
||
}
|
||
let t_upload = _perf.then(iced::time::Instant::now);
|
||
let errors_before =
|
||
crate::scene::pipeline::gpu_errors_seen();
|
||
let entry =
|
||
inner.build_wire_buffers(
|
||
device,
|
||
queue,
|
||
&vp_wires[..],
|
||
&draw_depths,
|
||
&mut pipeline.block_geometry,
|
||
);
|
||
if let Some(start) = t_upload {
|
||
crate::perf_record!(
|
||
"[perf] wire-upload {:.1}ms wires={} content_id={}",
|
||
start.elapsed().as_secs_f64() * 1000.0,
|
||
vp_wires.len(),
|
||
vp.wire_content_id,
|
||
);
|
||
}
|
||
|
||
// Buffers the device rejected are still `Buffer`s.
|
||
// Cached under a content id that keeps matching,
|
||
// they render nothing for the rest of the session
|
||
// and nothing ever rebuilds them. Draw the degraded
|
||
// frame, but let the next one try again.
|
||
if crate::scene::pipeline::gpu_errors_seen() == errors_before {
|
||
pipeline
|
||
.wire_buffer_cache
|
||
.insert(vp.wire_content_id, entry.clone());
|
||
}
|
||
entry
|
||
}
|
||
};
|
||
inner.gpu_wires = built.0;
|
||
inner.gpu_block_wires = built.1;
|
||
inner.wire_handle_index = built.2;
|
||
inner.gpu_circles = built.3;
|
||
inner.gpu_ellipses = built.4;
|
||
} // end !arena_served
|
||
inner.cached_wire_id = vp.wire_content_id;
|
||
if _perf {
|
||
let gi: u32 = inner.gpu_wires.iter().map(|w| w.instance_count).sum();
|
||
let bi: u32 = inner
|
||
.gpu_block_wires
|
||
.iter()
|
||
.map(|w| w.instance_count)
|
||
.sum();
|
||
let outcome = if !arena_served {
|
||
"shared-fullupload"
|
||
} else if _patched {
|
||
"arena-patch"
|
||
} else if inner.wire_arena_fallback_kind.is_some() {
|
||
"arena-hybrid"
|
||
} else {
|
||
"arena-build"
|
||
};
|
||
crate::perf_record!(
|
||
"[perf] wire {:>7.1}ms {:<18} wires={} gpu_instances={} block_instances={}",
|
||
_t0.elapsed().as_secs_f64() * 1000.0,
|
||
outcome,
|
||
vp_wires.len(),
|
||
gi,
|
||
bi,
|
||
);
|
||
}
|
||
}
|
||
// 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);
|
||
let highlighted_geometry_unchanged =
|
||
vp.selected_handles.is_empty() && vp.hover_handles.is_empty()
|
||
|| vp.wire_patch.as_ref().is_some_and(|(previous, patch)| {
|
||
*previous == inner.cached_selection.0
|
||
&& patch.changes.iter().all(|(handle, _)| {
|
||
!vp.selected_handles.contains(handle)
|
||
&& !vp.hover_handles.contains(handle)
|
||
})
|
||
});
|
||
let selection_changed = inner.cached_selection.1 != vp.selection_generation;
|
||
let highlighted_geometry_changed = inner.cached_selection.0
|
||
!= vp.wire_content_id
|
||
&& !highlighted_geometry_unchanged;
|
||
let annotation_context_changed = inner
|
||
.cached_annotation_highlight_source
|
||
.as_ref()
|
||
.map_or(!vp.annotation_context_wires.is_empty(), |previous| {
|
||
!(previous.is_empty() && vp.annotation_context_wires.is_empty())
|
||
&& !Arc::ptr_eq(previous, &vp.annotation_context_wires)
|
||
});
|
||
if selection_changed || highlighted_geometry_changed || annotation_context_changed {
|
||
let sel_color = if self.selection_effect {
|
||
Some(self.selection_color)
|
||
} else {
|
||
None
|
||
};
|
||
inner.upload_selected_wires(
|
||
device,
|
||
queue,
|
||
&vp_wires[..],
|
||
&vp.selected_handles,
|
||
&vp.hover_handles,
|
||
&vp.annotation_context_wires,
|
||
&draw_depths,
|
||
sel_color,
|
||
);
|
||
// 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,
|
||
queue,
|
||
&vp_wires[..],
|
||
&vp.selected_handles,
|
||
&vp.hover_handles,
|
||
&vp.annotation_context_wires,
|
||
&draw_depths,
|
||
sel_color,
|
||
);
|
||
inner.cached_annotation_highlight_source =
|
||
Some(Arc::clone(&vp.annotation_context_wires));
|
||
}
|
||
// Advance the content id even when an unrelated entity patch kept
|
||
// the existing overlay valid, so future deltas compare to the
|
||
// arena generation actually on screen.
|
||
inner.cached_selection = sel_key;
|
||
// Batched solid meshes stay resident while unrelated entity
|
||
// categories change.
|
||
if inner
|
||
.cached_mesh_source
|
||
.as_ref()
|
||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.meshes))
|
||
{
|
||
let patched = vp.wire_patch.as_ref().is_some_and(|(previous, patch)| {
|
||
*previous == inner.cached_mesh_content_id
|
||
&& inner.patch_mesh_batch(
|
||
device,
|
||
queue,
|
||
&vp.meshes[..],
|
||
&patch.changes,
|
||
)
|
||
});
|
||
if !patched {
|
||
inner.upload_mesh_batch(device, queue, &vp.meshes[..]);
|
||
}
|
||
inner.cached_mesh_source = Some(Arc::clone(&vp.meshes));
|
||
}
|
||
inner.cached_mesh_content_id = vp.wire_content_id;
|
||
// 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 = (
|
||
Arc::as_ptr(&vp.meshes) as usize as u64,
|
||
vp.selection_generation,
|
||
);
|
||
if hl_key != inner.cached_highlight_key {
|
||
inner.update_mesh_highlight(
|
||
&vp.selected_handles,
|
||
&vp.hover_handles,
|
||
&vp.annotation_context_wires,
|
||
);
|
||
inner.cached_highlight_key = hl_key;
|
||
}
|
||
// 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, queue, &vp.preview_wires[..], &draw_depths);
|
||
inner.upload_preview_text(device, queue, &vp.preview_text_verts[..]);
|
||
// 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,
|
||
);
|
||
let silhouette_enabled =
|
||
vp.display_silhouette && (vp.view_wireframe || vp.show_3d_edges);
|
||
let silhouette_key = (
|
||
Arc::as_ptr(&vp.meshes) as usize,
|
||
vp.wire_content_id,
|
||
vp.view_dir.to_array().map(f32::to_bits),
|
||
silhouette_enabled,
|
||
);
|
||
if inner.silhouette_key != silhouette_key {
|
||
inner.upload_silhouettes(
|
||
device,
|
||
queue,
|
||
if silhouette_enabled { &vp.meshes[..] } else { &[] },
|
||
vp.wire_content_id,
|
||
vp.view_dir,
|
||
);
|
||
inner.silhouette_key = silhouette_key;
|
||
}
|
||
inner.upload_clip_boundary(device, queue, &vp.clip_boundary_ndc);
|
||
let hatch_lod_key = (
|
||
Arc::as_ptr(&vp.hatches) as usize,
|
||
vp.camera_generation,
|
||
clip_size.width,
|
||
clip_size.height,
|
||
fill_mode,
|
||
);
|
||
if inner.hatch_lod_key != hatch_lod_key {
|
||
inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height);
|
||
inner.hatch_lod_key = hatch_lod_key;
|
||
}
|
||
let wipeout_lod_key = (
|
||
Arc::as_ptr(&vp.wipeout_hatches) as usize,
|
||
vp.camera_generation,
|
||
clip_size.width,
|
||
clip_size.height,
|
||
fill_mode,
|
||
);
|
||
if inner.wipeout_lod_key != wipeout_lod_key {
|
||
inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height);
|
||
inner.wipeout_lod_key = wipeout_lod_key;
|
||
}
|
||
let cull_key = (
|
||
vp.wire_content_id,
|
||
vp.camera_generation,
|
||
clip_size.width,
|
||
clip_size.height,
|
||
);
|
||
if inner.wire_arena_id == vp.wire_content_id
|
||
&& inner.wire_cull_key != cull_key
|
||
{
|
||
let mut visible = if inner.wire_arena_fallback_kind == Some(false) {
|
||
inner.wire_arena_fallback.as_ref().clone()
|
||
} else {
|
||
inner
|
||
.wire_arena
|
||
.as_ref()
|
||
.map(|arena| {
|
||
arena.wire_gpus_visible(
|
||
view_rot,
|
||
eye,
|
||
clip_size.width,
|
||
clip_size.height,
|
||
)
|
||
})
|
||
.unwrap_or_default()
|
||
};
|
||
if inner.wire_arena_fallback_kind == Some(true) {
|
||
visible.extend(inner.wire_arena_fallback.iter().cloned());
|
||
} else if let Some(arena) = inner.wire_arena_mesh.as_ref() {
|
||
visible.extend(arena.wire_gpus_visible(
|
||
view_rot,
|
||
eye,
|
||
clip_size.width,
|
||
clip_size.height,
|
||
));
|
||
}
|
||
inner.gpu_wires = std::sync::Arc::new(visible);
|
||
inner.wire_cull_key = cull_key;
|
||
}
|
||
if vp.show_viewcube {
|
||
inner.viewcube.upload(
|
||
queue,
|
||
vp.cam_rotation,
|
||
vp.compass_rotation,
|
||
vp.hover_region,
|
||
self.viewcube_text_color,
|
||
);
|
||
}
|
||
|
||
// A rejected allocation leaves buffers that are invalid but
|
||
// indistinguishable from good ones at every cache key. Forget
|
||
// them all, so the next frame rebuilds this slot instead of
|
||
// drawing nothing for the rest of the session — which is what
|
||
// sent a user to REGENALL to get the model back.
|
||
if crate::scene::pipeline::gpu_errors_seen() != slot_errors_before {
|
||
inner.forget_cached_keys();
|
||
}
|
||
}
|
||
let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0;
|
||
if let Some(sample) = self.nav_perf {
|
||
crate::perf::record(format_args!(
|
||
"[perf] nav-prepare op={} cause={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}",
|
||
sample.op.label(),
|
||
sample.cause,
|
||
sample.space,
|
||
sample.mode,
|
||
sample.input_ms,
|
||
sample.build_ms,
|
||
prepare_ms,
|
||
sample.started.elapsed().as_secs_f64() * 1000.0,
|
||
self.viewports.len(),
|
||
));
|
||
} else if crate::perf::enabled() && prepare_ms >= 5.0 {
|
||
crate::perf_record!(
|
||
"[perf] frame-prepare {:>7.1}ms viewports={}",
|
||
prepare_ms,
|
||
self.viewports.len(),
|
||
);
|
||
}
|
||
// Memory pressure shortens cold-slot retention without evicting siblings.
|
||
let errors_now = crate::scene::pipeline::gpu_errors_seen();
|
||
let urgent = recovering || errors_now != errors_at_entry;
|
||
let released = pipeline.release_idle_slots(device, &slots, urgent);
|
||
if released > 0 && crate::perf::enabled() {
|
||
crate::perf_record!(
|
||
"[perf] slots-released n={released} urgent={urgent} slots={}",
|
||
pipeline.inners.len(),
|
||
);
|
||
}
|
||
report_gpu_live(pipeline);
|
||
}
|
||
|
||
fn render(
|
||
&self,
|
||
pipeline: &MultiPipeline,
|
||
encoder: &mut iced::wgpu::CommandEncoder,
|
||
target: &iced::wgpu::TextureView,
|
||
clip: &Rectangle<u32>,
|
||
) {
|
||
let nav_render_started = iced::time::Instant::now();
|
||
pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let clip_right = clip.x + clip.width;
|
||
let clip_bottom = clip.y + clip.height;
|
||
for vp in &self.viewports {
|
||
let Some(slot) = pipeline.slot_by_instance.get(&vp.instance_id) else {
|
||
continue;
|
||
};
|
||
let Some(inner) = pipeline.inners.get(*slot) else {
|
||
continue;
|
||
};
|
||
let Some(placement) = inner.viewport else {
|
||
continue;
|
||
};
|
||
let surface_dest = placement.surface;
|
||
let left = surface_dest.x.max(clip.x);
|
||
let top = surface_dest.y.max(clip.y);
|
||
let surface_clip = Rectangle {
|
||
x: left,
|
||
y: top,
|
||
width: (surface_dest.x + surface_dest.width).min(clip_right).saturating_sub(left),
|
||
height: (surface_dest.y + surface_dest.height).min(clip_bottom).saturating_sub(top),
|
||
};
|
||
if surface_clip.width == 0 || surface_clip.height == 0 {
|
||
continue;
|
||
}
|
||
// `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,
|
||
placement.raster,
|
||
surface_dest,
|
||
surface_clip,
|
||
self.bg_color,
|
||
mesh_wireframe,
|
||
vp.hidden_line,
|
||
vp.show_3d_edges,
|
||
);
|
||
// The ViewCube renders directly to the surface in the top-right corner
|
||
// of the viewport. Skip it only when the top-right corner is off-canvas
|
||
// or the visible area cannot fit the cube.
|
||
if vp.show_viewcube && inner.viewcube.should_render(surface_dest, surface_clip, clip) {
|
||
inner.viewcube.render(encoder, target, surface_clip);
|
||
}
|
||
}
|
||
let render_ms = nav_render_started.elapsed().as_secs_f64() * 1000.0;
|
||
if let Some(sample) = self.nav_perf {
|
||
crate::perf::record(format_args!(
|
||
"[perf] nav-render op={} space={} mode={} encode={:.2}ms elapsed={:.2}ms viewports={}",
|
||
sample.op.label(),
|
||
sample.space,
|
||
sample.mode,
|
||
render_ms,
|
||
sample.started.elapsed().as_secs_f64() * 1000.0,
|
||
self.viewports.len(),
|
||
));
|
||
} else if crate::perf::enabled() && render_ms >= 5.0 {
|
||
crate::perf_record!(
|
||
"[perf] frame-encode {:>7.1}ms viewports={}",
|
||
render_ms,
|
||
self.viewports.len(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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, placement: &PhysicalViewport) -> 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.background_image
|
||
.as_ref()
|
||
.map(|image| std::sync::Arc::as_ptr(&image.pixels) as usize)
|
||
.unwrap_or(0)
|
||
.hash(&mut h);
|
||
vp.environment_image
|
||
.as_ref()
|
||
.map(|image| std::sync::Arc::as_ptr(&image.pixels) as usize)
|
||
.unwrap_or(0)
|
||
.hash(&mut h);
|
||
if vp.meshes.is_empty() {
|
||
0usize
|
||
} else {
|
||
std::sync::Arc::as_ptr(&vp.meshes) as usize
|
||
}
|
||
.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.show_2d_solid_fills.hash(&mut h);
|
||
vp.mesh_fill.hash(&mut h);
|
||
vp.show_3d_edges.hash(&mut h);
|
||
vp.display_silhouette.hash(&mut h);
|
||
vp.hidden_line.hash(&mut h);
|
||
// ViewCube visibility is excluded from the *scene* signature elsewhere only
|
||
// for the live-hover pass; here it MUST invalidate the cache so toggling the
|
||
// cube (NAVVCUBE) re-renders and actually clears the last cube frame
|
||
// instead of leaving its stale pixels on the cached surface.
|
||
vp.show_viewcube.hash(&mut h);
|
||
// 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);
|
||
vp.skip_background.hash(&mut h);
|
||
placement.size.width.hash(&mut h);
|
||
placement.size.height.hash(&mut h);
|
||
for value in [placement.raster.x, placement.raster.y, placement.raster.width, placement.raster.height] {
|
||
value.to_bits().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);
|
||
}
|
||
}
|
||
// Live hatch preview. Pattern-origin grip drags keep the boundary fixed and
|
||
// move only the family anchors, so hash both geometry and pattern data.
|
||
vp.preview_hatches.len().hash(&mut h);
|
||
for model in vp.preview_hatches.iter() {
|
||
model.world_origin[0].to_bits().hash(&mut h);
|
||
model.world_origin[1].to_bits().hash(&mut h);
|
||
model.boundary.len().hash(&mut h);
|
||
for point in model.boundary.iter() {
|
||
point[0].to_bits().hash(&mut h);
|
||
point[1].to_bits().hash(&mut h);
|
||
}
|
||
for component in model.color {
|
||
component.to_bits().hash(&mut h);
|
||
}
|
||
model.angle_offset.to_bits().hash(&mut h);
|
||
model.scale.to_bits().hash(&mut h);
|
||
match &model.pattern {
|
||
crate::scene::model::hatch_model::HatchPattern::Solid => {
|
||
0_u8.hash(&mut h);
|
||
}
|
||
crate::scene::model::hatch_model::HatchPattern::Pattern(families) => {
|
||
1_u8.hash(&mut h);
|
||
families.len().hash(&mut h);
|
||
for family in families {
|
||
family.angle_deg.to_bits().hash(&mut h);
|
||
family.x0.to_bits().hash(&mut h);
|
||
family.y0.to_bits().hash(&mut h);
|
||
family.dx.to_bits().hash(&mut h);
|
||
family.dy.to_bits().hash(&mut h);
|
||
for dash in &family.dashes {
|
||
dash.to_bits().hash(&mut h);
|
||
}
|
||
}
|
||
}
|
||
crate::scene::model::hatch_model::HatchPattern::Gradient {
|
||
angle_deg,
|
||
color2,
|
||
kind,
|
||
invert,
|
||
shift,
|
||
} => {
|
||
2_u8.hash(&mut h);
|
||
angle_deg.to_bits().hash(&mut h);
|
||
for component in color2 {
|
||
component.to_bits().hash(&mut h);
|
||
}
|
||
kind.shader_kind().hash(&mut h);
|
||
invert.hash(&mut h);
|
||
shift.to_bits().hash(&mut h);
|
||
}
|
||
}
|
||
}
|
||
// Grip-drag / command-preview glyph quads (issue #316). A pure-text slide
|
||
// leaves `preview_wires` empty and moves ONLY these, so a signature that
|
||
// ignored them would let the scene-render cache freeze the dragged text at
|
||
// its first frame (re-blitting a stale texture) until release. Small — one
|
||
// dragged entity — so hashing every vertex is cheap. Hash the high AND low
|
||
// halves of the double-single position: a sub-unit slide at UTM scale shifts
|
||
// only the low residual, so hashing the high f32 alone would miss it.
|
||
vp.preview_text_verts.len().hash(&mut h);
|
||
for v in vp.preview_text_verts.iter() {
|
||
v.pos[0].to_bits().hash(&mut h);
|
||
v.pos[1].to_bits().hash(&mut h);
|
||
v.pos_low[0].to_bits().hash(&mut h);
|
||
v.pos_low[1].to_bits().hash(&mut h);
|
||
}
|
||
h.finish()
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
pub(in crate::scene) struct PhysicalViewport {
|
||
size: Size<u32>,
|
||
raster: Rectangle,
|
||
surface: Rectangle<u32>,
|
||
uv_offset: [f32; 2],
|
||
uv_scale: [f32; 2],
|
||
}
|
||
|
||
fn physical_viewport(rect: Rectangle, window: Size<u32>) -> PhysicalViewport {
|
||
let x = rect.x.floor();
|
||
let y = rect.y.floor();
|
||
let right = (rect.x + rect.width).ceil();
|
||
let bottom = (rect.y + rect.height).ceil();
|
||
let width = (right - x).max(1.0);
|
||
let height = (bottom - y).max(1.0);
|
||
let left = x.clamp(0.0, window.width as f32);
|
||
let top = y.clamp(0.0, window.height as f32);
|
||
let visible_w = (right.min(window.width as f32) - left).max(0.0);
|
||
let visible_h = (bottom.min(window.height as f32) - top).max(0.0);
|
||
PhysicalViewport {
|
||
size: Size::new(width as u32, height as u32),
|
||
raster: Rectangle {
|
||
x: rect.x - x,
|
||
y: rect.y - y,
|
||
width: rect.width.max(0.0),
|
||
height: rect.height.max(0.0),
|
||
},
|
||
surface: Rectangle {
|
||
x: left as u32,
|
||
y: top as u32,
|
||
width: visible_w as u32,
|
||
height: visible_h as u32,
|
||
},
|
||
uv_offset: [(left - x) / width, (top - y) / height],
|
||
uv_scale: [visible_w / width, visible_h / height],
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod pixel_placement_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn raster_and_blit_preserve_fractional_position_at_different_scales() {
|
||
for scale in [1.0, 1.25, 1.5, 2.0] {
|
||
for x in [-17.37, 0.25, 13.61, 199.7] {
|
||
for width in [0.4, 18.73, 181.83] {
|
||
let rect = Rectangle {
|
||
x: x * scale,
|
||
y: 12.13 * scale,
|
||
width: width * scale,
|
||
height: 91.17 * scale,
|
||
};
|
||
let placement = physical_viewport(rect, Size::new(256, 256));
|
||
assert!(placement.raster.x >= 0.0 && placement.raster.x < 1.0);
|
||
assert!(
|
||
placement.raster.x + placement.raster.width
|
||
<= placement.size.width as f32 + 1e-5
|
||
);
|
||
if placement.surface.width == 0 {
|
||
continue;
|
||
}
|
||
// Follow a model point through rasterization, texture UV
|
||
// crop, then the blit; paper geometry lands directly here.
|
||
for u in [0.0, 0.1, 0.5, 0.9, 1.0] {
|
||
let texture_x = placement.raster.x + u * placement.raster.width;
|
||
let output_x = placement.surface.x as f32
|
||
+ (texture_x / placement.size.width as f32 - placement.uv_offset[0])
|
||
/ placement.uv_scale[0]
|
||
* placement.surface.width as f32;
|
||
assert!((output_x - (rect.x + u * rect.width)).abs() < 0.0001);
|
||
}
|
||
assert!(
|
||
(placement.uv_scale[0] * placement.size.width as f32
|
||
- placement.surface.width as f32)
|
||
.abs()
|
||
< 1e-5
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn integer_translation_reuses_the_same_raster_placement() {
|
||
let rect = Rectangle {
|
||
x: 13.25,
|
||
y: 27.5,
|
||
width: 100.75,
|
||
height: 51.25,
|
||
};
|
||
let first = physical_viewport(rect, Size::new(512, 512));
|
||
let moved = physical_viewport(
|
||
Rectangle {
|
||
x: rect.x + 20.0,
|
||
..rect
|
||
},
|
||
Size::new(512, 512),
|
||
);
|
||
assert_eq!(first.raster, moved.raster);
|
||
assert_eq!(first.size, moved.size);
|
||
assert_eq!(first.uv_scale, moved.uv_scale);
|
||
assert_eq!(moved.surface.x, first.surface.x + 20);
|
||
}
|
||
|
||
#[test]
|
||
fn viewcube_visibility_across_display_scales_and_bounds() {
|
||
use crate::scene::pipeline::viewcube::viewcube_should_render;
|
||
|
||
// Model space viewports across standard UI display scales and fractional layouts
|
||
for scale in [1.0, 1.25, 1.5, 1.75, 2.0] {
|
||
let window = Size::new((1920.0 * scale) as u32, (1080.0 * scale) as u32);
|
||
let bounds = Rectangle {
|
||
x: 0.0,
|
||
y: 154.5,
|
||
width: 1920.0,
|
||
height: 800.3,
|
||
};
|
||
let rect = Rectangle {
|
||
x: bounds.x * scale,
|
||
y: bounds.y * scale,
|
||
width: bounds.width * scale,
|
||
height: bounds.height * scale,
|
||
};
|
||
let placement = physical_viewport(rect, window);
|
||
let clip = Rectangle {
|
||
x: (bounds.x * scale).round() as u32,
|
||
y: (bounds.y * scale).round() as u32,
|
||
width: (bounds.width * scale).round() as u32,
|
||
height: (bounds.height * scale).round() as u32,
|
||
};
|
||
let clip_right = clip.x + clip.width;
|
||
let clip_bottom = clip.y + clip.height;
|
||
let left = placement.surface.x.max(clip.x);
|
||
let top = placement.surface.y.max(clip.y);
|
||
let surface_clip = Rectangle {
|
||
x: left,
|
||
y: top,
|
||
width: (placement.surface.x + placement.surface.width)
|
||
.min(clip_right)
|
||
.saturating_sub(left),
|
||
height: (placement.surface.y + placement.surface.height)
|
||
.min(clip_bottom)
|
||
.saturating_sub(top),
|
||
};
|
||
let viewcube_side = (crate::scene::VIEWCUBE_RENDER_PX.ceil() * scale).ceil() as u32;
|
||
assert!(
|
||
viewcube_should_render(placement.surface, surface_clip, &clip, viewcube_side),
|
||
"ViewCube must be visible in model space at scale {scale}"
|
||
);
|
||
}
|
||
|
||
// Paper space viewport scrolled off the top edge: should not render
|
||
let clip: Rectangle<u32> = Rectangle {
|
||
x: 0,
|
||
y: 100,
|
||
width: 1000,
|
||
height: 800,
|
||
};
|
||
let scrolled_off_top: Rectangle<u32> = Rectangle {
|
||
x: 50,
|
||
y: 50,
|
||
width: 400,
|
||
height: 300,
|
||
}; // y is above clip.y (100)
|
||
let left = scrolled_off_top.x.max(clip.x);
|
||
let top = scrolled_off_top.y.max(clip.y);
|
||
let surface_clip = Rectangle {
|
||
x: left,
|
||
y: top,
|
||
width: (scrolled_off_top.x + scrolled_off_top.width)
|
||
.min(clip.x + clip.width)
|
||
.saturating_sub(left),
|
||
height: (scrolled_off_top.y + scrolled_off_top.height)
|
||
.min(clip.y + clip.height)
|
||
.saturating_sub(top),
|
||
};
|
||
assert!(
|
||
!viewcube_should_render(scrolled_off_top, surface_clip, &clip, 120),
|
||
"ViewCube must hide when top-right corner is off top of canvas"
|
||
);
|
||
|
||
// Paper space viewport scrolled off the right edge: should not render
|
||
let scrolled_off_right: Rectangle<u32> = Rectangle {
|
||
x: 800,
|
||
y: 150,
|
||
width: 400,
|
||
height: 300,
|
||
}; // x + w (1200) exceeds clip right (1000)
|
||
let left = scrolled_off_right.x.max(clip.x);
|
||
let top = scrolled_off_right.y.max(clip.y);
|
||
let surface_clip = Rectangle {
|
||
x: left,
|
||
y: top,
|
||
width: (scrolled_off_right.x + scrolled_off_right.width)
|
||
.min(clip.x + clip.width)
|
||
.saturating_sub(left),
|
||
height: (scrolled_off_right.y + scrolled_off_right.height)
|
||
.min(clip.y + clip.height)
|
||
.saturating_sub(top),
|
||
};
|
||
assert!(
|
||
!viewcube_should_render(scrolled_off_right, surface_clip, &clip, 120),
|
||
"ViewCube must hide when top-right corner is off right of canvas"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires a GPU adapter"]
|
||
fn fractional_viewports_keep_wire_pixels_aligned_after_blitting() {
|
||
use iced::futures::executor::block_on;
|
||
use iced::wgpu;
|
||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
|
||
.expect("GPU adapter");
|
||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||
required_limits: adapter.limits(),
|
||
..Default::default()
|
||
}))
|
||
.expect("GPU device");
|
||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||
let mut pipeline =
|
||
crate::scene::pipeline::Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8Unorm);
|
||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("pixel placement regression"),
|
||
size: wgpu::Extent3d {
|
||
width: 256,
|
||
height: 256,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||
view_formats: &[],
|
||
});
|
||
let view = target.create_view(&Default::default());
|
||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("pixel readback"),
|
||
size: 256 * 256 * 4,
|
||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||
mapped_at_creation: false,
|
||
});
|
||
for (x, width, parent_clip) in [
|
||
(13.37, 181.83, false),
|
||
(-12.61, 187.29, false),
|
||
(19.75, 173.75, false),
|
||
(13.37, 181.83, true),
|
||
(-12.61, 187.29, true),
|
||
(19.75, 173.75, true),
|
||
] {
|
||
let rect = Rectangle {
|
||
x,
|
||
y: 18.37,
|
||
width,
|
||
height: 181.83,
|
||
};
|
||
let placement = physical_viewport(rect, Size::new(256, 256));
|
||
pipeline.ensure_depth_texture(&device, placement.size);
|
||
pipeline.upload_blit_uv(&queue, placement.uv_offset, placement.uv_scale);
|
||
let mut uniforms = Uniforms::new(
|
||
&Camera::default(),
|
||
Rectangle::with_size(Size::new(width, rect.height)),
|
||
false,
|
||
);
|
||
uniforms.eye_high = [0.0; 3];
|
||
uniforms.eye_low = [0.0; 3];
|
||
uniforms.view_rot = Mat4::IDENTITY;
|
||
pipeline.upload_uniforms(&device, &queue, &uniforms);
|
||
let u = 0.73;
|
||
let ndc_x = 2.0 * u - 1.0;
|
||
let wire = WireModel::solid(
|
||
"alignment".into(),
|
||
vec![[ndc_x, -0.8, 0.5], [ndc_x, 0.8, 0.5]],
|
||
[1.0; 4],
|
||
false,
|
||
);
|
||
pipeline.upload_preview_wires(&device, &queue, &[wire], &Default::default());
|
||
let mut encoder = device.create_command_encoder(&Default::default());
|
||
{
|
||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: &view,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
..Default::default()
|
||
});
|
||
}
|
||
let clip = if parent_clip {
|
||
Rectangle {
|
||
x: placement.surface.x + 11,
|
||
y: 64,
|
||
width: placement.surface.width - 22,
|
||
height: 96,
|
||
}
|
||
} else {
|
||
placement.surface
|
||
};
|
||
pipeline.render(
|
||
&mut encoder,
|
||
&view,
|
||
placement.raster,
|
||
placement.surface,
|
||
clip,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
false,
|
||
false,
|
||
false,
|
||
);
|
||
encoder.copy_texture_to_buffer(
|
||
wgpu::TexelCopyTextureInfo {
|
||
texture: &target,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
wgpu::TexelCopyBufferInfo {
|
||
buffer: &readback,
|
||
layout: wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(256 * 4),
|
||
rows_per_image: Some(256),
|
||
},
|
||
},
|
||
wgpu::Extent3d {
|
||
width: 256,
|
||
height: 256,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
queue.submit([encoder.finish()]);
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
readback
|
||
.slice(..)
|
||
.map_async(wgpu::MapMode::Read, move |result| {
|
||
tx.send(result).unwrap();
|
||
});
|
||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||
rx.recv().unwrap().unwrap();
|
||
{
|
||
let bytes = readback.slice(..).get_mapped_range();
|
||
let row = (rect.y + rect.height * 0.5) as usize;
|
||
let expected = rect.x + u * rect.width;
|
||
let row_has_wire = bytes[48 * 256 * 4..49 * 256 * 4]
|
||
.chunks_exact(4)
|
||
.any(|pixel| pixel[..3] != [0, 0, 0]);
|
||
assert_eq!(
|
||
row_has_wire, !parent_clip,
|
||
"parent clip must exclude the wire at row 48"
|
||
);
|
||
let mut weight = 0.0;
|
||
let mut center = 0.0;
|
||
for column in (expected as usize - 4)..=(expected as usize + 4) {
|
||
let value = bytes[(row * 256 + column) * 4] as f32;
|
||
weight += value;
|
||
center += (column as f32 + 0.5) * value;
|
||
}
|
||
assert!(weight > 0.0, "wire must produce pixels");
|
||
assert!(
|
||
(center / weight - expected).abs() < 0.25,
|
||
"x={x}, width={width}, expected={expected}, actual={}",
|
||
center / weight
|
||
);
|
||
}
|
||
readback.unmap();
|
||
}
|
||
assert!(
|
||
block_on(validation.pop()).is_none(),
|
||
"GPU validation failed"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
fn normalized_direction(value: [f64; 3], fallback: [f32; 3]) -> [f32; 3] {
|
||
let length = (value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt();
|
||
if length <= 1e-12 {
|
||
fallback
|
||
} else {
|
||
[
|
||
(value[0] / length) as f32,
|
||
(value[1] / length) as f32,
|
||
(value[2] / length) as f32,
|
||
]
|
||
}
|
||
}
|
||
|
||
fn solar_direction(
|
||
sun: &acadrust::objects::Sun,
|
||
geo: &acadrust::objects::GeoData,
|
||
) -> Option<[f32; 3]> {
|
||
if sun.julian_day < 1_000_000 {
|
||
return None;
|
||
}
|
||
let daylight_ms = if sun.is_daylight_savings_on {
|
||
3_600_000.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let jd = sun.julian_day as f64
|
||
+ (sun.milliseconds as f64 - daylight_ms) / 86_400_000.0;
|
||
let days = jd - 2_451_545.0;
|
||
let mean_longitude = (280.460 + 0.985_647_4 * days).to_radians();
|
||
let mean_anomaly = (357.528 + 0.985_600_3 * days).to_radians();
|
||
let ecliptic_longitude = mean_longitude
|
||
+ (1.915 * mean_anomaly.sin() + 0.020 * (2.0 * mean_anomaly).sin()).to_radians();
|
||
let obliquity = (23.439 - 0.000_000_4 * days).to_radians();
|
||
let right_ascension =
|
||
(obliquity.cos() * ecliptic_longitude.sin()).atan2(ecliptic_longitude.cos());
|
||
let declination = (obliquity.sin() * ecliptic_longitude.sin()).asin();
|
||
let local_sidereal =
|
||
(280.460_618_37 + 360.985_647_366_29 * days + geo.reference_point.x).to_radians();
|
||
let hour_angle = (local_sidereal - right_ascension + std::f64::consts::PI)
|
||
.rem_euclid(std::f64::consts::TAU)
|
||
- std::f64::consts::PI;
|
||
let latitude = geo.reference_point.y.to_radians();
|
||
let east_component = -declination.cos() * hour_angle.sin();
|
||
let north_component = declination.sin() * latitude.cos()
|
||
- declination.cos() * hour_angle.cos() * latitude.sin();
|
||
let up_component = declination.sin() * latitude.sin()
|
||
+ declination.cos() * hour_angle.cos() * latitude.cos();
|
||
if up_component <= 0.0 {
|
||
return None;
|
||
}
|
||
let north = normalized_direction(
|
||
[geo.north_direction.x, geo.north_direction.y, 0.0],
|
||
[0.0, 1.0, 0.0],
|
||
);
|
||
let east = [north[1], -north[0], 0.0];
|
||
let up = normalized_direction(
|
||
[geo.up_direction.x, geo.up_direction.y, geo.up_direction.z],
|
||
[0.0, 0.0, 1.0],
|
||
);
|
||
Some(normalized_direction(
|
||
[
|
||
-(east[0] as f64 * east_component
|
||
+ north[0] as f64 * north_component
|
||
+ up[0] as f64 * up_component),
|
||
-(east[1] as f64 * east_component
|
||
+ north[1] as f64 * north_component
|
||
+ up[1] as f64 * up_component),
|
||
-(east[2] as f64 * east_component
|
||
+ north[2] as f64 * north_component
|
||
+ up[2] as f64 * up_component),
|
||
],
|
||
[0.0, 0.0, -1.0],
|
||
))
|
||
}
|
||
|
||
// ── Render-style helpers (impl Scene) ────────────────────────────────────
|
||
|
||
/// Report what the renderer is holding on the device, when it changes.
|
||
///
|
||
/// Only on change: `prepare` runs thousands of times a session and these
|
||
/// numbers move a handful of times. RSS is deliberately not used — the
|
||
/// allocator and the driver both retain high-water memory that RSS cannot tell
|
||
/// apart from live resources.
|
||
fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) {
|
||
use std::cell::Cell;
|
||
if !crate::perf::enabled() {
|
||
return;
|
||
}
|
||
thread_local! {
|
||
static LAST: Cell<Option<crate::scene::pipeline::GpuLiveBytes>> = const { Cell::new(None) };
|
||
}
|
||
let now = pipeline.gpu_live_bytes();
|
||
LAST.with(|last| {
|
||
if last.get() == Some(now) {
|
||
return;
|
||
}
|
||
last.set(Some(now));
|
||
const MIB: f64 = 1024.0 * 1024.0;
|
||
let mib = |bytes: u64| bytes as f64 / MIB;
|
||
crate::perf_record!(
|
||
"[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \
|
||
text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}",
|
||
now.slots,
|
||
mib(now.total()),
|
||
mib(now.shadow),
|
||
mib(now.render_targets),
|
||
mib(now.text_atlas),
|
||
mib(now.wire_arena),
|
||
mib(now.block_geometry),
|
||
);
|
||
});
|
||
}
|
||
|
||
impl Scene {
|
||
fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> {
|
||
let rect = self.model_tiles.borrow().get(index)?.rect;
|
||
let lower_left = [rect.x as f64, (1.0 - rect.y - rect.height) as f64];
|
||
let upper_right = [
|
||
(rect.x + rect.width) as f64,
|
||
(1.0 - rect.y) as f64,
|
||
];
|
||
const EPSILON: f64 = 1e-5;
|
||
let exact = self
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.filter(|value| value.name.eq_ignore_ascii_case("*Active"))
|
||
.find(|value| {
|
||
(value.lower_left.x - lower_left[0]).abs() <= EPSILON
|
||
&& (value.lower_left.y - lower_left[1]).abs() <= EPSILON
|
||
&& (value.upper_right.x - upper_right[0]).abs() <= EPSILON
|
||
&& (value.upper_right.y - upper_right[1]).abs() <= EPSILON
|
||
});
|
||
exact.or_else(|| {
|
||
let center_x = (lower_left[0] + upper_right[0]) * 0.5;
|
||
let center_y = (lower_left[1] + upper_right[1]) * 0.5;
|
||
self.document
|
||
.vports
|
||
.iter()
|
||
.filter(|value| {
|
||
value.name.eq_ignore_ascii_case("*Active")
|
||
&& center_x >= value.lower_left.x - EPSILON
|
||
&& center_x <= value.upper_right.x + EPSILON
|
||
&& center_y >= value.lower_left.y - EPSILON
|
||
&& center_y <= value.upper_right.y + EPSILON
|
||
})
|
||
.min_by(|left, right| {
|
||
let left_area = (left.upper_right.x - left.lower_left.x)
|
||
* (left.upper_right.y - left.lower_left.y);
|
||
let right_area = (right.upper_right.x - right.lower_left.x)
|
||
* (right.upper_right.y - right.lower_left.y);
|
||
left_area.total_cmp(&right_area)
|
||
})
|
||
.or_else(|| {
|
||
self.document
|
||
.vports
|
||
.iter()
|
||
.find(|value| value.name.eq_ignore_ascii_case("*Active"))
|
||
})
|
||
})
|
||
}
|
||
|
||
fn geolocation(&self) -> Option<&acadrust::objects::GeoData> {
|
||
use acadrust::objects::ObjectType;
|
||
|
||
crate::entities::object_data::geo_objects(&self.object_data_cache)
|
||
.iter()
|
||
.find_map(|handle| match self.document.objects.get(handle) {
|
||
Some(ObjectType::GeoData(value))
|
||
if value.coordinate_type == 3
|
||
&& value.reference_point.x.is_finite()
|
||
&& value.reference_point.y.is_finite()
|
||
&& value.reference_point.x.abs() <= 180.0
|
||
&& value.reference_point.y.abs() <= 90.0 => Some(value),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
fn build_lighting_cache(
|
||
&self,
|
||
target_block: Handle,
|
||
frozen: &rustc_hash::FxHashSet<Handle>,
|
||
) -> Vec<SceneLight> {
|
||
use acadrust::objects::{ClassObjectData, ObjectType};
|
||
|
||
fn converted(scene: &Scene, light: &acadrust::entities::Light) -> Option<SceneLight> {
|
||
if !light.status {
|
||
return None;
|
||
}
|
||
let mut direction = normalized_direction(
|
||
[
|
||
light.target.x - light.position.x,
|
||
light.target.y - light.position.y,
|
||
light.target.z - light.position.z,
|
||
],
|
||
[0.0, 0.0, -1.0],
|
||
);
|
||
let color_layer = if light.light_color.rgb().is_some() {
|
||
None
|
||
} else {
|
||
Some(light.common.layer.clone())
|
||
};
|
||
let rgba = if color_layer.is_none() {
|
||
tess_util::aci_to_rgba(&light.light_color)
|
||
} else {
|
||
scene.layer_color(&light.common.layer)
|
||
};
|
||
let mut color = [rgba[0], rgba[1], rgba[2]];
|
||
let mut intensity = light.intensity.max(0.0) as f32;
|
||
let mut attenuation_type = light.attenuation_type as f32;
|
||
let mut attenuation_start = if light.use_attenuation_limits {
|
||
light.attenuation_start_limit as f32
|
||
} else {
|
||
0.0
|
||
};
|
||
let mut attenuation_end = if light.use_attenuation_limits {
|
||
light.attenuation_end_limit as f32
|
||
} else {
|
||
0.0
|
||
};
|
||
let mut area_softness = 0.0_f32;
|
||
let mut web_profile = [1.0_f32; 8];
|
||
let mut web_rotation = [0.0_f32; 3];
|
||
let mut web_enabled = false;
|
||
if light.photometric_mode {
|
||
if let Some(photo) = light.photometric_data.as_ref() {
|
||
let solid_angle = if light.is_spot() && light.falloff_angle > 0.0 {
|
||
2.0 * std::f64::consts::PI
|
||
* (1.0 - (light.falloff_angle * 0.5).cos())
|
||
} else {
|
||
4.0 * std::f64::consts::PI
|
||
};
|
||
let physical = if photo.physical_intensity > 0.0 {
|
||
photo.physical_intensity
|
||
} else {
|
||
photo.web_flux.max(0.0)
|
||
};
|
||
let candela = match photo.physical_intensity_method {
|
||
1 => physical / solid_angle.max(1e-6),
|
||
2 => physical * photo.illuminance_distance.max(1e-6).powi(2),
|
||
_ => physical,
|
||
};
|
||
if candela > 0.0 && candela.is_finite() {
|
||
intensity *= (candela / 1500.0) as f32;
|
||
}
|
||
if photo.lamp_color_temperature >= 1000.0 {
|
||
let kelvin = photo.lamp_color_temperature.clamp(1000.0, 40000.0) / 100.0;
|
||
let red = if kelvin <= 66.0 {
|
||
255.0
|
||
} else {
|
||
329.698_727_446 * (kelvin - 60.0).powf(-0.133_204_759_2)
|
||
};
|
||
let green = if kelvin <= 66.0 {
|
||
99.470_802_586_1 * kelvin.ln() - 161.119_568_166_1
|
||
} else {
|
||
288.122_169_528_3 * (kelvin - 60.0).powf(-0.075_514_849_2)
|
||
};
|
||
let blue = if kelvin >= 66.0 {
|
||
255.0
|
||
} else if kelvin <= 19.0 {
|
||
0.0
|
||
} else {
|
||
138.517_731_223_1 * (kelvin - 10.0).ln() - 305.044_792_730_7
|
||
};
|
||
let lamp = [red, green, blue].map(|channel| {
|
||
(channel.clamp(0.0, 255.0) / 255.0) as f32
|
||
});
|
||
for index in 0..3 {
|
||
color[index] *= lamp[index];
|
||
}
|
||
}
|
||
attenuation_type = 2.0;
|
||
attenuation_start = 0.0;
|
||
attenuation_end = 0.0;
|
||
area_softness = match photo.extended_light_shape {
|
||
1 => photo.extended_light_length,
|
||
2 => photo.extended_light_length.max(photo.extended_light_width),
|
||
3 => photo.extended_light_radius * 2.0,
|
||
_ => 0.0,
|
||
}
|
||
.max(0.0) as f32;
|
||
if photo.has_web_file {
|
||
if let Some(profile) = scene.photometric_web_profile(&photo.web_file) {
|
||
web_profile = profile;
|
||
web_rotation = [
|
||
photo.web_rotation.x as f32,
|
||
photo.web_rotation.y as f32,
|
||
photo.web_rotation.z as f32,
|
||
];
|
||
let rotation = glam::Quat::from_euler(
|
||
glam::EulerRot::XYZ,
|
||
web_rotation[0],
|
||
web_rotation[1],
|
||
web_rotation[2],
|
||
);
|
||
direction = (rotation * glam::Vec3::from_array(direction))
|
||
.normalize_or(glam::Vec3::NEG_Z)
|
||
.to_array();
|
||
web_enabled = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(SceneLight {
|
||
handle: light.common.handle,
|
||
color_layer,
|
||
light_type: light.light_type as f32,
|
||
position: [light.position.x, light.position.y, light.position.z],
|
||
direction,
|
||
color,
|
||
intensity,
|
||
hotspot_cos: if light.hotspot_angle > 0.0 {
|
||
(light.hotspot_angle * 0.5).cos() as f32
|
||
} else {
|
||
1.0
|
||
},
|
||
falloff_cos: if light.falloff_angle > 0.0 {
|
||
(light.falloff_angle * 0.5).cos() as f32
|
||
} else {
|
||
-1.0
|
||
},
|
||
attenuation_type,
|
||
attenuation_start,
|
||
attenuation_end,
|
||
cast_shadows: light.cast_shadows,
|
||
shadow_softness: light.shadow_map_softness as f32 / 255.0 + area_softness,
|
||
shadow_map_size: u32::try_from(light.shadow_map_size.max(0))
|
||
.unwrap_or(0),
|
||
web_profile,
|
||
web_rotation,
|
||
web_enabled,
|
||
})
|
||
}
|
||
|
||
let mut lights = Vec::new();
|
||
for &handle in crate::entities::object_data::light_entities(
|
||
&self.object_data_cache,
|
||
) {
|
||
if let Some(EntityType::Light(light)) = self.document.get_entity(handle) {
|
||
let common = &light.common;
|
||
if self.layer_frozen_in(&common.layer, Some(frozen))
|
||
|| !self.belongs_to_visible_block(
|
||
handle,
|
||
common.owner_handle,
|
||
target_block,
|
||
)
|
||
{
|
||
continue;
|
||
}
|
||
if let Some(light) = converted(self, light) {
|
||
lights.push(light);
|
||
}
|
||
}
|
||
}
|
||
|
||
let geo = self.geolocation();
|
||
for handle in crate::entities::object_data::sun_objects(&self.object_data_cache) {
|
||
let Some(ObjectType::ClassObject(value)) = self.document.objects.get(handle) else {
|
||
continue;
|
||
};
|
||
let ClassObjectData::Sun(sun) = &value.data else {
|
||
continue;
|
||
};
|
||
if !sun.is_on {
|
||
continue;
|
||
}
|
||
let Some(geo) = geo else {
|
||
continue;
|
||
};
|
||
let Some(direction) = solar_direction(sun, geo) else {
|
||
continue;
|
||
};
|
||
let rgba = tess_util::aci_to_rgba(&sun.color);
|
||
lights.push(SceneLight {
|
||
handle: value.handle,
|
||
color_layer: None,
|
||
light_type: 1.0,
|
||
position: [0.0; 3],
|
||
direction,
|
||
color: [rgba[0], rgba[1], rgba[2]],
|
||
intensity: sun.intensity.max(0.0) as f32,
|
||
hotspot_cos: 1.0,
|
||
falloff_cos: -1.0,
|
||
attenuation_type: 0.0,
|
||
attenuation_start: 0.0,
|
||
attenuation_end: 0.0,
|
||
cast_shadows: sun.has_shadow,
|
||
shadow_softness: sun.shadow_softness as f32 / 255.0,
|
||
shadow_map_size: u32::try_from(sun.shadow_map_size.max(0)).unwrap_or(0),
|
||
web_profile: [1.0; 8],
|
||
web_rotation: [0.0; 3],
|
||
web_enabled: false,
|
||
});
|
||
}
|
||
lights
|
||
}
|
||
|
||
fn apply_document_lighting(
|
||
&self,
|
||
uniforms: &mut Uniforms,
|
||
target_block: Handle,
|
||
frozen: &rustc_hash::FxHashSet<Handle>,
|
||
viewport: &ViewportInstance,
|
||
) {
|
||
let key = (target_block, Self::frozen_layers_sig(frozen));
|
||
if !self.lighting_cache.borrow().contains_key(&key) {
|
||
let lights = self.build_lighting_cache(target_block, frozen);
|
||
self.lighting_cache.borrow_mut().insert(key, lights);
|
||
}
|
||
let cache = self.lighting_cache.borrow();
|
||
let lights = cache.get(&key).map(Vec::as_slice).unwrap_or_default();
|
||
let settings = self.viewport_lighting_settings(viewport);
|
||
let visible_lights: Vec<&SceneLight> = lights
|
||
.iter()
|
||
.filter(|light| match self.document.get_entity(light.handle) {
|
||
Some(EntityType::Light(entity)) => {
|
||
let common = &entity.common;
|
||
!common.invisible
|
||
&& !self.entity_temporarily_hidden(light.handle)
|
||
&& !self.layer_hidden(&common.layer)
|
||
&& !self.layer_frozen_in(&common.layer, Some(frozen))
|
||
&& self.belongs_to_visible_block(
|
||
light.handle,
|
||
common.owner_handle,
|
||
target_block,
|
||
)
|
||
}
|
||
Some(_) => false,
|
||
None => self.document.objects.get(&light.handle).is_some_and(|object| {
|
||
matches!(
|
||
object,
|
||
acadrust::objects::ObjectType::ClassObject(value)
|
||
if matches!(&value.data, acadrust::objects::ClassObjectData::Sun(_))
|
||
) && (!settings.sun_handle.is_valid()
|
||
|| settings.sun_handle == light.handle)
|
||
}),
|
||
})
|
||
.take(4)
|
||
.collect();
|
||
uniforms.lighting[1..4].copy_from_slice(&settings.ambient);
|
||
if settings.force_default || visible_lights.is_empty() {
|
||
Self::apply_default_lighting(uniforms, &viewport.camera, settings.default_type);
|
||
return;
|
||
}
|
||
let eye = [
|
||
uniforms.eye_high[0] as f64 + uniforms.eye_low[0] as f64,
|
||
uniforms.eye_high[1] as f64 + uniforms.eye_low[1] as f64,
|
||
uniforms.eye_high[2] as f64 + uniforms.eye_low[2] as f64,
|
||
];
|
||
uniforms.lighting[0] = visible_lights.len() as f32;
|
||
for (index, light) in visible_lights.iter().copied().enumerate() {
|
||
let color = light
|
||
.color_layer
|
||
.as_deref()
|
||
.map(|layer| self.layer_color(layer))
|
||
.map(|rgba| [rgba[0], rgba[1], rgba[2]])
|
||
.unwrap_or(light.color);
|
||
uniforms.light_position_type[index] = [
|
||
(light.position[0] - eye[0]) as f32,
|
||
(light.position[1] - eye[1]) as f32,
|
||
(light.position[2] - eye[2]) as f32,
|
||
light.light_type,
|
||
];
|
||
uniforms.light_direction_intensity[index] = [
|
||
light.direction[0],
|
||
light.direction[1],
|
||
light.direction[2],
|
||
light.intensity,
|
||
];
|
||
uniforms.light_color_hotspot[index] = [
|
||
color[0],
|
||
color[1],
|
||
color[2],
|
||
light.hotspot_cos,
|
||
];
|
||
uniforms.light_attenuation[index] = [
|
||
light.attenuation_type,
|
||
light.attenuation_start,
|
||
light.attenuation_end,
|
||
light.falloff_cos,
|
||
];
|
||
uniforms.light_web_profile_a[index]
|
||
.copy_from_slice(&light.web_profile[..4]);
|
||
uniforms.light_web_profile_b[index]
|
||
.copy_from_slice(&light.web_profile[4..]);
|
||
uniforms.light_web_rotation[index] = [
|
||
light.web_rotation[0],
|
||
light.web_rotation[1],
|
||
light.web_rotation[2],
|
||
light.web_enabled as u8 as f32,
|
||
];
|
||
}
|
||
if let Some((index, light)) = visible_lights
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, light)| light.cast_shadows)
|
||
{
|
||
let target = (viewport.camera.target - viewport.camera.eye()).as_vec3();
|
||
let up_for = |direction: glam::Vec3| {
|
||
if direction.z.abs() > 0.95 {
|
||
glam::Vec3::Y
|
||
} else {
|
||
glam::Vec3::Z
|
||
}
|
||
};
|
||
let radius = viewport
|
||
.camera
|
||
.ortho_size()
|
||
.max(viewport.camera.distance * 0.25)
|
||
.max(1.0);
|
||
let shadow_view_proj = if light.light_type < 1.5 {
|
||
let direction = glam::Vec3::from_array(light.direction)
|
||
.normalize_or(glam::Vec3::NEG_Z);
|
||
let light_eye = target - direction * radius * 2.0;
|
||
let view = glam::camera::rh::view::look_at_mat4(
|
||
light_eye,
|
||
target,
|
||
up_for(direction),
|
||
);
|
||
let aspect = (uniforms.viewport_size[0] / uniforms.viewport_size[1].max(1.0))
|
||
.max(1.0);
|
||
let projection = glam::camera::rh::proj::directx::orthographic(
|
||
-radius * aspect,
|
||
radius * aspect,
|
||
-radius,
|
||
radius,
|
||
0.01,
|
||
radius * 5.0,
|
||
);
|
||
projection * view
|
||
} else {
|
||
let position = glam::Vec3::new(
|
||
uniforms.light_position_type[index][0],
|
||
uniforms.light_position_type[index][1],
|
||
uniforms.light_position_type[index][2],
|
||
);
|
||
let direction = if light.light_type < 2.5 {
|
||
(target - position).normalize_or(glam::Vec3::NEG_Z)
|
||
} else {
|
||
glam::Vec3::from_array(light.direction)
|
||
.normalize_or(glam::Vec3::NEG_Z)
|
||
};
|
||
let view = glam::camera::rh::view::look_at_mat4(
|
||
position,
|
||
position + direction,
|
||
up_for(direction),
|
||
);
|
||
let fov = if light.light_type < 2.5 {
|
||
170.0_f32.to_radians()
|
||
} else {
|
||
(2.0 * light.falloff_cos.clamp(-0.99, 0.99).acos())
|
||
.clamp(5.0_f32.to_radians(), 170.0_f32.to_radians())
|
||
};
|
||
let far = if light.attenuation_end > 0.01 {
|
||
light.attenuation_end
|
||
} else {
|
||
radius * 5.0
|
||
};
|
||
glam::camera::rh::proj::directx::perspective(
|
||
fov,
|
||
1.0,
|
||
0.01,
|
||
far.max(0.02),
|
||
) * view
|
||
};
|
||
uniforms.shadow_view_proj = shadow_view_proj;
|
||
let requested_size = light.shadow_map_size.max(256) as f32;
|
||
uniforms.shadow_params = [
|
||
1.0,
|
||
(2.0 / requested_size).clamp(0.0002, 0.004),
|
||
light.shadow_softness.clamp(0.0, 2.0),
|
||
index as f32,
|
||
];
|
||
}
|
||
}
|
||
|
||
fn viewport_lighting_settings(
|
||
&self,
|
||
viewport: &ViewportInstance,
|
||
) -> ViewportLightingSettings {
|
||
let ambient = |color: &AcadColor| {
|
||
color.rgb().map_or([0.18; 3], |(r, g, b)| {
|
||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
|
||
})
|
||
};
|
||
let from_entity = |value: &acadrust::entities::Viewport| ViewportLightingSettings {
|
||
force_default: value.default_lighting,
|
||
default_type: value.default_lighting_type,
|
||
ambient: ambient(&value.ambient_color),
|
||
sun_handle: value.sun_handle,
|
||
};
|
||
let from_table = |value: &acadrust::tables::VPort| ViewportLightingSettings {
|
||
force_default: value.use_default_lights,
|
||
default_type: value.default_lighting_type,
|
||
ambient: ambient(&value.ambient_color),
|
||
sun_handle: value.sun_handle,
|
||
};
|
||
|
||
if viewport.paper_sheet {
|
||
let handle = self.current_layout_sheet_viewport_handle();
|
||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(handle) {
|
||
return from_entity(value);
|
||
}
|
||
} else if viewport.handle.is_valid() {
|
||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(viewport.handle) {
|
||
return from_entity(value);
|
||
}
|
||
} else if self.current_layout == "Model" {
|
||
let index = viewport.tile_idx.unwrap_or(0);
|
||
if let Some(value) = self.model_tile_vport(index) {
|
||
return from_table(value);
|
||
}
|
||
}
|
||
ViewportLightingSettings::default()
|
||
}
|
||
|
||
fn viewport_display_settings(
|
||
&self,
|
||
viewport: &ViewportInstance,
|
||
) -> ViewportDisplaySettings {
|
||
// What lies behind a viewport depends on what the viewport is. The
|
||
// full-canvas paper sheet shows the desk, and the page is drawn on top
|
||
// of it by `paper_sheet_fill` — give the desk the page's own colour and
|
||
// the edge disappears into a same-coloured surround that no amount of
|
||
// zooming out reveals. A floating viewport sits ON that sheet, so it
|
||
// paints no canvas at all; anything else would erase the page beneath
|
||
// it. Only model space clears to the editor background.
|
||
let canvas_background = if viewport.paper_sheet {
|
||
crate::scene::PAPER_DESK_COLOR
|
||
} else if self.current_layout != "Model" {
|
||
// This viewport paints no canvas of its own (`skip_background`),
|
||
// but shading and fog still blend toward whatever is behind it —
|
||
// and behind a floating viewport is the page.
|
||
self.paper_bg_color
|
||
} else {
|
||
self.bg_color
|
||
};
|
||
let build = |visual_style_handle: Handle,
|
||
brightness: f64,
|
||
contrast: f64,
|
||
background_handle: Handle| {
|
||
let visual_style = resolve_visual_style_handle(
|
||
&self.document,
|
||
visual_style_handle,
|
||
);
|
||
let mut background = self
|
||
.viewport_background(background_handle, canvas_background, 0);
|
||
self.apply_document_render_environment(&mut background);
|
||
ViewportDisplaySettings {
|
||
visual_style,
|
||
brightness: (brightness as f32 / 10.0).clamp(-1.0, 1.0),
|
||
contrast: (contrast as f32 / 10.0).clamp(-1.0, 1.0),
|
||
background,
|
||
}
|
||
};
|
||
|
||
if viewport.paper_sheet {
|
||
let handle = self.current_layout_sheet_viewport_handle();
|
||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(handle) {
|
||
return build(
|
||
value.visual_style_handle,
|
||
value.brightness,
|
||
value.contrast,
|
||
value.background_handle,
|
||
);
|
||
}
|
||
} else if viewport.handle.is_valid() {
|
||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(viewport.handle) {
|
||
return build(
|
||
value.visual_style_handle,
|
||
value.brightness,
|
||
value.contrast,
|
||
value.background_handle,
|
||
);
|
||
}
|
||
} else if self.current_layout == "Model" {
|
||
let index = viewport.tile_idx.unwrap_or(0);
|
||
if let Some(value) = self.model_tile_vport(index) {
|
||
return build(
|
||
value.visual_style_handle,
|
||
value.brightness,
|
||
value.contrast,
|
||
value.background_handle,
|
||
);
|
||
}
|
||
}
|
||
|
||
let mut background = ViewportBackgroundSettings::canvas(canvas_background);
|
||
self.apply_document_render_environment(&mut background);
|
||
ViewportDisplaySettings {
|
||
background,
|
||
..ViewportDisplaySettings::default()
|
||
}
|
||
}
|
||
|
||
fn packed_background_color(color: u32) -> [f32; 4] {
|
||
[
|
||
((color >> 16) & 0xff) as f32 / 255.0,
|
||
((color >> 8) & 0xff) as f32 / 255.0,
|
||
(color & 0xff) as f32 / 255.0,
|
||
1.0,
|
||
]
|
||
}
|
||
|
||
fn background_image(
|
||
&self,
|
||
reference: &str,
|
||
) -> Option<crate::scene::model::image_model::DecodedImage> {
|
||
let reference = reference.trim();
|
||
if reference.is_empty() {
|
||
return None;
|
||
}
|
||
if reference.starts_with("http://") || reference.starts_with("https://") {
|
||
return crate::scene::model::image_model::resolve_image(reference);
|
||
}
|
||
let normalized = reference.replace('\\', "/");
|
||
let source = std::path::PathBuf::from(&normalized);
|
||
let mut candidates = Vec::new();
|
||
if source.is_absolute() {
|
||
candidates.push(source.clone());
|
||
}
|
||
if let Some(base) = self.material_base_dir.as_deref() {
|
||
candidates.push(base.join(&source));
|
||
if let Some(name) = source.file_name() {
|
||
candidates.push(base.join(name));
|
||
for folder in [
|
||
"Textures",
|
||
"textures",
|
||
"Materials",
|
||
"materials",
|
||
"Environments",
|
||
"environments",
|
||
"Render",
|
||
"render",
|
||
] {
|
||
candidates.push(base.join(folder).join(name));
|
||
}
|
||
}
|
||
}
|
||
candidates
|
||
.into_iter()
|
||
.find(|path| path.is_file())
|
||
.and_then(|path| {
|
||
crate::scene::model::image_model::resolve_image(&path.to_string_lossy())
|
||
})
|
||
}
|
||
|
||
fn photometric_web_profile(&self, reference: &str) -> Option<[f32; 8]> {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = (self, reference);
|
||
None
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let reference = reference.trim();
|
||
if reference.is_empty() {
|
||
return None;
|
||
}
|
||
let source = std::path::PathBuf::from(reference.replace('\\', "/"));
|
||
let mut candidates = Vec::new();
|
||
if source.is_absolute() {
|
||
candidates.push(source.clone());
|
||
}
|
||
if let Some(base) = self.material_base_dir.as_deref() {
|
||
candidates.push(base.join(&source));
|
||
if let Some(name) = source.file_name() {
|
||
candidates.push(base.join(name));
|
||
for folder in ["Photometric", "photometric", "Web", "web", "Lights", "lights"] {
|
||
candidates.push(base.join(folder).join(name));
|
||
}
|
||
}
|
||
}
|
||
let path = candidates.into_iter().find(|path| path.is_file())?;
|
||
let contents = std::fs::read_to_string(path).ok()?;
|
||
let tilt_start = contents.lines().position(|line| {
|
||
line.trim_start().to_ascii_uppercase().starts_with("TILT=")
|
||
})?;
|
||
let lines: Vec<&str> = contents.lines().collect();
|
||
let tilt = lines[tilt_start]
|
||
.trim()
|
||
.split_once('=')
|
||
.map(|(_, value)| value.trim().to_ascii_uppercase())?;
|
||
if tilt != "NONE" && tilt != "INCLUDE" {
|
||
return None;
|
||
}
|
||
let values: Vec<f64> = lines[tilt_start + 1..]
|
||
.iter()
|
||
.flat_map(|line| line.split(|character: char| character.is_whitespace() || character == ','))
|
||
.filter(|token| !token.is_empty())
|
||
.filter_map(|token| token.parse::<f64>().ok())
|
||
.collect();
|
||
let mut cursor = 0usize;
|
||
if tilt == "INCLUDE" {
|
||
let angle_count = values.get(cursor + 1).copied()?.round() as usize;
|
||
cursor = cursor.checked_add(2 + angle_count.checked_mul(2)?)?;
|
||
}
|
||
let header = values.get(cursor..cursor + 13)?;
|
||
let candela_multiplier = header[2].max(0.0);
|
||
let vertical_count = header[3].round() as usize;
|
||
let horizontal_count = header[4].round() as usize;
|
||
if vertical_count < 2
|
||
|| horizontal_count == 0
|
||
|| vertical_count > 4096
|
||
|| horizontal_count > 4096
|
||
{
|
||
return None;
|
||
}
|
||
cursor += 13;
|
||
let vertical = values.get(cursor..cursor + vertical_count)?;
|
||
cursor += vertical_count;
|
||
cursor += horizontal_count;
|
||
let candela_count = vertical_count.checked_mul(horizontal_count)?;
|
||
let candela = values.get(cursor..cursor + candela_count)?;
|
||
let sample_row = |row: &[f64], angle: f64| {
|
||
if angle <= vertical[0] {
|
||
return row[0];
|
||
}
|
||
if angle >= vertical[vertical_count - 1] {
|
||
return row[vertical_count - 1];
|
||
}
|
||
let upper = vertical.partition_point(|candidate| *candidate < angle);
|
||
let lower = upper.saturating_sub(1);
|
||
let span = (vertical[upper] - vertical[lower]).max(1e-9);
|
||
let amount = (angle - vertical[lower]) / span;
|
||
row[lower] + (row[upper] - row[lower]) * amount
|
||
};
|
||
let mut profile = [0.0_f32; 8];
|
||
for (sample, value) in profile.iter_mut().enumerate() {
|
||
let angle = sample as f64 * 180.0 / 7.0;
|
||
let sum = candela
|
||
.chunks_exact(vertical_count)
|
||
.map(|row| sample_row(row, angle))
|
||
.sum::<f64>();
|
||
*value = (sum * candela_multiplier / horizontal_count as f64).max(0.0) as f32;
|
||
}
|
||
let peak = profile.iter().copied().fold(0.0_f32, f32::max);
|
||
if !peak.is_finite() || peak <= 1e-9 {
|
||
return None;
|
||
}
|
||
for value in &mut profile {
|
||
*value = (*value / peak).clamp(0.0, 1.0);
|
||
}
|
||
Some(profile)
|
||
}
|
||
}
|
||
|
||
fn apply_document_render_environment(
|
||
&self,
|
||
background: &mut ViewportBackgroundSettings,
|
||
) {
|
||
use acadrust::objects::{ClassObjectData, ObjectType};
|
||
|
||
let environment = self
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.filter_map(|(handle, object)| match object {
|
||
ObjectType::ClassObject(value) => match &value.data {
|
||
ClassObjectData::RenderEnvironment(environment) => {
|
||
Some((*handle, environment))
|
||
}
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
})
|
||
.min_by_key(|(handle, _)| handle.value())
|
||
.map(|(_, environment)| environment);
|
||
|
||
if let Some(environment) = environment {
|
||
if environment.fog_enabled {
|
||
background.fog_color = [
|
||
environment.fog_color[0] as f32 / 255.0,
|
||
environment.fog_color[1] as f32 / 255.0,
|
||
environment.fog_color[2] as f32 / 255.0,
|
||
1.0,
|
||
];
|
||
let normalized_density = |density: f64| {
|
||
let density = density.max(0.0) as f32;
|
||
if density > 1.0 {
|
||
(density / 100.0).clamp(0.0, 1.0)
|
||
} else {
|
||
density.clamp(0.0, 1.0)
|
||
}
|
||
};
|
||
background.fog_params = [
|
||
1.0,
|
||
environment.fog_background_enabled as u8 as f32,
|
||
normalized_density(environment.fog_density_near),
|
||
normalized_density(environment.fog_density_far),
|
||
];
|
||
let near = environment.fog_distance_near.max(0.0) as f32;
|
||
let far = environment.fog_distance_far.max(near as f64 + 1e-6) as f32;
|
||
background.fog_distances = [near, far, 0.0, 0.0];
|
||
}
|
||
if background.environment.is_none() && environment.environment_image_enabled {
|
||
if let Some(image) = self.background_image(&environment.environment_image_filename) {
|
||
background.environment_params = [1.0, 0.0, 0.25, 0.35];
|
||
background.environment = Some(image);
|
||
}
|
||
}
|
||
}
|
||
|
||
if background.environment.is_some() {
|
||
return;
|
||
}
|
||
let preset = self
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.filter_map(|(handle, object)| match object {
|
||
ObjectType::ClassObject(value) => {
|
||
let settings = match &value.data {
|
||
ClassObjectData::RenderSettings(settings) => settings,
|
||
ClassObjectData::MentalRayRenderSettings(settings) => &settings.base,
|
||
ClassObjectData::RapidRtRenderSettings(settings) => &settings.base,
|
||
_ => return None,
|
||
};
|
||
(settings.environment_image_enabled
|
||
&& !settings.environment_image_filename.is_empty())
|
||
.then_some((*handle, settings))
|
||
}
|
||
_ => None,
|
||
})
|
||
.min_by_key(|(handle, settings)| (!settings.has_predefined, handle.value()))
|
||
.map(|(_, settings)| settings);
|
||
if let Some(settings) = preset {
|
||
if let Some(image) = self.background_image(&settings.environment_image_filename) {
|
||
background.environment_params = [1.0, 0.0, 0.25, 0.35];
|
||
background.environment = Some(image);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn viewport_background(
|
||
&self,
|
||
handle: Handle,
|
||
canvas: [f32; 4],
|
||
depth: usize,
|
||
) -> ViewportBackgroundSettings {
|
||
use acadrust::objects::{ClassObjectData, ObjectType};
|
||
|
||
if depth > 4 || !handle.is_valid() {
|
||
return ViewportBackgroundSettings::canvas(canvas);
|
||
}
|
||
let Some(ObjectType::ClassObject(value)) = self.document.objects.get(&handle) else {
|
||
return ViewportBackgroundSettings::canvas(canvas);
|
||
};
|
||
match &value.data {
|
||
ClassObjectData::SolidBackground(background) => {
|
||
let mut result = ViewportBackgroundSettings::canvas(
|
||
Self::packed_background_color(background.color),
|
||
);
|
||
result.params[0] = 1.0;
|
||
result
|
||
}
|
||
ClassObjectData::GradientBackground(background) => {
|
||
let mut result = ViewportBackgroundSettings::canvas(canvas);
|
||
result.colors[0] = Self::packed_background_color(background.color_top);
|
||
result.colors[1] = Self::packed_background_color(background.color_middle);
|
||
result.colors[2] = Self::packed_background_color(background.color_bottom);
|
||
result.base = result.colors[1];
|
||
result.params = [
|
||
2.0,
|
||
background.horizon as f32,
|
||
background.height as f32,
|
||
background.rotation as f32,
|
||
];
|
||
result
|
||
}
|
||
ClassObjectData::GroundPlaneBackground(background) => {
|
||
let mut result = ViewportBackgroundSettings::canvas(canvas);
|
||
result.colors[0] = Self::packed_background_color(background.color_sky_zenith);
|
||
result.colors[1] = Self::packed_background_color(background.color_sky_horizon);
|
||
result.colors[2] =
|
||
Self::packed_background_color(background.color_underground_horizon);
|
||
result.colors[3] =
|
||
Self::packed_background_color(background.color_underground_azimuth);
|
||
result.colors[4] = Self::packed_background_color(background.color_near);
|
||
result.base = Self::packed_background_color(background.color_far);
|
||
result.params[0] = 3.0;
|
||
result
|
||
}
|
||
ClassObjectData::ImageBackground(background) => {
|
||
let mut result = ViewportBackgroundSettings::canvas(canvas);
|
||
if let Some(image) = self.background_image(&background.filename) {
|
||
result.image_params = [
|
||
background.fit_to_screen as u8 as f32,
|
||
background.maintain_aspect_ratio as u8 as f32,
|
||
background.use_tiling as u8 as f32,
|
||
image.width as f32 / image.height.max(1) as f32,
|
||
];
|
||
result.image_transform = [
|
||
background.offset.x as f32,
|
||
background.offset.y as f32,
|
||
background.scale.x as f32,
|
||
background.scale.y as f32,
|
||
];
|
||
result.params[0] = 4.0;
|
||
result.image = Some(image);
|
||
}
|
||
result
|
||
}
|
||
ClassObjectData::IblBackground(background) => {
|
||
let mut result = self.viewport_background(
|
||
background.secondary_background,
|
||
canvas,
|
||
depth + 1,
|
||
);
|
||
if background.enabled {
|
||
if let Some(environment) = self.background_image(&background.name) {
|
||
result.environment_params = [
|
||
1.0,
|
||
background.rotation as f32,
|
||
0.25,
|
||
0.35,
|
||
];
|
||
if background.display_image {
|
||
result.params = [5.0, 0.0, 0.0, background.rotation as f32];
|
||
result.image_params[3] =
|
||
environment.width as f32 / environment.height.max(1) as f32;
|
||
result.image = Some(environment.clone());
|
||
}
|
||
result.environment = Some(environment);
|
||
}
|
||
}
|
||
result
|
||
}
|
||
ClassObjectData::SkyLightBackground(background) => {
|
||
let mut result = ViewportBackgroundSettings::canvas(canvas);
|
||
result.colors[0] = [0.18, 0.42, 0.78, 1.0];
|
||
result.colors[1] = [1.0, 0.92, 0.72, 0.0];
|
||
result.colors[2] = [0.78, 0.86, 0.95, 1.0];
|
||
result.base = result.colors[2];
|
||
result.params[0] = 6.0;
|
||
if let Some(ObjectType::ClassObject(value)) =
|
||
self.document.objects.get(&background.sun)
|
||
{
|
||
if let ClassObjectData::Sun(sun) = &value.data {
|
||
if sun.is_on {
|
||
if let Some(direction) = self
|
||
.geolocation()
|
||
.and_then(|geo| solar_direction(sun, geo))
|
||
{
|
||
result.params[1..4].copy_from_slice(&[
|
||
-direction[0],
|
||
-direction[1],
|
||
-direction[2],
|
||
]);
|
||
let color = tess_util::aci_to_rgba(&sun.color);
|
||
result.colors[1] = [
|
||
color[0],
|
||
color[1],
|
||
color[2],
|
||
sun.intensity.max(0.0) as f32,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
result
|
||
}
|
||
_ => ViewportBackgroundSettings::canvas(canvas),
|
||
}
|
||
}
|
||
|
||
fn apply_default_lighting(uniforms: &mut Uniforms, camera: &Camera, default_type: i16) {
|
||
let sources = [
|
||
(glam::Vec3::new(0.5, 0.8, 0.6), 0.6_f32),
|
||
(glam::Vec3::new(-0.7, 0.3, 0.4), 0.4_f32),
|
||
];
|
||
let count = if default_type == 0 { 1 } else { 2 };
|
||
uniforms.lighting[0] = count as f32;
|
||
for (index, (view_direction, two_light_intensity)) in
|
||
sources.into_iter().take(count).enumerate()
|
||
{
|
||
let toward_source = (camera.rotation * view_direction).normalize_or_zero();
|
||
let intensity = if count == 1 { 1.0 } else { two_light_intensity };
|
||
uniforms.light_position_type[index][3] = 1.0;
|
||
uniforms.light_direction_intensity[index] = [
|
||
-toward_source.x,
|
||
-toward_source.y,
|
||
-toward_source.z,
|
||
intensity,
|
||
];
|
||
uniforms.light_color_hotspot[index] = [1.0, 1.0, 1.0, 1.0];
|
||
uniforms.light_attenuation[index][3] = -1.0;
|
||
}
|
||
}
|
||
|
||
/// Returns (entity_color, pattern_length, pattern, line_weight_px, aci).
|
||
pub(in crate::scene) fn render_style(&self, e: &EntityType) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
||
let (color, pl, pat, lw, aci) = render_style_for(&self.document, e);
|
||
let bg = if self.current_layout == "Model" {
|
||
self.bg_color
|
||
} else {
|
||
self.paper_bg_color
|
||
};
|
||
// 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)
|
||
}
|
||
|
||
fn display_plot_style(&self) -> Option<Arc<crate::io::plot_style::PlotStyleTable>> {
|
||
if self.current_layout == "Model" {
|
||
return None;
|
||
}
|
||
let settings = self.effective_plot_settings()?;
|
||
if !settings.flags.show_plot_styles || settings.current_style_sheet.is_empty() {
|
||
return None;
|
||
}
|
||
let key = (
|
||
self.current_layout.clone(),
|
||
settings.current_style_sheet.to_ascii_lowercase(),
|
||
);
|
||
if let Some(style) = self.display_plot_style_cache.borrow().get(&key) {
|
||
return style.clone();
|
||
}
|
||
let style = crate::io::plot_style::PlotStyleTable::load_named(
|
||
&settings.current_style_sheet,
|
||
)
|
||
.ok()
|
||
.map(Arc::new);
|
||
self.display_plot_style_cache
|
||
.borrow_mut()
|
||
.insert(key, style.clone());
|
||
style
|
||
}
|
||
|
||
fn apply_display_plot_style(
|
||
&self,
|
||
color: &mut [f32; 4],
|
||
aci: u8,
|
||
style: &crate::io::plot_style::PlotStyleTable,
|
||
) {
|
||
if aci == 0 {
|
||
return;
|
||
}
|
||
if let Some(rgb) = style.resolve_color(aci) {
|
||
color[..3].copy_from_slice(&rgb);
|
||
}
|
||
let screening = style.resolve_screening(aci);
|
||
for (channel, paper) in color[..3]
|
||
.iter_mut()
|
||
.zip(self.paper_bg_color[..3].iter())
|
||
{
|
||
*channel = *channel * screening + *paper * (1.0 - screening);
|
||
}
|
||
}
|
||
|
||
fn display_styled_wires(
|
||
&self,
|
||
source: Arc<Vec<WireModel>>,
|
||
source_gen: u64,
|
||
) -> (Arc<Vec<WireModel>>, u64) {
|
||
let Some(style) = self.display_plot_style() else {
|
||
return (source, source_gen);
|
||
};
|
||
let key = (source_gen, style.name.to_ascii_lowercase());
|
||
if let Some((gen, wires)) = self.styled_wire_cache.borrow().get(&key) {
|
||
return (Arc::clone(wires), *gen);
|
||
}
|
||
let mut wires = source.as_ref().clone();
|
||
for wire in &mut wires {
|
||
self.apply_display_plot_style(&mut wire.color, wire.aci, &style);
|
||
if wire.aci > 0 {
|
||
if wire.fill_is_2d_solid
|
||
&& style
|
||
.aci_entries
|
||
.get(wire.aci as usize)
|
||
.is_some_and(|entry| (65..=72).contains(&entry.fill_style))
|
||
{
|
||
wire.fill_tris.clear();
|
||
wire.fill_tris_low.clear();
|
||
}
|
||
if let Some(mm) = style.resolve_lineweight(wire.aci) {
|
||
wire.line_weight_px = (mm * MM_TO_PX).max(1.0);
|
||
}
|
||
for vertex in &mut wire.text_verts {
|
||
self.apply_display_plot_style(&mut vertex.color, wire.aci, &style);
|
||
}
|
||
}
|
||
}
|
||
let wires = Arc::new(wires);
|
||
let gen = crate::scene::WIRE_CONTENT_GEN
|
||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
let mut cache = self.styled_wire_cache.borrow_mut();
|
||
if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT {
|
||
cache.clear();
|
||
}
|
||
cache.insert(key, (gen, Arc::clone(&wires)));
|
||
(wires, gen)
|
||
}
|
||
|
||
fn display_styled_hatches(
|
||
&self,
|
||
source: Arc<Vec<HatchModel>>,
|
||
wire_fills: Option<&Arc<Vec<HatchModel>>>,
|
||
pattern_scale: f32,
|
||
) -> Arc<Vec<HatchModel>> {
|
||
let Some(style) = self.display_plot_style() else {
|
||
return source;
|
||
};
|
||
let key = (
|
||
self.geometry_epoch,
|
||
Arc::as_ptr(&source) as usize,
|
||
wire_fills.map_or(0, |fills| Arc::as_ptr(fills) as usize),
|
||
style.name.to_ascii_lowercase(),
|
||
pattern_scale.to_bits(),
|
||
);
|
||
if let Some(hatches) = self.styled_hatch_cache.borrow().get(&key) {
|
||
return Arc::clone(hatches);
|
||
}
|
||
let mut hatches = source.as_ref().clone();
|
||
for hatch in &mut hatches {
|
||
self.apply_display_plot_style(&mut hatch.color, hatch.aci, &style);
|
||
if hatch.aci > 0 {
|
||
if matches!(hatch.pattern, crate::scene::model::hatch_model::HatchPattern::Solid) {
|
||
if let Some(fill_style) = style
|
||
.aci_entries
|
||
.get(hatch.aci as usize)
|
||
.and_then(|entry| {
|
||
crate::scene::model::hatch_model::plot_style_fill_pattern(
|
||
entry.fill_style,
|
||
)
|
||
})
|
||
{
|
||
hatch.pattern = fill_style;
|
||
hatch.scale = pattern_scale;
|
||
}
|
||
}
|
||
if let Some(mm) = style.resolve_lineweight(hatch.aci) {
|
||
hatch.line_weight_px = (mm * MM_TO_PX).max(1.0);
|
||
}
|
||
if let crate::scene::model::hatch_model::HatchPattern::Gradient {
|
||
color2, ..
|
||
} = &mut hatch.pattern
|
||
{
|
||
self.apply_display_plot_style(color2, hatch.aci, &style);
|
||
}
|
||
}
|
||
}
|
||
if let Some(wire_fills) = wire_fills {
|
||
hatches.extend(wire_fills.iter().cloned());
|
||
}
|
||
let hatches = Arc::new(hatches);
|
||
let mut cache = self.styled_hatch_cache.borrow_mut();
|
||
cache.retain(|(epoch, _, _, _, _), _| *epoch == self.geometry_epoch);
|
||
if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT * 2 {
|
||
cache.clear();
|
||
}
|
||
cache.insert(key, Arc::clone(&hatches));
|
||
hatches
|
||
}
|
||
|
||
fn display_styled_wire_fills(
|
||
&self,
|
||
wires: &Arc<Vec<WireModel>>,
|
||
source_gen: u64,
|
||
pattern_scale: f32,
|
||
) -> Option<Arc<Vec<HatchModel>>> {
|
||
let Some(style) = self.display_plot_style() else {
|
||
return None;
|
||
};
|
||
// Depth generation rides in the key: the fills below compose their
|
||
// depth from the live depth map, which can move independently of the
|
||
// wire set (a DRAWORDER edit re-ranks without retessellating).
|
||
let key = (
|
||
source_gen,
|
||
style.name.to_ascii_lowercase(),
|
||
pattern_scale.to_bits(),
|
||
self.draw_depth_generation(),
|
||
);
|
||
if let Some(hatches) = self.styled_wire_fill_cache.borrow().get(&key) {
|
||
return Some(Arc::clone(hatches));
|
||
}
|
||
let depths = self.draw_depth_map();
|
||
let mut hatches = Vec::new();
|
||
for wire in wires.iter().filter(|wire| wire.fill_is_2d_solid && wire.aci > 0) {
|
||
let Some(pattern) = style
|
||
.aci_entries
|
||
.get(wire.aci as usize)
|
||
.and_then(|entry| {
|
||
(65..=72)
|
||
.contains(&entry.fill_style)
|
||
.then(|| {
|
||
crate::scene::model::hatch_model::plot_style_fill_pattern(
|
||
entry.fill_style,
|
||
)
|
||
})
|
||
.flatten()
|
||
})
|
||
else {
|
||
continue;
|
||
};
|
||
let mut color = wire.color;
|
||
self.apply_display_plot_style(&mut color, wire.aci, &style);
|
||
let line_weight_px = style
|
||
.resolve_lineweight(wire.aci)
|
||
.map(|mm| (mm * MM_TO_PX).max(1.0))
|
||
.unwrap_or(wire.line_weight_px);
|
||
for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() {
|
||
let mut boundary = Vec::with_capacity(4);
|
||
for (point_index, point) in triangle.iter().enumerate() {
|
||
let index = triangle_index * 3 + point_index;
|
||
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
boundary.push([point[0] + low[0], point[1] + low[1]]);
|
||
}
|
||
boundary.push(boundary[0]);
|
||
hatches.push(HatchModel {
|
||
pattern_origin: None,
|
||
render_instance: wire.render_instance.clone(),
|
||
world_origin: [0.0, 0.0],
|
||
boundary: Arc::new(boundary),
|
||
boundary_wcs: None,
|
||
fill_plane: None,
|
||
fill_plane_boundary: None,
|
||
boundary_exterior: None,
|
||
boundary_sources: None,
|
||
boundary_paths: None,
|
||
style: acadrust::entities::HatchStyleType::Normal,
|
||
pattern: pattern.clone(),
|
||
name: "PLOTSTYLE".to_string(),
|
||
color,
|
||
aci: 0,
|
||
line_weight_px,
|
||
angle_offset: 0.0,
|
||
scale: pattern_scale,
|
||
// Compose against the scene graph exactly like the wire
|
||
// pipeline does. The raw depth_override is a per-block
|
||
// child label (or None for top-level wires) — either way
|
||
// it would place the fill outside its host's depth band
|
||
// and let sibling wipes/masks bury it.
|
||
draw_depth: crate::scene::pipeline::wire_gpu::wire_draw_depth(
|
||
wire, &depths,
|
||
),
|
||
});
|
||
}
|
||
}
|
||
let hatches = Arc::new(hatches);
|
||
let mut cache = self.styled_wire_fill_cache.borrow_mut();
|
||
if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT {
|
||
cache.clear();
|
||
}
|
||
cache.insert(key, Arc::clone(&hatches));
|
||
Some(hatches)
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
// ── 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".
|
||
pub(in crate::scene) fn linetype_name_for<'a>(document: &'a CadDocument, e: &'a EntityType) -> &'a str {
|
||
linetype_name_for_viewport(document, e, None)
|
||
}
|
||
|
||
pub(in crate::scene) fn linetype_name_for_viewport<'a>(
|
||
document: &'a CadDocument,
|
||
e: &'a EntityType,
|
||
viewport: Option<Handle>,
|
||
) -> &'a str {
|
||
let elt = &e.common().linetype;
|
||
if elt.is_empty() || elt.eq_ignore_ascii_case("bylayer") {
|
||
if let Some(handle) = viewport_override(
|
||
document,
|
||
&e.common().layer,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportLinetypeOverride,
|
||
)
|
||
.and_then(|value| value.as_handle())
|
||
{
|
||
if let Some(line_type) = document.line_types.iter().find(|line_type| line_type.handle == handle) {
|
||
return line_type.name.as_str();
|
||
}
|
||
}
|
||
document
|
||
.layers
|
||
.get(&e.common().layer)
|
||
.map(|l| l.line_type.as_str())
|
||
.unwrap_or("Continuous")
|
||
} else {
|
||
elt.as_str()
|
||
}
|
||
}
|
||
|
||
/// Returns `(entity_color, pattern_length, pattern, line_weight_px, aci)` for
|
||
/// an entity, resolving ByLayer color and linetype from the document.
|
||
pub(in crate::scene) fn render_style_for(
|
||
document: &CadDocument,
|
||
e: &EntityType,
|
||
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
||
render_style_for_viewport(document, e, None)
|
||
}
|
||
|
||
fn viewport_override(
|
||
document: &CadDocument,
|
||
layer_name: &str,
|
||
viewport: Option<Handle>,
|
||
kind: acadrust::objects::KnownXRecordKind,
|
||
) -> Option<acadrust::objects::XRecordValue> {
|
||
let viewport = viewport.filter(|handle| handle.is_valid())?;
|
||
let layer = document.layers.get(layer_name)?;
|
||
document
|
||
.layer_viewport_overrides(layer.handle, kind)
|
||
.into_iter()
|
||
.find_map(|(handle, value)| (handle == viewport).then_some(value))
|
||
}
|
||
|
||
pub(crate) fn render_style_for_viewport(
|
||
document: &CadDocument,
|
||
e: &EntityType,
|
||
viewport: Option<Handle>,
|
||
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
||
let layer_name = &e.common().layer;
|
||
let (entity_color, aci) = {
|
||
let common = e.common();
|
||
let book_color = common
|
||
.color_book_handle
|
||
.filter(|handle| handle.is_valid())
|
||
.and_then(|handle| document.objects.get(&handle))
|
||
.and_then(|object| match object {
|
||
acadrust::objects::ObjectType::BookColor(book) => Some(&book.color),
|
||
_ => None,
|
||
});
|
||
let ec = book_color.unwrap_or(&common.color);
|
||
let viewport_color = (!book_color.is_some() && *ec == AcadColor::ByLayer)
|
||
.then(|| {
|
||
viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportColorOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(AcadColor::from_true_color_value)
|
||
})
|
||
.flatten();
|
||
let resolved = if *ec == AcadColor::ByLayer {
|
||
viewport_color.as_ref().unwrap_or_else(|| {
|
||
document
|
||
.layers
|
||
.get(layer_name)
|
||
.map(|l| &l.color)
|
||
.unwrap_or(&AcadColor::WHITE)
|
||
})
|
||
} else {
|
||
ec
|
||
};
|
||
let aci = match resolved {
|
||
AcadColor::Index(i) => *i,
|
||
_ => 0,
|
||
};
|
||
let [r, g, b, _] = tess_util::aci_to_rgba(resolved);
|
||
let transparency = if common.transparency.is_by_layer() {
|
||
viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportAlphaOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(|value| acadrust::types::Transparency::from_alpha_value(value as u32))
|
||
.or_else(|| document.layers.get(layer_name).map(|layer| layer.transparency))
|
||
.unwrap_or(common.transparency)
|
||
} else {
|
||
common.transparency
|
||
};
|
||
let alpha = 1.0 - transparency.as_percent() as f32;
|
||
([r, g, b, alpha], aci)
|
||
};
|
||
|
||
let lt_name = linetype_name_for_viewport(document, e, viewport);
|
||
// 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;
|
||
let (pattern_length, pattern) = resolve_pattern(&document.line_types, lt_name, lt_scale);
|
||
|
||
let line_weight_px = {
|
||
// 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 viewport_lineweight = matches!(ew, LineWeight::ByLayer | LineWeight::Default)
|
||
.then(|| {
|
||
viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportLineweightOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(|value| LineWeight::from_value(value as i16))
|
||
})
|
||
.flatten();
|
||
let resolved = match ew {
|
||
LineWeight::ByLayer | LineWeight::ByBlock | LineWeight::Default => viewport_lineweight
|
||
.as_ref()
|
||
.or_else(|| document.layers.get(layer_name).map(|l| &l.line_weight))
|
||
.unwrap_or(&LineWeight::Default),
|
||
_ => ew,
|
||
};
|
||
lineweight_to_px(resolved)
|
||
};
|
||
|
||
(entity_color, pattern_length, pattern, line_weight_px, aci)
|
||
}
|
||
|
||
pub(crate) fn has_resolved_book_color(document: &CadDocument, e: &EntityType) -> bool {
|
||
e.common()
|
||
.color_book_handle
|
||
.filter(|handle| handle.is_valid())
|
||
.and_then(|handle| document.objects.get(&handle))
|
||
.is_some_and(|object| matches!(object, acadrust::objects::ObjectType::BookColor(_)))
|
||
}
|
||
|
||
/// 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 {
|
||
lw.millimeters()
|
||
.map(|mm| (mm as f32 * MM_TO_PX).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 {
|
||
layer_render_style_viewport(document, layer_name, None)
|
||
}
|
||
|
||
pub(crate) fn layer_render_style_viewport(
|
||
document: &CadDocument,
|
||
layer_name: &str,
|
||
viewport: Option<Handle>,
|
||
) -> InheritStyle {
|
||
let layer = document.layers.get(layer_name);
|
||
let viewport_color = viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportColorOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(AcadColor::from_true_color_value);
|
||
let color = viewport_color
|
||
.as_ref()
|
||
.or_else(|| layer.map(|layer| &layer.color))
|
||
.unwrap_or(&AcadColor::WHITE);
|
||
let [r, g, b, _] = tess_util::aci_to_rgba(color);
|
||
let alpha = viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportAlphaOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(|value| acadrust::types::Transparency::from_alpha_value(value as u32))
|
||
.or_else(|| layer.map(|layer| layer.transparency))
|
||
.map(|transparency| 1.0 - transparency.as_percent() as f32)
|
||
.unwrap_or(1.0);
|
||
let lt_name = viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportLinetypeOverride,
|
||
)
|
||
.and_then(|value| value.as_handle())
|
||
.and_then(|handle| document.line_types.iter().find(|line_type| line_type.handle == handle))
|
||
.map(|line_type| line_type.name.as_str())
|
||
.or_else(|| layer.map(|layer| layer.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 viewport_lineweight = viewport_override(
|
||
document,
|
||
layer_name,
|
||
viewport,
|
||
acadrust::objects::KnownXRecordKind::LayerViewportLineweightOverride,
|
||
)
|
||
.and_then(|value| value.as_i32())
|
||
.map(|value| LineWeight::from_value(value as i16));
|
||
let lw = viewport_lineweight
|
||
.as_ref()
|
||
.or_else(|| layer.map(|layer| &layer.line_weight))
|
||
.unwrap_or(&LineWeight::Default);
|
||
InheritStyle {
|
||
color: [r, g, b, alpha],
|
||
pat_len,
|
||
pat,
|
||
lw_px: lineweight_to_px(lw),
|
||
}
|
||
}
|
||
|
||
/// Whether a block child uses layer-0 inheritance semantics.
|
||
///
|
||
/// XREF merge keeps dependent layers distinct by namespacing them as
|
||
/// `xref|layer`; its source layer `0` therefore becomes `xref|0` but must still
|
||
/// inherit through the containing INSERT exactly like an unprefixed layer 0.
|
||
pub(crate) fn is_effective_layer_zero(layer_name: &str) -> bool {
|
||
layer_name.eq_ignore_ascii_case("0")
|
||
|| layer_name
|
||
.rsplit_once('|')
|
||
.is_some_and(|(_, dependent)| dependent.eq_ignore_ascii_case("0"))
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn render_style_for_block_sub(
|
||
document: &CadDocument,
|
||
e: &EntityType,
|
||
insert_color: [f32; 4],
|
||
insert_pat_len: f32,
|
||
insert_pat: [f32; 8],
|
||
insert_lw_px: f32,
|
||
l0: InheritStyle,
|
||
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
||
render_style_for_block_sub_viewport(
|
||
document,
|
||
e,
|
||
insert_color,
|
||
insert_pat_len,
|
||
insert_pat,
|
||
insert_lw_px,
|
||
l0,
|
||
None,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn render_style_for_block_sub_viewport(
|
||
document: &CadDocument,
|
||
e: &EntityType,
|
||
insert_color: [f32; 4],
|
||
insert_pat_len: f32,
|
||
insert_pat: [f32; 8],
|
||
insert_lw_px: f32,
|
||
l0: InheritStyle,
|
||
viewport: Option<Handle>,
|
||
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
|
||
let (color, pat_len, pat, lw_px, aci) = render_style_for_viewport(document, e, viewport);
|
||
let common = e.common();
|
||
let on_l0 = is_effective_layer_zero(&common.layer);
|
||
|
||
let has_book_color = has_resolved_book_color(document, e);
|
||
let resolved_rgb = if !has_book_color && common.color == AcadColor::ByBlock {
|
||
insert_color
|
||
} else if !has_book_color && on_l0 && common.color == AcadColor::ByLayer {
|
||
l0.color
|
||
} else {
|
||
color
|
||
};
|
||
let alpha = if common.transparency.is_by_block() {
|
||
insert_color[3]
|
||
} else if on_l0 && common.transparency.is_by_layer() {
|
||
l0.color[3]
|
||
} else {
|
||
color[3]
|
||
};
|
||
let final_color = [resolved_rgb[0], resolved_rgb[1], resolved_rgb[2], alpha];
|
||
|
||
let lt_bylayer =
|
||
common.linetype.is_empty() || common.linetype.eq_ignore_ascii_case("bylayer");
|
||
let (final_pat_len, final_pat) = if common.linetype.eq_ignore_ascii_case("byblock") {
|
||
(insert_pat_len, insert_pat)
|
||
} else if on_l0 && lt_bylayer {
|
||
(l0.pat_len, l0.pat)
|
||
} else {
|
||
(pat_len, pat)
|
||
};
|
||
|
||
let final_lw = if matches!(common.line_weight, LineWeight::ByBlock) {
|
||
insert_lw_px
|
||
} else if on_l0 && matches!(common.line_weight, LineWeight::ByLayer | LineWeight::Default) {
|
||
l0.lw_px
|
||
} else {
|
||
lw_px
|
||
};
|
||
|
||
(final_color, final_pat_len, final_pat, final_lw, aci)
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
||
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
|
||
}
|
||
}
|
||
|
||
/// Re-resolve display colours for `bg`, for wires that recorded what their
|
||
/// resolution consumed.
|
||
///
|
||
/// This is what lets a set tessellated under one background be shown under
|
||
/// another without rebuilding it: the geometry never depended on the
|
||
/// background, only the colours did — see the `let _ = bg_color;` in
|
||
/// `block_cache::local_wires_for`.
|
||
///
|
||
/// Every colour is recomputed from `raw_color`, never from the current
|
||
/// `color`, so the pass is idempotent and safe over a set that mixes memoized
|
||
/// wires with freshly tessellated ones. Recomputing from `color` would not be:
|
||
/// `adapt_to_bg` maps a near-white colour to pure black, and pure black back
|
||
/// to pure *white*, so a second application loses the original tint.
|
||
///
|
||
/// Wires with `bg_adapt: None` — everything not built by `Batches::finalize` —
|
||
/// are left alone.
|
||
pub(crate) fn resolve_colors_for_bg(wires: &mut [WireModel], bg: [f32; 4]) {
|
||
for wire in wires {
|
||
let Some(adapt) = wire.bg_adapt.as_deref() else {
|
||
continue;
|
||
};
|
||
let contrast = adapt.contrast_bg.unwrap_or(bg);
|
||
wire.color = if adapt.canvas_color {
|
||
bg
|
||
} else if adapt.preserve_color {
|
||
adapt.raw_color
|
||
} else {
|
||
adapt_to_bg(adapt.raw_color, contrast)
|
||
};
|
||
if !adapt.preserve_color {
|
||
for (vertex, raw) in wire.text_verts.iter_mut().zip(&adapt.text_raw_colors) {
|
||
vertex.color = adapt_to_bg(*raw, contrast);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──
|
||
|
||
impl Scene {
|
||
/// 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(
|
||
&self,
|
||
wires: &[WireModel],
|
||
wire_content_id: u64,
|
||
source_key: u64,
|
||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||
) -> std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>> {
|
||
use std::sync::Arc;
|
||
{
|
||
let cache = self.sdf_text_cache.borrow();
|
||
if let Some(verts) = cache.get(&wire_content_id) {
|
||
return verts.clone();
|
||
}
|
||
}
|
||
// A new content id (every geometry edit) misses the cache — but if no
|
||
// text-bearing entity changed since the last build, the glyphs are
|
||
// identical, so reuse them instead of re-walking every wire. Reuse is
|
||
// per `source_key`: another source's glyphs are a different wire set,
|
||
// not an older build of this one (#403). The glyphs also bake the
|
||
// entity draw-order depth (below), which shifts whenever an entity is
|
||
// added or removed — reuse only across rank-stable (all-Modified)
|
||
// edits.
|
||
{
|
||
let reuse = {
|
||
let last = self.last_sdf_text.borrow();
|
||
match last.get(&source_key) {
|
||
Some((epoch, arc))
|
||
if self.text_unchanged(*epoch) && self.draw_ranks_stable(*epoch) =>
|
||
{
|
||
Some(arc.clone())
|
||
}
|
||
_ => None,
|
||
}
|
||
};
|
||
if let Some(arc) = reuse {
|
||
self.sdf_text_cache
|
||
.borrow_mut()
|
||
.insert(wire_content_id, arc.clone());
|
||
self.last_sdf_text
|
||
.borrow_mut()
|
||
.insert(source_key, (self.geometry_epoch, arc.clone()));
|
||
return arc;
|
||
}
|
||
}
|
||
let mut out: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||
for w in wires {
|
||
if !w.display_visible {
|
||
continue;
|
||
}
|
||
if w.render_instance.is_some() {
|
||
continue;
|
||
}
|
||
if !w.text_verts.is_empty() {
|
||
// Bake the host wire's draw-order depth into its glyphs so
|
||
// text layers like the rest of the entity: its own background
|
||
// fill lands at the same biased depth (glyphs draw after fills
|
||
// and win the LessEqual test), and a hatch later in draw order
|
||
// still covers the text. A tessellation-time value (block-local
|
||
// compose) survives as an offset on top of the wire's depth.
|
||
let d = crate::scene::pipeline::wire_gpu::wire_draw_depth(w, depth_map);
|
||
if d == 0.0 {
|
||
out.extend_from_slice(&w.text_verts);
|
||
} else {
|
||
out.extend(w.text_verts.iter().map(|tv| {
|
||
let mut tv = *tv;
|
||
tv.draw_depth += d;
|
||
tv
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
let verts = Arc::new(out);
|
||
{
|
||
let mut cache = self.sdf_text_cache.borrow_mut();
|
||
// Ids change on rebuild, so old keys die naturally; cap bounds churn.
|
||
if cache.len() > 8 {
|
||
cache.clear();
|
||
}
|
||
cache.insert(wire_content_id, verts.clone());
|
||
}
|
||
{
|
||
let mut last = self.last_sdf_text.borrow_mut();
|
||
if last.len() > 8 {
|
||
last.clear();
|
||
}
|
||
last.insert(source_key, (self.geometry_epoch, verts.clone()));
|
||
}
|
||
verts
|
||
}
|
||
|
||
fn annotation_context_highlight_wires(
|
||
&self,
|
||
inst: &ViewportInstance,
|
||
) -> Arc<Vec<WireModel>> {
|
||
if self.selected.is_empty()
|
||
&& self.hover_highlight.is_none()
|
||
&& self.constraint_hover_highlights.is_empty()
|
||
{
|
||
return Arc::new(Vec::new());
|
||
}
|
||
|
||
let content_viewport = !inst.paper_sheet
|
||
&& inst.tile_idx.is_none()
|
||
&& inst.handle != Handle::NULL;
|
||
let target_block = if inst.paper_sheet {
|
||
self.current_layout_block_handle()
|
||
} else {
|
||
self.content_render_block_handle()
|
||
};
|
||
let annotation_scale_handle = if inst.paper_sheet {
|
||
self.paper_annotation_scale_handle()
|
||
} else if content_viewport {
|
||
self.viewport_scale_handle(inst.handle)
|
||
} else {
|
||
crate::scene::annotative::scale_handle_by_name(
|
||
&self.document,
|
||
&self.document.header.current_annotation_scale,
|
||
)
|
||
};
|
||
let annotation_scale = if inst.paper_sheet {
|
||
1.0
|
||
} else if content_viewport {
|
||
self.viewport_annotation_multiplier(inst.handle)
|
||
} else {
|
||
self.annotation_scale
|
||
};
|
||
let frozen: rustc_hash::FxHashSet<Handle> = if content_viewport {
|
||
match self.document.get_entity(inst.handle) {
|
||
Some(EntityType::Viewport(viewport)) => {
|
||
viewport.frozen_layers.iter().copied().collect()
|
||
}
|
||
_ => rustc_hash::FxHashSet::default(),
|
||
}
|
||
} else {
|
||
rustc_hash::FxHashSet::default()
|
||
};
|
||
let bg = if self.current_layout == "Model" {
|
||
self.bg_color
|
||
} else {
|
||
self.paper_bg_color
|
||
};
|
||
let all_visible = self.annotation_all_visible();
|
||
|
||
let mut key = 0xcbf2_9ce4_8422_2325_u64;
|
||
let mut mix = |value: u64| {
|
||
key = key.rotate_left(17) ^ value.wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||
};
|
||
mix(self.geometry_epoch);
|
||
mix(self.selection_generation);
|
||
mix(target_block.value());
|
||
mix(annotation_scale_handle.map_or(0, |handle| handle.value()));
|
||
mix(annotation_scale.to_bits() as u64);
|
||
mix(u64::from(content_viewport));
|
||
mix(u64::from(all_visible));
|
||
mix(self.active_viewport.map_or(0, |handle| handle.value()));
|
||
mix(crate::scene::text::sdf_atlas::generation());
|
||
for component in bg {
|
||
mix(component.to_bits() as u64);
|
||
}
|
||
let mut frozen_sig = frozen.len() as u64;
|
||
for handle in &frozen {
|
||
frozen_sig ^= handle
|
||
.value()
|
||
.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||
}
|
||
mix(frozen_sig);
|
||
|
||
if let Some(wires) = self.annotation_highlight_cache.borrow().get(&key) {
|
||
return Arc::clone(wires);
|
||
}
|
||
|
||
let mut highlighted: Vec<(Handle, bool)> = self
|
||
.selected
|
||
.iter()
|
||
.copied()
|
||
.map(|handle| (handle, true))
|
||
.collect();
|
||
highlighted.extend(
|
||
self.hover_highlight_handles()
|
||
.into_iter()
|
||
.filter(|handle| !self.selected.contains(handle))
|
||
.map(|handle| (handle, false)),
|
||
);
|
||
highlighted.sort_unstable_by_key(|(handle, _)| handle.value());
|
||
|
||
let empty_selection = rustc_hash::FxHashSet::default();
|
||
let interaction_meshes = self.interaction_meshes_arc();
|
||
let mut wires = Vec::new();
|
||
for (handle, selected) in highlighted {
|
||
let Some(entity) = self.document.get_entity(handle) else {
|
||
continue;
|
||
};
|
||
if !self.resident_entity_visible(
|
||
entity,
|
||
target_block,
|
||
Some(&frozen),
|
||
annotation_scale_handle,
|
||
true,
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
let tint = if selected {
|
||
self.selection_color
|
||
} else {
|
||
WireModel::HOVER
|
||
};
|
||
for set in interaction_meshes
|
||
.iter()
|
||
.filter(|set| set.entity_handle() == Some(handle))
|
||
{
|
||
let (edges, edges_low) = set.geometry_edges();
|
||
if edges.len() < 2 {
|
||
continue;
|
||
}
|
||
let mut points = Vec::with_capacity(edges.len() / 2 * 3);
|
||
for pair_start in (0..edges.len() - 1).step_by(2) {
|
||
for index in [pair_start, pair_start + 1] {
|
||
let high = edges[index];
|
||
let low = edges_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
let local = acadrust::types::Vector3::new(
|
||
high[0] as f64 + low[0] as f64,
|
||
high[1] as f64 + low[1] as f64,
|
||
high[2] as f64 + low[2] as f64,
|
||
);
|
||
let world = set
|
||
.instance_transform
|
||
.map_or(local, |transform| transform.apply(local));
|
||
points.push([world.x, world.y, world.z]);
|
||
}
|
||
points.push([f64::NAN; 3]);
|
||
}
|
||
let mut edge_wire = WireModel::solid_f64(
|
||
format!("mesh-edge:{}", handle.value()),
|
||
points,
|
||
tint,
|
||
selected,
|
||
);
|
||
edge_wire.line_weight_px = 2.0;
|
||
wires.push(edge_wire);
|
||
}
|
||
|
||
if matches!(entity, EntityType::Hatch(_)) {
|
||
if selected {
|
||
if let Some(mut wire) = self.hatch_outline_wire(handle) {
|
||
let (_, pattern_length, pattern, line_weight_px, aci) =
|
||
render_style_for_viewport(
|
||
&self.document,
|
||
entity,
|
||
content_viewport.then_some(inst.handle),
|
||
);
|
||
wire.color = self.selection_color;
|
||
wire.selected = true;
|
||
wire.pattern_length = pattern_length;
|
||
wire.pattern = pattern;
|
||
wire.line_weight_px = line_weight_px;
|
||
wire.aci = aci;
|
||
wires.push(wire);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if !crate::scene::annotative::is_annotative(&self.document, entity) {
|
||
continue;
|
||
}
|
||
|
||
let mut scales: Vec<Handle> = crate::scene::annotative::object_scale_memberships(
|
||
&self.document,
|
||
handle,
|
||
)
|
||
.into_iter()
|
||
.map(|(_, scale)| scale)
|
||
.collect();
|
||
scales.sort_unstable_by_key(Handle::value);
|
||
scales.dedup();
|
||
|
||
let base_visible = !crate::scene::annotative::annotative_offscale_for(
|
||
&self.document,
|
||
entity.common(),
|
||
annotation_scale_handle,
|
||
all_visible,
|
||
);
|
||
let displayed_scale = base_visible
|
||
.then(|| {
|
||
crate::scene::annotative::active_object_context_for_scale(
|
||
&self.document,
|
||
handle,
|
||
annotation_scale_handle,
|
||
)
|
||
.map(|context| context.scale)
|
||
})
|
||
.flatten();
|
||
for scale in scales {
|
||
if displayed_scale == Some(scale) {
|
||
continue;
|
||
}
|
||
let context_scale = match self.document.objects.get(&scale) {
|
||
Some(acadrust::objects::ObjectType::Scale(value)) => {
|
||
(value.inverse_factor() / self.annotation_scale_unit_factor()) as f32
|
||
}
|
||
_ => annotation_scale,
|
||
};
|
||
let block_cache = self.block_cache_arc_for(Some(scale), true, self.active_viewport);
|
||
let mut context_wires = crate::scene::tessellate_entity(
|
||
&self.document,
|
||
&empty_selection,
|
||
self.active_viewport,
|
||
bg,
|
||
context_scale,
|
||
Some(scale),
|
||
entity,
|
||
Some(&block_cache),
|
||
None,
|
||
None,
|
||
content_viewport,
|
||
);
|
||
for wire in &mut context_wires {
|
||
wire.color = tint;
|
||
wire.selected = selected;
|
||
for vertex in &mut wire.text_verts {
|
||
vertex.color = [tint[0], tint[1], tint[2], vertex.color[3]];
|
||
}
|
||
}
|
||
wires.extend(context_wires);
|
||
}
|
||
}
|
||
|
||
let wires = Arc::new(wires);
|
||
let mut cache = self.annotation_highlight_cache.borrow_mut();
|
||
if cache.len() > 16 {
|
||
cache.clear();
|
||
}
|
||
cache.insert(key, Arc::clone(&wires));
|
||
wires
|
||
}
|
||
|
||
/// 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.
|
||
pub(in crate::scene) fn build_viewports(
|
||
&self,
|
||
bounds: Rectangle,
|
||
model_render_mode: acadrust::entities::ViewportRenderMode,
|
||
_hover_region: Option<usize>,
|
||
show_viewcube: bool,
|
||
show_interaction: bool,
|
||
viewcube_text_color: [f32; 4],
|
||
) -> Primitive {
|
||
let nav_build_started = iced::time::Instant::now();
|
||
let perf_nav = self.take_nav_perf();
|
||
// 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 = show_interaction.then(|| self.viewcube_hover.get()).flatten();
|
||
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);
|
||
// Transparent clear — outside drawn geometry the resolve texture
|
||
// stays at alpha=0, so the alpha-blended blit reveals the container
|
||
// background (model bg, or the desk colour in a paper layout).
|
||
let bg_color = [0.0, 0.0, 0.0, 0.0];
|
||
let viewports: Vec<ViewportData> = instances
|
||
.iter()
|
||
.filter_map(|inst| {
|
||
let force = self.refresh_consume(self.instance_id_for(inst));
|
||
self.viewport_data_for(
|
||
inst,
|
||
canvas,
|
||
hover_region,
|
||
show_viewcube,
|
||
show_interaction,
|
||
force,
|
||
)
|
||
})
|
||
.collect();
|
||
// Empty viewports → blit nothing; the container background (model bg
|
||
// or the paper desk colour) stays visible.
|
||
let perf_nav = perf_nav.map(|mut sample| {
|
||
sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0;
|
||
sample
|
||
});
|
||
Primitive {
|
||
viewports,
|
||
bg_color,
|
||
viewcube_text_color,
|
||
selection_color: self.selection_color,
|
||
selection_effect: self.selection_effect,
|
||
nav_perf: perf_nav,
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
show_viewcube: bool,
|
||
show_interaction: bool,
|
||
viewcube_text_color: [f32; 4],
|
||
) -> Primitive {
|
||
let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten();
|
||
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,
|
||
viewcube_text_color,
|
||
selection_color: self.selection_color,
|
||
selection_effect: self.selection_effect,
|
||
nav_perf: None,
|
||
};
|
||
};
|
||
let active = self.active_model_tile.get();
|
||
let is_active = tile_idx == active;
|
||
if is_active && canvas.1 > 0.0 {
|
||
self.set_render_aspect(canvas.0 / canvas.1);
|
||
self.set_render_pixel_scale(canvas.0, canvas.1);
|
||
}
|
||
let nav_build_started = iced::time::Instant::now();
|
||
let perf_nav = if is_active {
|
||
self.take_nav_perf()
|
||
} else {
|
||
None
|
||
};
|
||
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 force = self.refresh_consume(self.instance_id_for(&inst));
|
||
let viewports = self
|
||
.viewport_data_for(
|
||
&inst,
|
||
canvas,
|
||
hover_region,
|
||
show_viewcube,
|
||
show_interaction,
|
||
force,
|
||
)
|
||
.into_iter()
|
||
.collect();
|
||
let perf_nav = perf_nav.map(|mut sample| {
|
||
sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0;
|
||
sample
|
||
});
|
||
Primitive {
|
||
viewports,
|
||
bg_color,
|
||
viewcube_text_color,
|
||
selection_color: self.selection_color,
|
||
selection_effect: self.selection_effect,
|
||
nav_perf: perf_nav,
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
show_viewcube: bool,
|
||
show_interaction: bool,
|
||
force_rasterize: bool,
|
||
) -> Option<ViewportData> {
|
||
let display = self.viewport_display_settings(inst);
|
||
let mut flags = render_mode_flags(inst.render_mode);
|
||
if let Some(style) = display.visual_style.as_ref() {
|
||
if flags.mesh_fill && style.face_lighting_quality == 1 {
|
||
flags.flat_shade = true;
|
||
}
|
||
}
|
||
let view_wireframe = !flags.face3d_fill;
|
||
|
||
// 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);
|
||
|
||
// EVERY wire source is resident + camera-independent now (unified
|
||
// static-hold, `resident_wires_for`) and stamps its stable
|
||
// [`WIRE_CONTENT_GEN`] id into `last_model_wire_gen` — Model tiles,
|
||
// the paper sheet, content viewports and the pick composite alike. So
|
||
// the GPU wire upload, the Face3D split and the SDF text gather below
|
||
// are all skipped while the content is unchanged, and
|
||
// `render_signature` stays stable so paper hits the single-blit scene
|
||
// cache exactly like Model.
|
||
let base_arc = if let Some(tile_idx) = inst.tile_idx {
|
||
let aspect = if full.height > 0.0 {
|
||
full.width / full.height
|
||
} else {
|
||
1.0
|
||
};
|
||
self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height)
|
||
} 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).
|
||
self.paper_sheet_wires_arc()
|
||
} else if inst.handle == acadrust::Handle::NULL {
|
||
self.entity_wires_arc()
|
||
} else {
|
||
self.model_wires_for_viewport_arc(inst.handle, full.height)
|
||
};
|
||
let source_gen = self.last_model_wire_gen.get();
|
||
let styled_fill_source = Arc::clone(&base_arc);
|
||
let (base_arc, styled_gen) = self.display_styled_wires(base_arc, source_gen);
|
||
self.last_model_wire_gen.set(styled_gen);
|
||
// 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 source's stable content
|
||
// gen — a drag or camera move never re-uploads the base wire set.
|
||
let base_wire_content_id = styled_gen;
|
||
let base_wire_patch = self.model_wire_patch_for(base_wire_content_id);
|
||
// Split Face3D wires from the rest. The split is content-only (keyed
|
||
// 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 — for every source, since all ids are stable now.
|
||
let (face3d_wires, other_arc) = {
|
||
let cached = { self.split_cache.borrow().get(&base_wire_content_id).cloned() };
|
||
let inherited_empty = if let Some((base, patch)) = base_wire_patch.as_ref() {
|
||
if patch.face_pass_changed {
|
||
None
|
||
} else {
|
||
self.split_cache
|
||
.borrow()
|
||
.get(base)
|
||
.filter(|(_, others)| others.is_none())
|
||
.cloned()
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let (fa, oa) = cached.or(inherited_empty).unwrap_or_else(|| {
|
||
// No Face3D wire at all (pure 2-D drawings, mesh imports):
|
||
// "others" would be a wire-for-wire copy of the base set —
|
||
// mark it `None` and use the base set directly instead of
|
||
// duplicating it (#358). The base Arc itself must not be
|
||
// stored in the cache (see the `split_cache` field docs).
|
||
let (fa, oa) = if base_arc.iter().any(|w| is_face3d_wire(w, &self.document)) {
|
||
let (f, o) = split_face3d_wires(&base_arc, &self.document);
|
||
(Arc::new(f), Some(Arc::new(o)))
|
||
} else {
|
||
(Arc::new(Vec::new()), None)
|
||
};
|
||
let mut c = self.split_cache.borrow_mut();
|
||
// Ids change on rebuild, so old keys die naturally; the cap
|
||
// just bounds pathological churn.
|
||
if c.len() > 8 {
|
||
c.clear();
|
||
}
|
||
c.insert(base_wire_content_id, (fa.clone(), oa.clone()));
|
||
(fa, oa)
|
||
});
|
||
self.split_cache
|
||
.borrow_mut()
|
||
.entry(base_wire_content_id)
|
||
.or_insert_with(|| (fa.clone(), oa.clone()));
|
||
(fa, oa.unwrap_or_else(|| Arc::clone(&base_arc)))
|
||
};
|
||
// 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;
|
||
// The 3-D wireframe deliberately ignores entity draw order and lets
|
||
// true depth decide overlaps. Tag its resident wire id separately so
|
||
// switching between the 2-D and 3-D styles rebuilds the GPU constants
|
||
// even though the world-space geometry itself did not change. The
|
||
// incremental patch's base id receives the same tag, preserving the
|
||
// arena fast path after the first mode switch.
|
||
let wire_mode_tag = u64::from(
|
||
inst.render_mode == acadrust::entities::ViewportRenderMode::Wireframe3D,
|
||
);
|
||
let wire_content_id = base_wire_content_id
|
||
.wrapping_mul(2)
|
||
.wrapping_add(wire_mode_tag);
|
||
let wire_patch = base_wire_patch.map(|(base, patch)| {
|
||
(
|
||
base.wrapping_mul(2).wrapping_add(wire_mode_tag),
|
||
patch,
|
||
)
|
||
});
|
||
// A live overlay belongs to one drawing space, but every viewport that
|
||
// displays that space must project the same world-space preview. In a
|
||
// paper layout, model-space overlays go to all content viewports while
|
||
// paper-space overlays stay on the sheet. This also keeps model-space
|
||
// coordinates out of the full-canvas sheet pass (#540).
|
||
let show_live_overlay = show_interaction && if self.current_layout == "Model" {
|
||
true
|
||
} else if self.active_viewport.is_some() {
|
||
!inst.paper_sheet
|
||
} else {
|
||
inst.paper_sheet
|
||
};
|
||
let annotation_context_wires = if show_live_overlay {
|
||
self.annotation_context_highlight_wires(inst)
|
||
} else {
|
||
Arc::new(Vec::new())
|
||
};
|
||
let preview_wires = if !show_live_overlay
|
||
|| (self.interim_wire.is_none()
|
||
&& self.preview_wires.is_empty()
|
||
&& self.constraint_hover_wires.is_empty())
|
||
{
|
||
Arc::new(Vec::new())
|
||
} else {
|
||
let mut v: Vec<WireModel> = Vec::with_capacity(
|
||
self.preview_wires.len() + self.constraint_hover_wires.len() + 1,
|
||
);
|
||
if let Some(iw) = &self.interim_wire {
|
||
v.push(iw.clone());
|
||
}
|
||
v.extend(self.preview_wires.iter().cloned());
|
||
v.extend(self.constraint_hover_wires.iter().cloned());
|
||
Arc::new(v)
|
||
};
|
||
let preview_hatches = if show_live_overlay {
|
||
Arc::clone(&self.preview_hatches)
|
||
} else {
|
||
Arc::new(Vec::new())
|
||
};
|
||
|
||
// Per-viewport frozen-layer set for a paper content viewport. Content
|
||
// viewports hide special fills, media, meshes and lights on VP-frozen
|
||
// layers too, matching the already-filtered resident wire set.
|
||
let vp_frozen: rustc_hash::FxHashSet<Handle> = if !inst.paper_sheet
|
||
&& inst.tile_idx.is_none()
|
||
&& inst.handle != acadrust::Handle::NULL
|
||
{
|
||
match self.document.get_entity(inst.handle) {
|
||
Some(EntityType::Viewport(vp)) => vp.frozen_layers.iter().cloned().collect(),
|
||
_ => rustc_hash::FxHashSet::default(),
|
||
}
|
||
} else {
|
||
rustc_hash::FxHashSet::default()
|
||
};
|
||
let lighting_block = if inst.paper_sheet {
|
||
self.current_layout_block_handle()
|
||
} else {
|
||
self.content_render_block_handle()
|
||
};
|
||
|
||
// 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 {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: full.width.max(1.0),
|
||
height: full.height.max(1.0),
|
||
};
|
||
let display_plot_lineweights = self.current_layout != "Model"
|
||
&& self.effective_plot_settings().is_some_and(|settings| {
|
||
settings.flags.show_plot_styles && settings.flags.print_lineweights
|
||
});
|
||
let mut uniforms = Uniforms::new(
|
||
&inst.camera,
|
||
full_bounds,
|
||
self.document.header.lineweight_display || display_plot_lineweights,
|
||
);
|
||
if self.current_layout == "Model" {
|
||
uniforms.lineweight_scale = -self.model_lineweight_scale;
|
||
} else {
|
||
let paper_per_pixel = 2.0 * self.camera.borrow().ortho_size() / canvas.1.max(1.0);
|
||
let paper_units_per_mm = self.paper_space_unit_factor() as f32;
|
||
uniforms.lineweight_scale = paper_units_per_mm / paper_per_pixel / MM_TO_PX;
|
||
}
|
||
|
||
// Model space: scale linetypes using the current annotation scale so their
|
||
// appearance can match a paper-space viewport at the same drawing scale.
|
||
if self.current_layout == "Model" {
|
||
if self.annotation_scale.is_finite() && self.annotation_scale > 1e-9 {
|
||
uniforms.linetype_scale = self.annotation_scale;
|
||
}
|
||
} else if self.document.header.paper_space_linetype_scaling
|
||
&& !inst.paper_sheet
|
||
&& inst.handle != Handle::NULL
|
||
{
|
||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(inst.handle) {
|
||
let viewport_scale =
|
||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||
|
||
if viewport_scale.is_finite() && viewport_scale > 1e-9 {
|
||
uniforms.linetype_scale = (1.0 / viewport_scale) as f32;
|
||
}
|
||
}
|
||
}
|
||
// Crop the rotation-only RTE view-projection to the visible sub-rect.
|
||
uniforms.view_rot = crop_view_proj(uniforms.view_rot, uo, vo, us, vs);
|
||
uniforms.viewport_size = [visible_w, visible_h];
|
||
uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 };
|
||
uniforms.transparency_enable = if self.transparency_display { 1.0 } else { 0.0 };
|
||
uniforms.viewport_background = display.background.base;
|
||
uniforms.view_tone = [display.brightness, display.contrast, 0.0, 0.0];
|
||
uniforms.background_top = display.background.colors[0];
|
||
uniforms.background_middle = display.background.colors[1];
|
||
uniforms.background_bottom = display.background.colors[2];
|
||
uniforms.background_aux0 = display.background.colors[3];
|
||
uniforms.background_aux1 = display.background.colors[4];
|
||
uniforms.background_params = display.background.params;
|
||
uniforms.background_image_params = display.background.image_params;
|
||
uniforms.background_image_transform = display.background.image_transform;
|
||
uniforms.environment_params = display.background.environment_params;
|
||
let environment_scale = (inst.camera.fov_y * 0.5).tan().max(1e-4);
|
||
uniforms.environment_view = glam::Mat4::from_quat(inst.camera.rotation)
|
||
* glam::Mat4::from_scale(glam::Vec3::new(
|
||
visible_w / visible_h.max(1.0) * environment_scale,
|
||
environment_scale,
|
||
1.0,
|
||
));
|
||
uniforms.fog_color = display.background.fog_color;
|
||
uniforms.fog_params = display.background.fog_params;
|
||
uniforms.fog_distances = display.background.fog_distances;
|
||
if let Some(style) = display.visual_style.as_ref() {
|
||
let opacity = if style.face_modifier & 1 != 0 {
|
||
style.face_opacity
|
||
} else {
|
||
1.0
|
||
};
|
||
let highlight = if style.face_modifier & 2 != 0 {
|
||
(style.face_specular / 100.0).clamp(0.0, 1.0)
|
||
} else {
|
||
-1.0
|
||
};
|
||
uniforms.visual_style = [
|
||
style.face_color_mode as f32,
|
||
opacity,
|
||
highlight,
|
||
(style.brightness / 10.0).clamp(-1.0, 1.0),
|
||
];
|
||
uniforms.visual_style_color =
|
||
style.mono_color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||
}
|
||
self.apply_document_lighting(&mut uniforms, lighting_block, &vp_frozen, inst);
|
||
|
||
// `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.
|
||
let screen_rect = Rectangle {
|
||
x: visible_x / canvas.0,
|
||
y: visible_y / canvas.1,
|
||
width: visible_w / canvas.0,
|
||
height: visible_h / canvas.1,
|
||
};
|
||
|
||
// 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 viewport model builders are block-filtered too;
|
||
// the scissor only clips their already-correct Model Space set.
|
||
let (hatches, wipeout_hatches, paper_images) = if inst.paper_sheet {
|
||
let (hatches, wipeouts, images) =
|
||
self.paper_sheet_render_models_for_view(show_interaction);
|
||
(hatches, wipeouts, Some(images))
|
||
} else {
|
||
(
|
||
self.hatch_models_for_viewport(inst.handle, &vp_frozen, show_interaction),
|
||
self.wipeout_models_for_viewport(inst.handle, &vp_frozen),
|
||
None,
|
||
)
|
||
};
|
||
let viewport_scale = if inst.paper_sheet {
|
||
1.0
|
||
} else {
|
||
self.document
|
||
.get_entity(inst.handle)
|
||
.and_then(|entity| match entity {
|
||
EntityType::Viewport(viewport) => Some(vp_effective_scale(
|
||
viewport.custom_scale,
|
||
viewport.view_height,
|
||
viewport.height,
|
||
)),
|
||
_ => None,
|
||
})
|
||
.unwrap_or(1.0)
|
||
};
|
||
let pattern_scale = (self.paper_space_unit_factor() / viewport_scale.max(1.0e-9)) as f32;
|
||
let styled_wire_fills =
|
||
self.display_styled_wire_fills(&styled_fill_source, source_gen, pattern_scale);
|
||
let hatches = self.display_styled_hatches(
|
||
hatches,
|
||
styled_wire_fills.as_ref().filter(|fills| !fills.is_empty()),
|
||
pattern_scale,
|
||
);
|
||
let images = if let Some(images) = paper_images {
|
||
images
|
||
} else {
|
||
self.images_for_viewport(inst.handle, &vp_frozen)
|
||
};
|
||
// 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_for_viewport(inst.handle, &vp_frozen)
|
||
};
|
||
|
||
// 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.
|
||
// The reuse fallback inside the gather is keyed per wire SOURCE — the
|
||
// sheet, a Model tile, a content viewport and the implicit view carry
|
||
// different glyph sets even at the same geometry epoch (#403). Paper
|
||
// sheets must also include their layout block: switching layouts does
|
||
// not change the geometry epoch, and a role-only key reused the prior
|
||
// sheet's glyph coordinates while its wires moved correctly. Tiles
|
||
// share the resident Model set, so they share one key; the implicit
|
||
// view mixes in the current layout block (BEDIT swaps sets without a
|
||
// geometry delta).
|
||
let text_source_key: u64 = if inst.tile_idx.is_some() {
|
||
0x1000_0000_0000_0000
|
||
} else if inst.paper_sheet {
|
||
0x2000_0000_0000_0000 | self.current_layout_block_handle().value()
|
||
} else if inst.handle == acadrust::Handle::NULL {
|
||
0x4000_0000_0000_0000 | self.current_layout_block_handle().value()
|
||
} else {
|
||
0x3000_0000_0000_0000 | inst.handle.value()
|
||
};
|
||
let draw_depths = if inst.render_mode
|
||
== acadrust::entities::ViewportRenderMode::Wireframe3D
|
||
{
|
||
Arc::clone(&self.no_draw_depths)
|
||
} else {
|
||
self.draw_depth_map()
|
||
};
|
||
let text_verts = self.gather_text_verts(
|
||
&all_wires,
|
||
wire_content_id,
|
||
text_source_key,
|
||
&draw_depths,
|
||
);
|
||
// Grip-drag / command-preview glyphs, excluded from the epoch-cached base
|
||
// gather above. Two sources, both tiny (one operation's worth) and walked
|
||
// per frame: the overlay wires' own glyphs (MOVE / COPY / ROTATE / SCALE /
|
||
// STRETCH / MIRROR ghosts carry text_verts) and `self.preview_text` (the
|
||
// grip-slide fast path, which emits bare glyphs with empty preview_wires).
|
||
// The two never overlap — a slide leaves preview_wires empty — so a plain
|
||
// concat is correct, no double-draw (issue #316).
|
||
let preview_text_verts = {
|
||
let mut pv: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||
for w in preview_wires.iter() {
|
||
if !w.text_verts.is_empty() {
|
||
pv.extend_from_slice(&w.text_verts);
|
||
}
|
||
}
|
||
if show_live_overlay {
|
||
pv.extend_from_slice(&self.preview_text);
|
||
}
|
||
Arc::new(pv)
|
||
};
|
||
// Stable per-viewport identity (tagged so tile / sheet / content /
|
||
// implicit-model instances never collide), so a reused pipeline slot
|
||
// can tell it changed occupant and reset its caches.
|
||
let instance_id: u64 = if let Some(t) = inst.tile_idx {
|
||
0x1000_0000_0000_0000 | (t as u64)
|
||
} else if inst.paper_sheet {
|
||
0x2000_0000_0000_0000
|
||
} else {
|
||
0x3000_0000_0000_0000 | inst.handle.value()
|
||
};
|
||
// A paper content viewport with a non-rectangular clip entity gets its
|
||
// boundary projected into this render target's NDC (paper shape mapped
|
||
// through the same visible-sub-rect crop as the content). Rectangular
|
||
// viewports, the paper sheet and Model tiles clip via their own render
|
||
// rectangle and need no stencil boundary.
|
||
let clip_boundary_ndc = if !inst.paper_sheet
|
||
&& inst.tile_idx.is_none()
|
||
&& inst.handle != acadrust::Handle::NULL
|
||
&& self.current_layout != "Model"
|
||
{
|
||
Arc::new(self.viewport_clip_boundary_ndc(inst.handle, uo, vo, us, vs))
|
||
} else {
|
||
Arc::new(vec![])
|
||
};
|
||
let navigating = !inst.paper_sheet && self.navigating_lod();
|
||
Some(ViewportData {
|
||
instance_id,
|
||
force_rasterize,
|
||
wires: Arc::downgrade(&all_wires),
|
||
clip_boundary_ndc,
|
||
preview_wires,
|
||
annotation_context_wires,
|
||
preview_hatches,
|
||
face3d_wires,
|
||
text_verts,
|
||
preview_text_verts,
|
||
draw_depth_generation: self.draw_depth_generation(),
|
||
draw_depths: Arc::downgrade(&draw_depths),
|
||
hatches,
|
||
wipeout_hatches,
|
||
images,
|
||
meshes,
|
||
background_image: display.background.image.clone(),
|
||
environment_image: display.background.environment.clone(),
|
||
uniforms,
|
||
view_dir: (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z),
|
||
cam_rotation: inst.camera.view_rotation_mat() * self.viewcube_ucs_mat(),
|
||
compass_rotation: inst.camera.view_rotation_mat(),
|
||
// Only the active viewport gets the hovered-region highlight.
|
||
hover_region: if inst.active { hover_region } else { None },
|
||
// The cube shows only on the active viewport, and only while the
|
||
// caller (the widget) says there is room for it beside the render
|
||
// bar — so it hides adaptively when the viewport gets narrow.
|
||
show_viewcube: inst.active && show_viewcube,
|
||
fill_mode: self.document.header.fill_mode,
|
||
view_wireframe,
|
||
show_2d_solid_fills: flags.show_2d_solid_fills,
|
||
mesh_fill: flags.mesh_fill,
|
||
show_3d_edges: flags.show_3d_edges,
|
||
display_silhouette: flags.hidden_line
|
||
|| display.visual_style.as_ref().is_some_and(|style| {
|
||
style.silhouette_width > 0 && style.edges_visible()
|
||
})
|
||
|| self.document.header.display_silhouette,
|
||
hidden_line: flags.hidden_line,
|
||
// 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: self.hatch_lod_enabled() && navigating,
|
||
skip_background: !inst.paper_sheet && self.current_layout != "Model",
|
||
geometry_epoch: self.geometry_epoch,
|
||
camera_generation: self.camera_generation,
|
||
wire_content_id,
|
||
wire_patch,
|
||
selected_handles: Arc::new(if show_interaction {
|
||
self.selected.iter().copied().collect()
|
||
} else {
|
||
rustc_hash::FxHashSet::default()
|
||
}),
|
||
hover_handles: Arc::new(if show_interaction {
|
||
self.hover_highlight_handles()
|
||
} else {
|
||
rustc_hash::FxHashSet::default()
|
||
}),
|
||
selection_generation: self
|
||
.selection_generation
|
||
.wrapping_mul(2)
|
||
.wrapping_add(u64::from(!show_interaction)),
|
||
selected_sig: if show_interaction { self.selected_set_sig() } else { 0 },
|
||
screen_rect,
|
||
})
|
||
}
|
||
|
||
/// Update viewcube hover state from cursor position within `bounds`.
|
||
///
|
||
/// 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.
|
||
pub(in crate::scene) fn update_viewcube_state(
|
||
&self,
|
||
state: &mut CameraState,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) {
|
||
let pos = cursor.position_in(bounds);
|
||
let cam_rotation = self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat();
|
||
if let Some(p) = pos {
|
||
let tile = self.active_model_tile_bounds(bounds.width, bounds.height);
|
||
state.hover_region = hover_id(
|
||
p.x - tile.x,
|
||
p.y - tile.y,
|
||
tile.width,
|
||
tile.height,
|
||
cam_rotation,
|
||
VIEWCUBE_PX,
|
||
);
|
||
} else {
|
||
state.hover_region = None;
|
||
}
|
||
}
|
||
|
||
pub(in crate::scene) fn viewcube_mouse_interaction(&self, state: &CameraState) -> mouse::Interaction {
|
||
if state.hover_region.is_some() {
|
||
mouse::Interaction::Pointer
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Linetype pattern helper ───────────────────────────────────────────────
|
||
|
||
pub(crate) fn resolve_pattern(
|
||
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;
|
||
}
|
||
|
||
// 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)
|
||
let mut pat = [0.0f32; 8];
|
||
let mut pat_len = 0.0f32;
|
||
for (i, el) in lt.elements.iter().take(8).enumerate() {
|
||
// positive = dash, negative = gap, exactly 0 = dot.
|
||
let v = el.length as f32 * scale;
|
||
pat[i] = v;
|
||
pat_len += v.abs();
|
||
}
|
||
if pat_len < 1e-6 {
|
||
return solid;
|
||
}
|
||
(pat_len, pat)
|
||
}
|
||
|
||
/// Whether a wire belongs to a Face3D entity, by document handle lookup —
|
||
/// so no changes to WireModel are needed.
|
||
fn is_face3d_wire(w: &WireModel, document: &acadrust::CadDocument) -> bool {
|
||
w.name
|
||
.parse::<u64>()
|
||
.ok()
|
||
.and_then(|v| document.get_entity(Handle::new(v)))
|
||
.map(|e| matches!(e, EntityType::Face3D(_)))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Partition a wire list into (face3d_wires, other_wires).
|
||
///
|
||
/// 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 {
|
||
if is_face3d_wire(w, document) {
|
||
face3d.push(w.clone());
|
||
} else {
|
||
others.push(w.clone());
|
||
}
|
||
}
|
||
(face3d, others)
|
||
}
|
||
|
||
// ── 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");
|
||
}
|
||
|
||
// 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");
|
||
}
|
||
|
||
#[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]);
|
||
}
|
||
|
||
#[test]
|
||
fn byblock_transparency_inherits_insert_alpha() {
|
||
let d = doc();
|
||
let mut entity = child("Other", Color::Index(3));
|
||
entity.common_mut().transparency = Transparency::BY_BLOCK;
|
||
let color = resolve(&d, &entity, [0.2, 0.4, 0.6, 0.25]);
|
||
assert_eq!(color[3], 0.25);
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_opaque_does_not_inherit_alpha() {
|
||
let d = doc();
|
||
let mut entity = child("0", Color::ByLayer);
|
||
entity.common_mut().transparency = Transparency::OPAQUE;
|
||
let color = resolve(&d, &entity, [0.2, 0.4, 0.6, 0.25]);
|
||
assert_eq!(color[3], 1.0);
|
||
}
|
||
}
|