The application has no support-file search path — the four folders it uses
are functions returning fixed paths, not settings. Rather than build a Files
page that pretends otherwise, this one answers the question people actually
open it to ask: where does this thing keep my plot styles. Each row shows the
location and opens it in the file manager, and a row whose path the platform
could not give says so with its button disabled.
General gains buttons through to the plugin manager, the keyboard shortcut
editor and the alias editor. Those three have good dialogs of their own and
are not absorbed; Options simply stops being a place you cannot reach them
from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ViewCube, the UCS icon, its at-origin placement and selection cycling all
have commands and status-bar pills, and all four kept their value only on the
app struct. Turn the ViewCube off with NAVVCUBE, restart, and it is back.
Nothing failed — the setting evaporated — so a checkbox for any of them would
have been a checkbox that forgets. They are persisted first, with a
round-trip test, and only then given controls.
The new 3D Modeling page carries the three view tools plus the drawing
variables that decide how solids are tessellated: ISOLINES, DISPSILH, SURFU,
SURFV, SURFTYPE, SOLIDHIST and SHOWHIST. Those are saved in the DWG rather
than in preferences, so the page says so beside each group and hides them
entirely when no drawing is open.
Changing them also rebuilds the geometry. The SETVAR path never did, which is
why the comment beside LTSCALE's invalidation exists — the same reasoning
applies here, and without it a new isoline count does not appear until some
unrelated edit rebuilds the solid.
Selection cycling joins the Selection page next to the other two selection
modes, since that is what it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two drafting preferences had no proper home. The Drafting Settings dialog
displays the drafting rotation and offers only a Reset button — there was no
way to type an angle short of `SETVAR SNAPANG`. The polar tracking increment
existed in exactly one place: a pop-up hanging off the status-bar pill.
The rotation is a text field over an edit buffer rather than the value
itself, so clearing it to retype does not snap the crosshair to zero on the
way through; the buffer is reseeded whenever the value is restored from
behind it. Angles render without a trailing `.0`, matching how the polar
pop-up has always shown them — same kind of number, same reader.
Snap modes, grid spacing and object snap stay in Drafting Settings, where
they belong; the page links to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zoom direction and factor, whether TEXTEDIT ends after one object, whether
continued dimensions inherit the base dimension's style, which points QDIM
measures from, and which annotative objects pick up a newly set scale. All
six work today; none had a control.
Two of them needed their meaning worked out rather than copied.
ANNOAUTOSCALE stores the mode in the magnitude and on/off in the sign, and
the four modes are not documented anywhere in the app — the command prompt
just says `[-4..4]`. Reading `add_annotation_scale_to_objects` gives them:
1 skips layers that are off, frozen, locked or frozen in the viewport, 2
keeps the locked ones, 3 skips only locked, 4 takes everything. The dropdown
names them instead of showing bare numbers, and choosing Off negates rather
than zeroing, so the mode survives being switched off and back on — which is
the convention the status-bar pill already follows.
The QDIM origin priority had no system variable at all; it could only be
reached through a keyword inside the running command.
Drawing units stay in their own dialog; this page links to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three settings that work, persist, and could only be reached by typing their
name: TEXTFILL, CLIPROMPTLINES and COMMANDLINEFADETIME. The last two govern
how much of the command window is prompt and how long its history lingers —
the kind of thing people fiddle with once and then leave, which is exactly
what a settings dialog is for.
One fix rides along. TEXTFILL is a process-global, so changing it invalidates
the text in every open drawing; the SETVAR path rebuilt only the active tab,
leaving the others rendering the previous setting until an unrelated edit
happened to rebuild them. Both paths now go through
`invalidate_text_everywhere`, so the checkbox and the command cannot disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
General had become three unrelated controls: a language picker, a save format
and a file association. The last two move to an Open and Save page, where
they join the two saving preferences that have never had a widget.
Automatic save has worked since the SAVETIME command was added and is
persisted; the only way to change the interval was to type it. Same for the
`.bak` copy from #205 and its ISAVEBAK command. Both are settings people
expect to find in a dialog, and neither was in one.
The page also carries a button through to the Plot dialog. Options holds the
preferences; the plot device, paper and plot styles stay where they already
work well. Note that the modal stack shows one dialog at a time, so the
button replaces Options rather than stacking on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog laid its pages out as a row of buttons across the top, which is
what AutoCAD does and what fits while there are four of them. The pages this
series adds take it to nine, and the names are translated into 21 languages —
a row of nine German or Russian page names does not fit at any dialog width
worth having.
The rail is now a vertical list down the left, the dialog is 880×620 rather
than 540×560, and the content pane scrolls on its own.
Which page was open is now remembered. With four pages, reopening on General
was a shrug; with nine it means hunting for the one you were last in.
The ribbon's Options tool has existed all along — `ToolDef`, icon and all —
but was marked `#[allow(dead_code)]` and never added to a group, so the
dialog could only be reached by typing OPTIONS or from the start page. It
now sits in Manage ▸ Application, beside About.
No settings change in this commit; it is only the shell the rest needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Repurpose CircleInstance center_high.w and params.w for start_width and end_width (preserving exact 128-byte stride)
- Route wide polylines, tapered polylines with bulges, and donuts through split_mixed_polyline into GPU analytical arc instances
- Compute physical annular bands, width tapers, and round end caps in circle.wgsl using world-space distances to prevent 3D perspective foreshortening distortion
- Expand bounding quad margins dynamically with grazing-angle compensation to eliminate clipping
- Relax pick_tris check in wire classification and partition routines so wide entities retain CPU picking while drawing analytically
- Add unit and hardware GPU integration tests, including tilted 3D perspective rendering verification
The ten strings the Options card gained were English in nineteen of the
twenty-one locales. They are now translated in all of them, using the words
each locale already uses for the surrounding labels — de-DE "Griffe",
fr-FR "Poignées", zh-CN "夹点", zh-TW "掣點" and so on for grips, and the
same for selection, preview and object. System-variable names stay in Latin
uppercase, as they already do in every locale's SETVAR listing.
Composing them from existing keys, the way el-GR composes the neighbouring
labels, only worked for one of the ten: there is no existing key for "limit",
"unlimited", "shift", "rectangle" or "lasso", and "Grips: Objects" as the
label of a numeric limit would read worse than English.
The rollover preview was reported as possibly not working. It works, and now
says so: `the_idle_rollover_follows_bit_one` drives a settled hover over a
line and checks what the scene highlighted, for all four values of the
bitmask. Bit 1 alone highlights, bit 2 alone does not, both do, neither does
not. That is the assertion the inverted config comment would have made
someone doubt — the pick simply needs the cursor still for the 500 ms of
`HOVER_DWELL_MS` before it runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Use the maintained cadkernel main revision and retain source surfaces. Verify signed and two-point input, partial failure, DWG round-trips and one-step undo/redo of solids and history objects.
Options → Selection already carried the pick box, the selection-area effect
and the grip visuals. Four settings behind it were finished, persisted and
reachable only by typing a system variable, and one more was a constant.
**Four that only needed a widget.** `SELECTIONEFFECT`, `SELECTIONPREVIEW`,
`PICKADD` and `PICKDRAG` are all wired end to end already. `SELECTIONEFFECT`
was the odd one: the card's own "Restore Defaults" button resets it, so it
could be turned off by a button and only turned back on from the command
line.
`SELECTIONPREVIEW` is a bitmask and gets one checkbox per bit, which is why
`set_preview_bit` exists rather than two inline expressions — the bits are
the thing this change is most able to get backwards.
The `PICKADD` checkbox is worded as AutoCAD words it, "use Shift to add to
selection", which is the *inverse* of the stored flag. That inversion is
commented at both ends; it reads as a bug otherwise.
**`GRIPOBJLIMIT`.** The limit past which a selection gets no grips arrived
with the selection work as a hardcoded 100, matching AutoCAD's default. It is
now a setting, a slider and a system variable, following `PICKBOX`'s path
exactly. AutoCAD's 0 means "no limit", which the old `>` comparison could not
express, so the read now guards on it — with a test, because zero is the one
value that would otherwise suppress every grip instead of allowing all of
them.
The slider covers 0..=1000, where every useful value lives; the sysvar takes
AutoCAD's full 0..=32767, and a larger value set from the command line parks
the handle at the end while the read-out keeps showing the real number.
**`SelectionPrefs`** replaces the `pick_box` parameter rather than adding
three more. `view_window` already took seventeen positional arguments, and
`pick_add` / `pick_drag_rect` are two adjacent `bool`s that would swap
silently at the call site.
**Two fixes found on the way.** `OPTIONS` and `OP` were registered for
autocomplete but never dispatched, so typing either answered "Unknown
command" and the dialog could only be opened from the start page. And the
`SELECTIONPREVIEW` doc comment had its bits the wrong way round — the code
reads `& 1` for the idle rollover and `& 2` for the in-command one, as
AutoCAD does — which would have put the two new checkboxes' labels backwards.
**Not done, deliberately.** `PICKFIRST` and `PICKAUTO` exist but are always
on, with no variable. `PICKSTYLE` is stored in the header and read by nobody
— group expansion decides from the group's own `selectable` flag — so an
"object grouping" checkbox would control nothing. Associative-hatch selection
does not exist as a selection behaviour. Each needs a behaviour change rather
than an exposure.
Of the ten new strings, en-US and el-GR are translated; the other nineteen
locales carry the English text. Composing them from existing keys, the way
el-GR composes the neighbouring labels, only works for one of the ten —
there is no existing key for "limit", "unlimited", "shift", "rectangle" or
"lasso", and "Grips: Objects" as the label of a numeric limit would be worse
than an untranslated string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolve curve intersections through cadkernel, retain empty exact results, gather analytic candidates independently of tessellation, and bound material uploads. Preserve large MINSERT data while limiting rendering work; remove obsolete review narratives.
Preserve main's validation, atomic bulk operations, profile planes, kernel geometry, transport limits and complete Python bindings. The submitted implementation reverted those existing fixes; retain its documentation intent without restoring the regressions.
github.com/HakanSeven12/OpenCADStudio discussion #1052: constructing a
3-4-5 right triangle by drawing two circles (radius 3 and 4, centers 5
apart) and using OSNAP Intersection to find their crossing points gave
measured side lengths that didn't match the entered radii.
OSNAP's Intersection snap found candidate points by sweeping line
segments from each wire's *tessellated* polyline approximation, never
the entity's real geometry. Circles feeding the resident wire set
always tessellate at a fixed 48 segments (Scene::resident_wires_for
passes wpp=None deliberately, to avoid re-tessellating on every camera
pan/zoom) regardless of radius or zoom — so every circle/arc/ellipse
intersection snap was already off by construction, not just at extreme
zoom levels. Arcs, ellipses, and splines have the same problem via
their own fixed-angle tessellation.
Added an exact intersection path that bypasses tessellation entirely,
used by both call sites in the Intersection-snap code (the indexed/
local fast path and the brute-force wire-pair fallback), falling back
to the original tessellated sweep for anything it can't resolve:
- exact_circle_intersections: closed-form circle/circle intersection
for any pair of Circle/Arc entities (frame-independent — each side's
own arc sweep is checked against its own native axis, so two arcs in
differently rotated coordinate frames still resolve correctly).
- exact_curve_intersections: a more general path for anything
involving a line or an ellipse — embeds both entities into a shared
working plane and dispatches to cadkernel::geom2d::intersect (an
exact/tolerance-converged solver already vendored in this repo's own
dependency but previously unused for this purpose). Handles a
reflection hazard the naive version would miss: when an entity's own
normal is anti-parallel to the shared embedding frame's normal, the
mapping mirrors the parametrization and reverses which physical arc
a [start, end] range refers to — both the Arc and PlanarEllipse
branches detect this and swap start/end to compensate.
The only remaining case still going through the tessellated sweep is a
compound polyline chain with a bulge segment (not a single recognized
curve — it would need its own per-segment bookkeeping this pass
doesn't add).
12 new tests, including the reported 3-4-5 case verified against the
exact expected crossing points, arcs in differently rotated frames,
and the anti-parallel-normal reflection case for both Arc and Ellipse
(independently verified via a Python script for exact precision).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Material map images (diffuse/specular/reflection/opacity/bump/
refraction/normal) were decoded at their original file dimensions and
uploaded to wgpu without checking device.limits().max_texture_dimension_2d,
unlike raster-image rendering, which already tiles to that same limit.
An oversized material map produced a wgpu validation error and left
the material unusable; the global error handler prevented a process
abort, but never produced a valid texture either.
A mesh material is sampled by UV across a surface, so it can't use the
same per-quad tiling trick raster images use. Instead,
upload_rgba_texture now checks the decoded image against the device's
texture-size limit and downscales it (aspect-preserving, via
image::imageops::resize) before upload when it's oversized, falling
back to the existing 1x1 material color if the decoded buffer is
malformed (its length doesn't match its reported dimensions).
3 new tests cover the resize math (aspect ratio preserved, both
dimensions respect the limit) and the malformed-input fallback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Automation's "open" op called io::load_bytes directly and installed
the result as-is, skipping everything the canonical path-based loader
(load_file_for_open / finalize_loaded_outcome) runs after parsing:
block-origin normalization for legacy non-zero-origin blocks,
relative raster/material path resolution, source_path assignment,
and the corrupt-entity purge the UI open path always applies. The
same file opened two ways behaved differently, and headless/automation
use was more exposed to malformed geometry with no filter at all.
Added io::load_bytes_finalized(path, bytes), which runs load_bytes
plus that same normalization and purge using the real path (not just
its basename, which the old code passed to load_bytes as the "name"
parameter — losing the directory needed for raster-path resolution
and source_path). automation.rs's "open" handler now uses it and
reports the purge count as a "purged" field in the response.
load_bytes itself is unchanged, since the byte round-trip tests
(sketch_persist.rs, named_parameters_persist.rs) deliberately want
the simpler byte-only path with no real filesystem path behind it.
New test constructs a document with one normal and one corrupt
(zero-radius) entity, saves it, opens it through automation, and
checks the corrupt entity was purged and source_path was set —
proving the automation path now matches the UI path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent MINSERT (array INSERT) bugs found while auditing
render_graph.rs:
1. array_offsets() reserved and filled one Vec element per array
instance, with row_count/column_count parsed as unchecked u16s.
A corrupt or adversarial 65535x65535 pair requests 4,294,836,225
instances (~96 GiB) before any GPU-side chunking limit can help,
and before the render graph even reaches the GPU. Fixed with a
documented instance budget (20,000) enforced at both the load-time
corrupt-entity filter (is_entity_corrupt, matching the existing
polyline/spline vertex-count guards) and a failsafe clamp in
array_offsets itself, for an insert built or edited some other way.
2. insert_instance_transform composed the per-instance row/column
offset translation *before* the INSERT's own transform, so the
block's x/y scale multiplied the spacing along with the block
contents — an INSERT with x-scale 2 and column spacing 10 rendered
20 units between columns instead of 10. The pinned CAD model's own
Insert::array_points() confirms the correct behavior: spacing
rotates with the insert but is never scaled. Fixed by orienting the
offset with rotation + OCS only, then translating the already-
composed (scaled) instance transform by that world-space vector.
7 new tests cover both: pathological/reasonable instance counts at
the load-time filter and the render-graph failsafe, and instance
transforms for scaled and rotated arrays.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`hit` was one number covering five things: resolving candidate handles, the
wire box test, two hatch tests and two mesh tests. `select-commit` now
reports them apart, which immediately reframed the problem — at 186 468
entities the wire box test is 55 ms of 206, the hatch tests are 103, and
handle resolution is 47.
Two changes alongside it, both in the crossing scan.
Skipping a wire already in the selection was keyed by hashing its name, once
per triangle and once per glyph inside the box. A bitmap over wire indices
answers the same question with an array index. The name set stays behind it,
because several wires can share one entity's name and the output dedups by
name; a wire whose name is already in that set now gets tested once more
before being dropped, which costs one projection and changes no output.
The degenerate-wire loop that ends both the box and the lasso scans tested
`seen.contains(wire.name)` before the structural tests beside it. `||`
short-circuits, so with the name lookup last a wire with two or more points —
very nearly all of them — is dropped without hashing anything. That loop
walks every candidate, so it was one string hash per wire in the box.
Rebuilding the properties panel for a 186 468-entity selection took ~112 ms,
and four things in it scaled with the selection for no reason.
The panel filtered the selection down to the active group with
`group.handles.contains(handle)`, a linear scan of a `Vec`, once per selected
entity. The default group is "All", which holds every selected handle, so
both factors are the whole selection: 186 468 squared. Hashing the group once
makes it linear. The measured cost never showed this — 55 ms at 1 993
entities against 112 ms at 186 468 is not a quadratic curve — but the scan is
still wrong, and it is the shape of thing that turns a large selection into
an unresponsive window.
`entity_in_working_plane` clones the entity before it checks whether the
plane is the identity, so with no UCS in play the panel deep-cloned every
selected entity to get back what it was handed. It now borrows, and clones
only where a transform is applied.
`aggregate_sections` built every entity's property list, collected them all,
then folded them together and read each exactly once. A property list is
sections of labels and formatted values, every one an allocation, so a
whole-drawing selection held 186 468 of them in memory at the same time. It
now builds one, folds it, and drops it, and stops once nothing is left in
common. The fold order is unchanged, so the result is too.
The ribbon sync cloned each entity's linetype into a `String` only to compare
it, and kept walking after every field had been found mixed. Once all four
are mixed nothing further can change, so it stops there.
`aggregate_sections` and `merge_sections` had no tests, so the fold that
drives the whole multi-selection panel was resting on the compiler. Two now
cover it: a field the entities agree on keeps its value, one they differ on
reads as varying, and a single entity folds to its own rows.
`properties-detail` and `properties-panel` report the phases, which is how
the panel arm was ruled out as the remaining cost — it does not reach its
5 ms floor at 186 468 entities.
Highlighting a whole-drawing selection took 299 ms, and it runs when the
selection changes — directly between the click that completes a selection and
seeing it.
Most of that was asking the same question twice. Each wire went through the
circle and ellipse extractors at its draw depth to build the analytic
instances, then through both again at depth 0.0 purely to ask *whether* it is
a circle or an ellipse, allocating a vector each time and dropping it.
Neither extractor's `Some`/`None` answer depends on the depth: every
`return None` in them turns on the geometry — which `TangentGeom` variant,
the radius, the axis lengths, the parameters — and the depth is only written
into the instance, never read to decide. So the extraction that builds the
instances already answers the classification. One pass now does both, and
fans out across cores, since it reads a wire and writes nothing shared. That
took 173 ms of shape extraction and splitting down to 19 ms, and the whole
highlight from 299 ms to 150 ms.
Gathering the highlighted wires also cloned each handle's slot vector and
sorted the copy — one allocation and one sort per selected entity. The slots
are already ascending: both builders walk the wires in order and push as they
go, and `patch_handle_index` shifts a handle's slots by a constant, which
preserves it. A `debug_assert` on the slots actually read holds that in
place. The compat path of `from_highlight_refs` emitted its instances
sequentially while the native path beside it fans out; it now matches.
Nothing renders in a test, so a wrong bucket in the classification would
surface only as circles drawn wrong when selected. Two tests cover it: each
wire lands where the old predicate put it, and the colour override — always
for hover, and the configured tint or `SELECTED` for a selection — reaches
the instances.
`wire-highlight-detail` reports the phases: gathering, classifying,
uploading the analytic instances, the regular wires and the block ones.