- .github/workflows/release.yml: remove the build-flatpak job; matrix-
add macOS (arm64 + x86_64) that builds H7CAD.app by hand, renders
.icns from assets/logo.svg via librsvg + iconutil, and packages a
UDZO .dmg. AppImage path updated to packaging/H7CAD.desktop.
- packaging/H7CAD.desktop: moved out of flatpak/ so the AppImage job
doesn't depend on a Flatpak layout.
- packaging/Info.plist: macOS bundle template; __VERSION__ swapped
for the release tag at build time.
- flatpak/: deleted (manifests, metainfo, desktop, builder cache).
- src/main.rs: `#![cfg_attr(all(windows, not(debug_assertions)),
windows_subsystem = "windows")]` so the Windows GUI binary no
longer spawns a console window alongside it (debug builds keep
the console for log output).
- README: add macOS install section, document Gatekeeper / SmartScreen
on first launch since the binaries are unsigned.
Bump to 0.3.5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Home-ribbon Color / Linetype / Lineweight chips live on the
global `self.ribbon` and were never re-seeded from the active tab's
header, so opening a fresh file, creating a new tab, or switching
between tabs kept showing whatever the prior tab had picked. Layer
worked already because `self.tabs[i].active_layer` is per-tab.
Two halves of the fix:
1. When the user picks a new value from the ribbon with no entity
selection, persist it into that tab's `document.header`
(CLAYER + handle, CECOLOR, CELTYPE + handle, CELWEIGHT) and
mark the tab dirty. Switching back later reads the saved value
back out.
2. After every event that changes the active tab — `FileOpened`,
`TabNew`, `TabSwitch`, `TabClose` (including the
close-last-and-reset path) and `UnsavedDialogDiscard` — call
`sync_ribbon_from_selection()` so the chips track the new
tab's defaults (or its current selection).
Closes#21
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The acadrust fix from https://github.com/hakanaktt/acadrust/pull/27
unblocks files with corrupt hatch boundary-handle counts (anteen.dwg
went from >100 s "stuck" to ~0.7 s release). Until the PR lands on
upstream/main, [patch.crates-io] points at the fork branch carrying
that commit; the comment in Cargo.toml notes the revert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The greek-LOD text path emits no `points` and only `fill_tris`; the
face3d fill pipeline colours each triangle with `wire.color`. We were
storing the entity's own colour there, so selecting a text and then
zooming past the LOD boundary kept the wire.selected flag but the
on-screen colour dropped back to the entity hue.
Swap `wire.color` to `WireModel::SELECTED` when `selected` is set, the
same way `lod_stub_wire` already does it.
Follow-up to #19.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The stub wires emitted at low LOD carried wire.selected = true but
stored the entity's own colour, so the actual on-screen pixel kept
the entity hue. Selecting an object and then zooming past the LOD
boundary made the highlight vanish even though the entity was still
in the selection set.
Swap to WireModel::SELECTED at construction time, matching what
tessellate.rs does for full-LOD wires.
Follow-up to #19.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
At deep zoom-out, entities that projected to under ~5 px were dropped
from the scene entirely (and text below 2 px-baseline followed the
same path). The objects disappeared visually AND fell out of
window / crossing selection, and any prior selection highlight stopped
rendering once the zoom transition crossed the LOD threshold. #19.
Two changes:
1. `tessellate_entity`: replace the sub-5-px `return vec![]` cull, the
sub-2-px text-baseline `return vec![]`, and the empty-greek fall-out
with a new `lod_stub_wire` helper. The stub is a 2-point AABB
diagonal carrying the same `selected` flag, ACI, and AABB as the
entity — so the entity stays visible as a 1-pixel speck, tracks its
highlight colour across LOD changes, and remains hit-test'able.
2. `box_hit` / `poly_hit`: when a wire has no `points` but a finite
`aabb` (greek text emits only fill_tris), fall back to the AABB
rectangle as the hit-test shape. Defense in depth so future fill-
only wires don't silently drop out of selection.
Closes#19
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "round-trip metadata" fields (timestamps, GUIDs, file identity,
user-script scalars) were parsed and re-emitted on save but never
exposed anywhere, so users couldn't confirm what the file actually
carried. Add two command-line commands that read / edit them:
- DWGPROPS / DWGPROP dumps the header to the command-line output:
create / update / total-edit / user-elapsed timestamps,
fingerprint_guid, version_guid, code_page, menu_name, hyperlink_base,
project_name, stylesheet, required_versions, measurement (with a
human label), proxy_graphics, tree_depth, plus the five USERI / USERR
slots and the user_timer flag.
- USERI <1-5> <int> / USERR <1-5> <real> writes one of the five
document-scoped scalar slots and marks the tab dirty so the value
rides the next save. Lets users park drawing-scoped numbers in the
same place AutoCAD scripts have always used them, even though we
don't ship a LISP / DIESEL runtime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Property-panel coordinate display and block-insert scaling were both
hard-coded, ignoring the document's stored unit configuration.
- LUNITS / LUPREC: edit_prop() now formats values through a thread-local
UnitContext seeded by refresh_properties(). Decimal / Scientific /
Engineering / Architectural / Fractional all render correctly without
threading the document handle through every entity properties builder.
- AUNITS / AUPREC: format_angle() helper available (decimal degrees, DMS,
grad, rad). Callers that already format angular values via radians can
switch over without touching the helper signatures.
- INSUNITS + MEASUREMENT: xref's source INSUNITS is carried onto the
host BlockRecord.units during merge. commit_entity() then scales new
INSERTs so 1 source-unit maps to the host's INSUNITS length. When
either side is unitless (0) MEASUREMENT acts as the fallback
(0=Imperial / inches, 1=Metric / mm), matching AutoCAD's rule.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ACIS-bearing entities tessellated with a hard-coded LOD ladder
(circ_segs / grid_u / grid_v) so a high-detail drawing produced the
same coarse mesh as a low-detail one. Multiply the per-LOD counts by
`header.facet_resolution` (FACETRES), clamped to AutoCAD's
documented [0.01, 10.0] range with a floor of 4 segments to keep
degenerate values harmless.
All four call sites (build_caches, commit_entity mesh seed, undo/redo
populate_meshes_from_document, and the helper signatures themselves)
take a FACETRES argument now.
ISOLINES / surface_u_density / surface_v_density and the
camera / sun / geo header fields stay metadata until matching render
work lands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Home ribbon's creation-default chips were hard-coded to ByLayer
whenever the selection went empty. New entities therefore ignored the
file's saved CECOLOR / CELTYPE / CELWEIGHT and started fresh at ByLayer.
Pick those defaults up from header instead:
- sync_ribbon_from_selection() reads current_layer_name, current_entity_color,
current_linetype_{name|handle} and current_line_weight when nothing is
selected, mapping the i16 weight code through LineWeight::from_value.
- commit_entity() stamps header.current_entity_linetype_scale onto the
fresh entity's linetype_scale when it differs from 1.0.
ELEVATION / PAPER_ELEVATION / CMLSCALE / CMLJUST will follow with the
commands that consume them; PLINEGEN already routes through the per-
polyline bit at render time.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When a fresh document hasn't tessellated yet (no wire AABB cache,
no per-entity key vertices found), compute_model_space_extents()
returned None, so ZOOM EXTENTS / auto_fit_viewport had to wait for
the next pass before they could fit. Use the header's saved
model_space_extents_min/max as a last-resort answer so the very first
ZOOM EXTENTS / fit-all after open lands on the drawing immediately.
LIMMIN/LIMMAX and INSBASE remain read-only metadata for now — they
need a bounded-grid mode (LIMMIN/LIMMAX) and a "save as block" anchor
(INSBASE) before they map onto a visible behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header system variables that change how the file renders were being
ignored. Wire them through the render path:
- PDMODE + PDSIZE: Point entities draw the requested glyph shape
(dot / + / × / | with optional enclosing circle and square) sized by
PDSIZE. Default PDMODE 0 keeps the single-vertex fast path.
- ATTMODE: INSERT attribute rendering now respects 0=Off (no attribs),
1=Normal (per-attrib invisible flag), 2=On (force all visible).
- FILLMODE: when false, hatch / wipeout / face3d-fill uploads are
short-circuited so the renderer draws wireframe only.
- LWDISPLAY: when false every entity falls back to the 1-pixel base
width, matching AutoCAD's "Show Lineweight" toggle.
- MIRRTEXT: when false, MIRROR keeps text / mtext / shape rotation +
oblique angle (so text stays right-reading) while still mirroring
position.
DISPSILH / PLINEGEN were also reviewed: render path already honours
the per-polyline plinegen bit and we don't yet render Solid3D
silhouettes, so the header values are read for round-trip but produce
no extra effect here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes out the 3D-entity audit. Mesh (modern SubD mesh) gained a full
render impl since it had no TruckConvertible / Grippable / PropertyEditable
hookup at all — it was silently dropped on open. Remaining mesh-style
entities pick up the few fields the parser populated:
- Mesh (SubD): wireframe + face-fan fill_tris rendering, per-vertex grips,
Properties panel surfaces version, subdivision_level, blend_crease,
creased-edge count, vertex / face / edge counts; wired into the
Transformable / Grippable / PropertyEditable / TruckConvertible
dispatch in entities/traits.rs
- Polyline3D: smooth_type, default_start_width, default_end_width,
mesh_m_count, mesh_n_count, smooth_m_density, smooth_n_density
(widths editable)
- PolygonMesh: smooth_type, m/n_smooth_density, elevation, normal
- PolyfaceMesh: smooth_surface, seqend_handle, elevation, normal,
thickness
Solid3D / Region / Body's uid + silhouettes + history_handle were
included in the prior 2D commit since they share the same file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MultiLeader and Table were ignoring most of the styling and content
fields that the parser populated. Bring both up to file-driven render:
MultiLeader
- line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback
- line_type_handle: dashed leader via line_types lookup by handle
- line_weight (ml.line_weight): per-leader pixel width override
- arrowhead_handle: arrow shape resolved through the dim arrow emitter
(Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None)
- extend_leader_to_text: continue the leader past the landing to the
text insertion point
- text_angle_type: Horizontal forces 0, Optimized clamps to a readable
half-plane, ParallelToLastLeaderLine keeps the stored direction
- text_direction_negative: adds π to the rotation
- text_alignment: drives the horizontal anchor when more specific than
the context's text_attachment_point
- text_attachment_direction + text_top_attachment + text_bottom_attachment:
Vertical mode uses the top / bottom attachment values chosen from the
first root's direction.y sign
- has_block_contents + block_content_handle + block_content_location +
block_rotation + block_scale + block_content_color: synthesise an
Insert and explode it through the standard tessellator so block-
content MultiLeaders render their referenced block
- style_handle, arrowhead_handle, line_type_handle, text_style_handle,
block_content_handle, property_override_flags, text_align_in_ipe,
block_scale, block_rotation, extend_leader_to_text,
text_direction_negative, text_top_attachment, text_bottom_attachment
surfaced in the Properties panel
Table
- Per-cell borders: drop the always-grid pass, walk the cells and
emit each border honouring its `invisible` flag with dedup so shared
edges aren't doubled
- table_style_handle, block_record_handle, data_version, value_flags,
override_flag / override_border_* flags, break_options /
break_flow_direction / break_spacing, normal surfaced in the
Properties panel
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inserting a block with attributes (ATTRIB entities attached to the
INSERT) drew only the block's primary geometry — the attribute values
the user filled in were silently dropped. block_cache::expand_insert
walks the block's BlockRecord which lists the ATTDEF templates, not the
per-INSERT ATTRIB instances, so they were never tessellated.
Add append_insert_attribute_wires: after expanding the block sub-wires,
iterate ins.attributes, skip the entries flagged invisible, and emit a
text wire for each at its stored WCS position. The wire's name reuses
the parent INSERT handle so selection / picking still treats the block
and its attribute text as one entity. Runs on both the block-cache
fast path and the legacy explode fallback.
Closes#20
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AttributeDefinition / AttributeEntity were rendering as left-aligned
single-line text regardless of their stored alignment, generation flags,
or multiline settings. Bring both to parity with Text plus expose every
remaining field through the Properties panel:
- horizontal_alignment / vertical_alignment with bounds-based anchor
using alignment_point, mirroring Text's logic
- text_generation_flags bit 2 (backward) flips width_factor sign; bit 4
(upside-down) adds π to the rotation; combined with the TextStyle
flags via XOR (double-mirror cancels)
- multi-line splitting on \n / \P with line_count surfaced as info
- mtext_flag, is_multiline, line_count, lock_position, field_length,
AttributeFlags bits (invisible / constant / verify / preset /
annotative) surfaced as properties
- AttributeEntity.attdef_handle shown as the definition reference
- flags.constant marks the default-value field read-only
- flags.locked_position / lock_position blocks the position grip
Shape entity now applies rotation, relative_x_scale, and oblique_angle
to its placeholder diamond marker, and surfaces shape_number,
style_handle, thickness, and normal in the Properties panel.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes out the file-driven dim render. Remaining entity fields and the
last handful of DimStyle knobs are now honoured:
- leader_length: extend the radius/diameter dim line past the arrow tip
toward the text by this distance
- ext_line_rotation: rotate extension lines off perpendicular (DIMEDIT
Oblique result) for Linear / Aligned dims
- attachment_point: map the 1..9 grid to Text horizontal / vertical
alignment so the saved anchor is used
- horizontal_direction: use as in-plane text reading direction override
- DIMTXSTY by handle: prefer dimtxsty_handle over the name; survives
text-style renames
- DIMTFAC + DIMTOLJ: tolerance text rendered as a separate Text entity
scaled by DIMTFAC and aligned vertically by DIMTOLJ
- DIMALTTD + DIMALTTZ: alternate-units tolerance suffix formatted with
these decimal-place / zero-suppression knobs
- DIMARCSYM / DIMJOGANG / DIMUNIT / normal / line_spacing_factor /
insertion_point / block_name / version: read so the file round-trips
but no-op in the render path until the matching dim variants / 3D
text plane are supported
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picking a layer / color / linetype / lineweight from the Home ribbon was
only updating the creation default — selected entities stayed unchanged.
DWG editors uniformly treat these dropdowns as edit controls when an
entity is selected.
Route the ribbon Layer / Color / Linetype / Lineweight handlers through
the same property_target_handles + apply_common_prop / apply_color /
apply_line_weight path the Properties panel uses. With no selection the
old behaviour (change creation default) is preserved.
Follow-up to #17.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
H7CAD was subscribing to window::frames() unconditionally, so iced kept
firing Message::Tick at the display refresh rate even when nothing on
screen was changing. That re-ran update() and the view-tree builder ~60
times a second and showed up as 2-3% idle CPU on Windows.
Gate the subscription on `self.opening.is_some()` (the only thing that
actually needs per-frame redraws today — the file-open progress
indicator). Camera-change detection moves with the events that drive it
since user input already wakes iced.
Closes#18
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Selecting an entity refreshed the Properties panel but the Layer / Color /
Linetype / Lineweight dropdowns kept showing the creation-time defaults
— so they couldn't be used to inspect or edit a single object's settings.
refresh_properties() now also calls sync_ribbon_from_selection(), which
walks the current selection and mirrors common values into ribbon.active_*
fields. Mixed selections leave the prior value in place (no "*Varies*"
chip yet); an empty selection restores per-tab creation defaults.
Closes#17
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tessellation and DIMBASELINE pick up the few DimStyle fields that were
still hard-coded or ignored:
- DIMTMOVE = 1 draws a connector segment from the dim-line anchor to the
saved text_middle_point when the text has been moved
- Leader arrows are resolved via the leader's DIMSTYLE → DIMLDRBLK; the
shape (closed-filled / open / dot / tick / …) is drawn through the same
arrow emitter the dim path uses, including fill_tris for filled blocks
- DIMBASELINE picks up DIMDLI from the active DimStyle instead of the
hard-coded 1.5 increment
- DIMTOFL / DIMTIX / DIMATFIT / DIMUPT / DIMTXTDIRECTION are read so the
fields round-trip on save; their effect is at dim creation, not render,
so they're acknowledged with a no-op block
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drives more of the dim render off the parsed DimStyle so files round-trip
visually identical to AutoCAD instead of using hard-coded fallbacks:
- text format dispatch via DIMLUNIT (Sci / Decimal / Engineering /
Architectural / Fractional / Windows) + DIMFRAC fraction denominator
- alternate units appended in brackets per DIMALT / DIMALTF / DIMALTD /
DIMALTU / DIMALTRND / DIMAPOST / DIMALTZ
- tolerance / limits text per DIMTOL / DIMLIM / DIMTP / DIMTM / DIMTDEC /
DIMTZIN; "value ± t", "+tp / -tm", or stacked "high/low"
- center mark for radius/diameter from DIMCEN (positive = "+", negative =
"+" plus radial strokes)
- DIMDLE dim-line overshoot at tick endpoints
- DIMJUST horizontal text slide along the dim axis
- DIMTVP perpendicular text offset multiplier when DIMTAD=0
- DIMFXL/DIMFXLON fixed extension-line length override
- DIMTFILL/DIMTFILLCLR text background fill rectangle
- DIMLTEX/DIMLTEX1/DIMLTEX2 linetype handles resolved per-wire; ext1 and
ext2 split into separate wires when their patterns differ
- DIMSOXD read; left as a no-op until autofit is added
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dimension tessellation was hardcoding arrow shape (open-V line pair) and
text format ("{:.4}"), ignoring almost every DimStyle field the parser
populated. Drives the wire/text geometry from the resolved DimStyle:
- text: DIMDEC, DIMZIN, DIMLFAC, DIMRND, DIMDSEP, DIMPOST, DIMADEC,
DIMAZIN, DIMAUNIT (decimal/DMS/grad/rad), DIMTXSTY
- arrows: DIMBLK/DIMBLK1/DIMBLK2/DIMSAH resolution against block_records,
supporting ClosedFilled (default), Closed/Closed-Blank, Small, Open
(30/90), Dot (small/blank), Origin, Oblique/ArchTick, Box, Datum, None;
DIMTSZ overrides to oblique ticks
- colours: DIMCLRD / DIMCLRE / DIMCLRT split into three wires (ext, dim,
text); BYBLOCK/BYLAYER fall through to entity colour
- weights: DIMLWD / DIMLWE drive line_weight_px per wire
- suppress: DIMSE1/DIMSE2 (ext) and DIMSD1+DIMSD2 (dim) honoured
- text placement: DIMTAD vertical offset using DIMGAP + text height when
text_middle_point is unset; DIMTIH/DIMTOH force horizontal text
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`viewport_content_wires` cloned model-space tessellations and overwrote
`points` with paper-space projections (× scale) but left `pattern_length`
and `pattern` at their model-coord values (pre-multiplied by PSLTSCALE's
1/vp_scale). The GPU then compared a paper-coord distance against a
model-coord pattern, so dashed linetypes in viewport content collapsed to
solid (a typical 9 mm paper line fell inside the first 180 mm dash).
Multiply the projected wire's pattern by the same vp scale used for the
points. Works for both PSLTSCALE on (paper-uniform dashes) and off
(dashes shrink with viewport scale).
Adds two diagnostic examples used to track this down:
- inspect_lt: which layers/entities carry dashed linetypes
- inspect_block: enumerate entities + linetypes inside a named block
Tracks planned work for cutting file open time and per-frame render
cost: single-pass entity walk, incremental wire cache, batched wire
pipeline, hardware-instanced block inserts, and profiling spans.
Unignore ROADMAP.md so it's tracked.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4-B (batched hatch pipeline) is in; Phase 4.1 (multi-draw
indirect / GPU compute cull) stays as a deliberate non-goal because
iced 0.14 doesn't expose the required wgpu features to widget
pipelines, and the single-draw batched path already collapses N
bind-group swaps + N draw calls into one. Phase 4.2 (Hi-Z occlusion)
only matters for perspective 3D, which H7CAD doesn't ship.
The doc is now a short stub — the per-phase rationale lives at the
call sites (search for "Phase N.X" comments).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The batched hatch pipeline has soaked through visibility + LOD; it's
correct on every file tested (Floor plan.dxf, UTM site plans, the
non-UTM regression case). Time to remove the parallel per-hatch
plumbing that's been sitting empty since step 3:
- `gpu_hatches: Vec<HatchGpu>` — never populated since
`upload_hatches` switched to the batched builder; dropped.
- `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped.
- `compute_hatch_scissors` — no longer has a consumer; dropped, with
the render-time call removed from `Primitive::prepare`.
- Hatch render pass — the dead `else if` branch over `gpu_hatches`
is gone; only the single batched `pass.draw(0..vertex_count, 0..1)`
remains.
- `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is
`None` (empty hatch list) it just returns early.
`HatchGpu` itself stays — the wipeout pipeline still uses it. So do
`hatch_pipeline` / `hatch_bgl1`, kept around for that path.
`#[allow(dead_code)]` on the four storage-buffer fields in
`HatchBatchedGpu` — they exist only to keep the bind group's
referenced resources alive; nothing reads them directly.
Known gap left as a follow-up: per-hatch viewport scissor for paper-
space MSPACE viewports isn't ported to the batched path yet. Hatches
inside a paper-space viewport can render past the viewport border
until that's added. Hasn't surfaced as a visible regression in the
test files so far.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a dedicated `visibility: array<u32>` storage buffer (binding 4)
alongside the existing batched hatch buffers. Vertex shader reads
`visibility[v.instance_index]` — 0 → emit an out-of-NDC clip
position so the GPU rasterizer clips the primitive before the
fragment stage runs.
`compute_hatch_lod` now drives this buffer instead of (or alongside,
for the per-hatch fallback) `hatch_skip_flags`. Walks a per-instance
CPU AABB mirror that the builder fills at upload time (`instance_aabbs`),
applies the same `aabb_below_pixel` (sub-pixel LOD) OR `aabb_offscreen`
(frustum cull) rule the legacy path used, and pushes the resulting
0/1 flags via `queue.write_buffer` on a separate 4 B-per-hatch buffer
— ~40 KB per pan tick for 10 k hatches, far cheaper than re-writing
the 112 B-per-instance data.
Step 5 will remove the legacy `gpu_hatches` Vec, the per-hatch draw
loop, and `hatch_skip_flags` once the batched path has soaked.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues in the step-3 batched path surfaced during testing:
1. WGSL rejects compile-time NaN literals (`0.0 / 0.0`), so the
visibility-skip path that emitted a NaN clip position failed
`Device::create_shader_module` validation. Replaced with an
out-of-frustum clip position `(2, 2, 2, 1)` — the GPU rasterizer
clips the primitive and the fragment shader never runs.
2. Pattern hatches rendered with the wrong spacing / direction. The
batched builder was rotating `perp_step` / `along_step` into the
global frame, but the per-hatch shader (`hatch.wgsl`) expects them
in QCAD PAT local-frame convention: `perp_step = family.dy`,
`along_step = family.dx`, with the cos_off/sin_off rotation applied
inside the shader. Now matches `build_family_batch` from
`hatch_gpu.rs` line-for-line. `line_width` also reset to 0 (the
shader picks 1 px from a screen-space derivative; the stored field
is unused).
3. Padding's `max_spacing` likewise dropped its bogus rotation and
now uses `|dy|` per family, matching the new perp_step convention.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pipeline carries a new `hatch_batched_pipeline` + matching `bgl1`
alongside the existing per-hatch pipeline. `upload_hatches` now packs
the renderable hatch list into the single `HatchBatchedGpu` and
clears `gpu_hatches`; the draw loop prefers the batched path when
that resource is set, falling back to the per-hatch loop only when
build returned `None` (empty list).
One draw call replaces N — for a 100-hatch frame that's a 99-call
saving plus 99 fewer `set_bind_group(1, ...)` / `set_vertex_buffer`
pairs.
Caveats acknowledged for step 4 cleanup:
- `compute_hatch_lod` still writes into `hatch_skip_flags`, which the
batched path ignores. Step 4 will push the same flags into the
per-instance `visible` field on the GPU instance buffer so the
sub-pixel + frustum cull keeps working.
- Per-hatch viewport scissors (paper-space MSPACE) aren't ported yet;
the legacy per-hatch path still serves that case when the batched
build is skipped, but a model-space → paper-space transition keeps
the batched buffer intact and may render hatches outside the
intended viewport. Will be addressed alongside visibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Companion to the storage-buffer layout from step 1. Vertex shader
reads `(corner, instance_index)` per vertex, fetches the matching
`HatchInstance`, projects an AABB-quad corner; a zero `visible` flag
emits a NaN clip position so the GPU skips the fragment stage for
culled hatches without any per-frame buffer re-upload.
Fragment shader runs the same three-stage in-polygon → mode dispatch
→ pattern evaluation pipeline as `hatch.wgsl`, but with all ranges
indexed: `BoundaryBuffer[boundary_offset..offset+count]` for the
ray-cast, `FamilyBuffer[family_offset..offset+count]` for pattern
lines, `DashBuffer[dash_offset..]` for dash sequences. Phase 3.3
sub-pixel LOD substitution is preserved.
Step 3 will wire a pipeline + bind-group-layout around this and call
`upload_hatches_batched` from render.rs (the per-hatch
`upload_hatches` path stays in place for the bisect window).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stand-alone module — no consumers yet. Packs an entire `&[HatchModel]`
into four storage buffers + a per-vertex (corner, instance_index)
stream:
- `InstanceBuffer` — `HatchInstance[]` (112 B each): color, mode,
gradient params, world_origin, local-space aabb, boundary range,
family range, visibility flag.
- `BoundaryBuffer` — flat `vec4<f32>[]` (NaN markers preserved as
per-path separators, just like the per-hatch shader expects).
- `FamilyBuffer` — flat `LineFamilyGpu[]` (matches the per-hatch
family layout one-for-one apart from `dash_offset` indexing into a
separate dash buffer).
- `DashBuffer` — flat `f32[]`.
The per-vertex buffer holds `6 × instance_count` records of
`(corner: u32, instance_index: u32)`. Step 2 will add the matching
WGSL shader that uses these to project a per-instance AABB quad and
run the in-polygon test against `BoundaryBuffer[offset..offset+count]`.
iced reports 8 storage buffers per stage, 128 MB binding cap, 256 MB
buffer cap — plenty for ~10 k hatches × 112 B + ~32 MB boundaries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`len` / `is_empty` / `remove` / `update` are exercised by unit tests
and are part of the quadtree's public surface, but no production
caller exists yet — Scene::add/erase/transform still trigger an
epoch-based full rebuild rather than mutating the index in place.
Annotating them with `#[allow(dead_code)]` keeps the API intact for
the incremental-update follow-up without spamming the release build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: open a non-UTM drawing, most lines/polylines/hatches/etc.
vanish — only ~5% of indexed entities reachable from a huge query
(walking the nodes flat showed all 2975 items present, but recursing
through `node.children` from root reached only 161).
Root cause: when straddler items piled up at an INTERNAL node and the
count exceeded LEAF_CAPACITY, `insert()` re-invoked `split()` on that
node. `split()` allocates four fresh children and assigns them to
`node.children = Some([...])` — overwriting the existing children
pointer and orphaning the entire previous subtree (the descendant
nodes still hold their items; nothing reaches them).
Splitting an internal node is futile anyway: straddlers don't fit
smaller children either. Fix: gate the split on
`children.is_none()` so only leaves split. Internal nodes accumulate
straddlers as a linear-scan list — slower per-query for big strad-
dler counts, but correct.
Adds a regression test (`many_straddlers_dont_orphan_subtree`) that
floods an internal node with straddlers and verifies a huge query
still returns every inserted handle.
Bonus: `examples/dump_index_stats.rs` — standalone tool that loads a
DWG/DXF and dumps per-type counts (indexed / unbounded / top-level /
block-internal) plus a sample of bboxes. Useful for future bug
triage; built and ran cleanly against the file that exposed this.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2.1's wire path was silently dropping any entity whose
`bounding_box()` returns the degenerate default (or non-finite
coords). The legacy `entity_aabb` mapped those to the
`UNBOUNDED_AABB` sentinel, which `tessellate_entity` treated as
"render unconditionally". The new path filtered them at index time
(no bbox → no quadtree entry) AND `is_unindexable_entity` only
matched type variants (Insert/Viewport/Block/BlockEnd), so those
entities fell through neither path.
Symptom: opening a non-UTM file showed the floor plan with most
walls / dimensions / hatching dropped while isolated entities (text,
circles) still rendered.
Fix: `Scene::entity_index()` now returns an `EntityIndex` carrying
both the quadtree and a parallel `unbounded_handles: Vec<Handle>` of
entities whose bbox came back degenerate. The wire path always emits
that list in addition to the view-rect candidates, mirroring the
legacy "no bbox → never cull" behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`HatchGpu.world_aabb` was built by iterating `model.boundary` — but
those vertices are stored as f32 offsets from `model.world_origin`
(precision anchor for UTM-scale WCS). So the AABB was in
*origin-relative* space, not the local space `view_proj` projects
from. `aabb_offscreen` (Phase 2.3) then asked "is this rect near (0,0)
in NDC?" instead of "is this rect at the hatch's actual position?" —
which culled in-view hatches whenever the camera moved off the
origin and rendered out-of-view ones near it.
Sub-pixel LOD (`aabb_below_pixel`, Phase 3.3) survived because it
measures a projected diagonal, which is translation-invariant.
Fixes:
- Add `world_origin` back when building `HatchGpu.world_aabb`, so the
stored rect is in absolute local space.
- 25 % margin on the viewport rect in `aabb_offscreen`, matching the
wire path's `view_world_aabb` margin — keeps hatches at the edge
rendered while panning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2.1/2.2/2.3 landed across the previous commits:
- 727992b quadtree primitive
- 3e87460 Scene::entity_index lazy build
- e43498d wire tessellation uses query_rect
- fcf4d4f mesh draw-time frustum cull
- b70c9f9 hatch + wipeout draw-time frustum cull
What was skipped on purpose:
- Octree-with-Aabb3: meshes only carry XY world_aabb today; the
pragmatic adaptation is a 4-corner projection test in
`compute_mesh_lod`. Revisit if a true 3D viewport mode lands.
- Per-mutation incremental quadtree updates: `entity_index_cache` is
geometry_epoch-keyed and rebuilds lazily on next query. Full
rebuild is ~50 ms on a 100 k-entity doc — acceptable for occasional
edits; promote to incremental updates if profiling shows the
rebuild cost.
Phase 4 (GPU-side culling) remains the next big lever.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 2.1 step-4 attempt at culling hatches inside
`synced_hatch_models` was wrong: GPU hatch/wipeout buffers are
uploaded on geometry_epoch only (see render.rs — they're "static
buffers"), so any CPU-side cull at build time would freeze the
visible set at the geometry epoch boundary and never re-evaluate on
pan. Reverted that approach; the build-time function now returns the
full visible set again and the cache key is back to geometry_epoch.
The actual per-frame frustum cull moves into the existing draw-time
skip-flag machinery:
- `compute_hatch_lod` now also folds in `aabb_offscreen` (ORed with
the existing Phase 3.3 sub-pixel skip), so out-of-view hatches are
skipped at the draw call.
- New `compute_wipeout_lod` + `wipeout_skip_flags` mirror the hatch
path. The wipeout draw loop honors the flag.
- `Primitive::prepare` calls the new compute step alongside the rest.
Per-frame projection + skip is a few hundred microseconds on dense
docs; far cheaper than re-uploading GPU buffers per pan tick.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 2.1 step-4 change made `synced_hatch_models` and
`wipeout_models` view-cull through `entity_index().query_rect`, but
left both caches keyed on `geometry_epoch` alone. Cache hit would
return a list culled to a previous camera's view, dropping hatches as
the user panned. Now keyed by `(geometry_epoch, camera_generation)`
— same pattern as `wire_cache`.
`image_cache` stays geometry-only: images self-cull per frame via
`vp_scissor` in the GPU pipeline; the cached list is camera-agnostic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Roadmap calls for an Aabb3 octree, but H7CAD's mesh entities only
carry XY world_aabb (Phase 3.4 stored XY for the LOD selector and
never extended to Z) and the camera is orthographic top-down for
every realistic workflow. So Phase 2.2 here becomes a draw-side cull:
`compute_mesh_lod` now also populates a `mesh_visible: Vec<bool>`
flag using the same 4-corner projection it already does for LOD
picking, and the mesh render pass skips draws whose projected AABB
sits entirely outside the viewport rect.
Mesh quadtree-indexing is still in place from Phase 2.1 (Solid3D /
Region / Body are not in `is_unindexable_entity`, so they're inserted
into `entity_index`). When mesh entity counts grow large enough that
the upload-time scan dominates over per-frame draws, Phase 2.3 can
plug `query_rect` into `upload_meshes` too.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`synced_hatch_models` and `wipeout_models` now pre-filter their
per-entity scans through `entity_index().query_rect(view)` when a
Model-layout view AABB is available. Affects:
- Top-level hatch HashMap iteration (the dominant cost — clone +
render_style + selection tint per hatch).
- Wide LWPolyline / Polyline2D solid-fill loop.
- Wipeout entity scan.
The Insert→exploded-hatch loop is left alone; Insert is not
quadtree-indexed (its WCS bbox depends on block defn × insert
transform, handled inside block_cache).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>