Commit Graph
100 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 5 273f482aee refactor: retire the solid3d feature — the web build gets solids too
The feature existed for one reason: truck's mesh and boolean crates reach
`vtkio → xz2 → lzma-sys`, a C library that cannot cross-compile to wasm32. So
the web build dropped it, and with it the Model tab, solid tessellation and
ACIS import — all of which quietly did nothing in the browser.

cadkernel is pure Rust and has no C dependency, so none of that holds any
more. The feature gated exactly two things by the end, `acis_export` and the
save-path sync that calls it, and both are as portable as everything around
them. Removing it means the web build makes primitives, runs booleans, draws
ACIS solids read from a file, and writes them back out as exact geometry — the
same as the desktop build.

Verified against all three: native, `--no-default-features`, and an actual
`wasm32-unknown-unknown` check rather than an assumption about one.

The docs claimed otherwise in four places and are corrected. `tessellation.md`
in particular was organised around whether an entity "goes through truck",
which is no longer a question anything can be asked — the three paths are now
a kernel B-rep mesh, a curve sampled through `entities::curve`, or geometry
emitted directly, and the per-entity table is updated to match.

`index.html` no longer passes `data-cargo-no-default-features`; there are no
features left to turn off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:28:25 +03:00
Hakan SevenandClaude Opus 5 f14ef54528 fix(build): restore the no-default-features build, and drop lzma-sys
Two things the truck removal broke without saying so, both found by auditing
what is left in the manifest rather than by anything failing.

The cfg gate on `acis_export` ended up on `acad_to_render` instead, when that
module was put back after being deleted by mistake. `acad_to_render` is what
every entity converts through, so gating it left the wasm build — which is
what `--no-default-features` stands in for — unable to resolve it from thirty
places. It built at the commit before the migration and builds again now.

And deleting TRUCK_CHORD_FRAC took the constant but left its doc comment and
its `#[cfg]` behind, so the attribute landed on the next constant down and
gated a boundary-sampling tolerance that has nothing to do with solids.

lzma-sys was the last dependency in the manifest with nothing using it: no
code references it and nothing else in the tree pulls it in. The `solid3d`
feature it hung off still gates four places, so the feature stays.

Everything else declared is used. `getrandom` and `iced_widget` look unused
by grep and are not — the first is declared to turn on the feature the wasm
target needs from a crate half the tree pulls in, and the second is a
`[patch]` entry rather than a dependency at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:22:08 +03:00
Hakan SevenandClaude Opus 5 8c7ebdc9f1 refactor: drop truck, and evaluate every curve through the kernel
The last four truck crates are gone from the manifest. What they were doing
falls into three parts, and each now has one answer instead of two.

NURBS evaluation. truck was carrying B-spline curves and surfaces for the
SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview
and ACIS spline-surface faces. The kernel now has both, over one de Boor
written across however many coordinates a control point holds — so a plane
curve and a space curve cannot drift apart, and a surface is the same
algorithm applied twice. The rational and the polynomial cases stop being
separate types: weights absent means polynomial.

The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was
built into truck topology purely so it could be sampled back into points.
They are sampled through `entities::curve` now, which is where each one's
geometry is already defined once and what EXTRUDE and REVOLVE read — so a
circle drawn on screen and a circle handed to the Model tab come from the
same definition. That retires four of TruckObject's variants and the module
is renamed for what it does.

SWEEP and LOFT. Both only ever produced a mesh, so both are built from point
lists: a band of quads per span, and a lid where a profile closes. Lofting
profiles of different densities resamples them by distance rather than by
index, so a circle lofted to a square no longer twists.

Two things worth noting for anyone reading the old comments. The tolerance
globals that lived in the tessellation module were never about truck at all —
they are the per-frame chord height, and they move to `curve_tol`. And the
rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE
no longer holds: those read the curve directly and never look at this channel.

The two `automation` test failures are unchanged from before this and are not
caused by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
Hakan SevenandClaude Opus 5 1f642bb40a feat(model): build and read solids with the geometry kernel
Every 3-D solid OCS makes or opens now goes through cadkernel, which holds
analytic surfaces — a cylinder is a cylinder record, not a spline that looks
like one. That is what lets a solid built here be written back out as exact
ACIS instead of a triangulation, and a facetted export is a one-way door: the
next application to open the file would have no way back to the circle.

The Model tab's seven primitives, the Design-group booleans, 3DROTATE,
3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the
kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed
its own care before; the kernel reverses the loops on the way through, so it
comes back the right way out rather than lighting black.

EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a
circle, an arc-bulged polyline and a closed spline arrive as the same thing.
A circle extrudes into four cylinder patches rather than a run of chords, and
a profile turned about an axis becomes a cone, a sphere or a torus. A spline
profile has no analytic side wall and is refused rather than approximated.

ACIS goes both ways through the codec's own bridge: `lift` to read and
`append` to write. That drops the 679-line truck conversion and the planar-
only exporter, and the tessellator keeps its bespoke sampler as a fallback for
whatever the kernel cannot yet express — reported rather than passed off as
whole, since a mesh missing a wall looks finished.

truck-shapeops is no longer used and is gone from the manifest. truck-modeling
stays for now: fourteen files use it as a 3-D NURBS evaluator, which the
kernel has no answer for yet, and SWEEP and LOFT have no kernel operation
behind them either.

The two `automation` test failures are unchanged from before this and are not
caused by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
Hakan Seven 8016b79ddf fix(snap): align extension tracking
Require OTRACK crossings to include the active Extension ray and resolve Shift constraints consistently across preview, grips, and clicks.\n\nRefs #716
2026-08-09 23:55:06 +03:00
Hakan Seven ad5164875f Merge pull request #717 from gianlucafiore/fix/716-perpendicular-tracking
Fix perpendicular and OTRACK snap interactions
2026-08-09 23:37:56 +03:00
Hakan Seven f57c258ada fix(snap): correct active intersection snapping 2026-08-09 13:27:51 +03:00
Hakan Seven 6a39b82a68 Merge pull request #712 from gianlucafiore/fix/704-active-intersection-snap
Fix active intersection snapping
2026-08-09 13:17:31 +03:00
Hakan Seven d80ee709ac fix(otrack): clear and localize tracking hint 2026-08-09 13:04:55 +03:00
Hakan Seven ae24f985f0 Merge pull request #714 from gianlucafiore/feature/tracking-reference-tooltip
Show active tracking reference tooltip
2026-08-09 12:57:50 +03:00
Hakan SevenandClaude Opus 5 1a37562a28 chore: take the stack with its solid layer and ACIS bridge
The kernel gained a B-rep layer — topology, analytic surfaces, surface
intersection, face splitting, point classification, booleans — and
acadifc gained the bridge that lifts an ACIS document into it and lowers
it back, provenance-driven so an untouched body saves byte for byte.

The reachability test is a compile-time one on purpose. A dependency
chain that stops resolving is caught where it breaks rather than the
next time somebody reaches for the far end of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:26:57 +03:00
Hakan Seven 2f60dd0f59 refactor(i18n): group Fluent attributes 2026-08-09 11:12:47 +03:00
Hakan SevenandClaude Opus 5 f3c2e061e0 refactor(trim): find an extension's boundary with the kernel
EXTEND asked where a line reaches by matching over every Geo variant and
running its own line-line, line-circle and line-ellipse test — twice
over, once for a LINE and once for a polyline's terminal segment, about
two hundred and sixty lines between them. A boundary spline was walked
as sixty-four chords there rather than intersected, so an extend landed
on the chord nearest the spline instead of on the spline.

Both are one call now: shoot the segment's own infinite line and take
the nearest crossing on the side being extended. That is `cut_params`,
which every other cut in the file already goes through.

With those two gone, the sampled copy carried beside each boundary
spline had no remaining reader, so a spline boundary is now only ever
the exact NURBS. The line-ellipse adapter lost its last caller with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:02:23 +03:00
Hakan SevenandClaude Opus 5 ca85856f52 refactor(hatch): clip the pattern against the boundary in the kernel
The pattern's lines were clipped by sorting their crossings with the
boundary and taking every other span. That is right where a line cuts a
boundary cleanly and wrong wherever it grazes one: a line through a
corner crosses twice in effectively one place, and from there every span
is inverted — the fill lands in the holes and the solid comes out empty.
A 1e-5 dedup on the crossings hid some of it and could not fix the rest,
because which crossings to merge is not the question.

The kernel divides the line by its crossings and then asks each span
whether its middle is inside, which costs a containment test apiece and
cannot get out of step. It also accepts a curved boundary rather than
only a polygon, which this call site does not need yet and a section
fill will.

What stays is the PAT frame — the family angle, spacing, origin offset,
the k range and the dash walk — which is a format convention rather than
geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 09:59:19 +03:00
Hakan Seven 52799786f4 feat(i18n): complete locale catalogs 2026-08-09 09:44:54 +03:00
Hakan SevenandClaude Opus 5 f1bc5f2cae fix(spline): reverse a spline without reshaping it
SPLINEDIT's REVERSE reversed the control points and then regenerated a
clamped *uniform* knot vector. That is only harmless for a spline whose
knots were evenly spaced to begin with; for any other — which is most of
them once a modeller has been near one — the curve came back a different
shape, bulging where it had been taut. It now mirrors the knots within
the domain, which is exact, and swaps the end tangents to match.

PEDIT's Spline option evaluated a uniform cubic B-spline from four
blending polynomials written out by hand, clamped the open case by
repeating end control points, and then pinned the two endpoints
afterwards to undo the drift that leaves. A clamped knot vector says all
of that directly, and the closed case gets a genuinely periodic curve
rather than one whose seam is a special case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:52:12 +03:00
Hakan SevenandClaude Opus 5 0677dd146c refactor(blend): evaluate the Bézier with de Casteljau from the kernel
BLEND summed Bernstein terms using a binomial-coefficient table with
entries for degrees three and five — the two it builds — and a fallback
of one for everything else. Correct today, and quietly wrong the moment
a third continuity mode is added. De Casteljau needs no coefficients at
all, works at any degree, and is steadier: the Bernstein terms are large
coefficients times small powers and they cancel, while repeated
interpolation only ever averages neighbours.

Curvature through three points moves with it, since both are the 3D
arithmetic a blend needs and neither has a planar counterpart to
delegate to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:47:04 +03:00
Hakan SevenandClaude Opus 5 77a9167e34 fix(area): measure the curve, not a polygon through some of its points
A hatch's area was not the area of anything. Its boundary was flattened
into a ring by pushing an arc's *centre* into it, and a spline's control
points — neither of which is on the boundary — and the shoelace formula
was run over the result. Boundary edges now go through the kernel, which
measures each for what it encloses: an arc contributes its sector, a
spline is integrated.

AREA had three measurements between them. The bulged-polyline one
carried its own arc corrections; the planar entities it did not cover
fell through to mass properties. Both are now one call on the entity's
curve, which gets the bulges, the ellipse and the spline right in the
same place. What is left is the genuinely spatial — a 3D polyline, a
spline whose points wander off any plane — measured as rings of points
by Newell's method rather than by projecting them flat.

Regions were doing the same Newell sum by hand. They share it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:44:04 +03:00
Hakan SevenandClaude Opus 5 9babf355b1 refactor(fillet): find the tangent arc in the kernel
FILLET enumerated its own candidates twice, once for a line against an
arc and once for two arcs, each with its own quadratic, its own four
sign combinations and its own conventions about which root meant what.
Both are the same construction: a circle of the given radius tangent to
a curve has its centre on one of that curve's offsets, so the fillet
centre is where an offset of one crosses an offset of the other.

That now comes from the kernel, which does it for any pair of straight
and circular curves and agrees with the corner formula where both apply.
What stays here is the part that genuinely belongs to the command —
which candidate the two picks meant, whether the touch lands on the
drawn part of an arc rather than elsewhere on its circle, and which side
of each entity to keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:37:11 +03:00
Hakan SevenandClaude Opus 5 f9a0d73dc6 refactor(hatch): take the boundary arrangement from the kernel
624 lines become 70. What went is the planar arrangement itself —
splitting segments at their crossings, welding coincident endpoints and
tracing the bounded faces of the resulting graph — which is not a
drawing-specific problem. It is the same one a B-rep boolean solves in a
face's parameter space once the intersection curves are projected into
it, so solving it in the kernel means the boolean does not arrive with a
second copy.

What stays is reading the wires, which is where the drawing's own
conventions are: NaNs separating the runs inside one WireModel, and the
double-single coordinate pair that has to be recombined before anything
is measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:30:32 +03:00
Hakan SevenandClaude Opus 5 d32f39221c fix(measure): space points along a curve by distance, not by parameter
DIVIDE and MEASURE did nothing at all on an ELLIPSE or a SPLINE. Their
length function answered `0.0` for both — the `_ => 0.0` arm — and the
command reported the entity type as unsupported. On a polyline they
measured the chords and ignored the bulges, so points landed short of
where they belonged; on an arc or a circle they read the centre as world
coordinates when the entity stores it in its OCS.

All three now walk the entity's own curve, which the kernel measures
properly: closed forms where they exist, and the integral of the speed
where they do not.

A path ARRAY had the same confusion in a different place. It sampled the
path at even *parameters*, which is the same as even distances on a line
and a circle and nothing like it on an ellipse or a spline — copies
bunched up wherever the parameter ran slow. It also now closes properly:
on a closed path the last copy no longer lands on the first.

LENGTHEN measured an ellipse with a hundred and twenty-eight chords and
then bisected on that, and a spline with forty rounds of thirty-two
chords apiece. A chord sum reads short, so "current length" was already
below the true one before any delta was applied. Both now ask the kernel,
which also makes the spline case one evaluation instead of about
thirteen hundred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:24:29 +03:00
Hakan Seven e6345f89f7 feat(i18n): expand language coverage
Complete missing keys in the existing translations and add selectable Spanish, Brazilian Portuguese, Arabic, and Japanese resources.\n\nThe large compatibility catalogs for the new locales remain in progress.
2026-08-09 01:15:51 +03:00
Hakan SevenandClaude Opus 5 ece440bcab refactor(curve): draw a fit spline and an offset preview from the curve
Two places were left describing geometry for themselves.

A fit-point SPLINE was drawn from a C² cubic solved in spline.rs while
TRIM and snap cut a separate interpolation from the kernel. Near enough
to look right, far enough apart that a trim landed beside the line it
was aimed at. It now draws the curve those commands work on, and falls
back to the local solve only where the kernel declines — a fit spline
through points in space is not a planar curve, so there is nothing for
it to say.

Closed fit splines were worse than that: the kernel's interpolation is a
clamped solve with no wrap in it, so the curve TRIM used never returned
to its start and the seam cut nothing. Repeating the first fit point
closes it. The seam is C¹ where the rest of the curve is C², which is
the honest limit of a clamped solve — the alternative was a curve with a
gap in it.

OFFSET's preview sampling had drifted the same way TRIM's had: an arc
and a circle read `center.x/.y` as world coordinates when the entity
stores them in its OCS, so an extruded one previewed on the wrong side;
every curved kind was cut at a fixed twenty segments per radian whatever
its size; and an ellipse rebuilt its axis frame from the major axis' X
and Y alone, dropping the Z the entity stores. A hundred lines of that
become one tessellation. A RAY now previews as far as an XLINE does
rather than as a stub the length of its direction vector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:08:45 +03:00
Hakan SevenandClaude Opus 5 90abfc21a9 refactor(render): tessellate curved entities from their own curve
The point lists were built three different ways and none of them scaled
with the geometry. A full ellipse was cut into sixteen segments per half
and an elliptical arc into thirty-two, whatever its size, so anything
large read as a polygon; a circle worked out its own segment count from
the chord tolerance; an arc was handed to truck's arc-through-three-
points builder, which cancels at survey coordinates.

All three now sample the entity's curve at the render pass's chord
tolerance, so how round something looks follows how big it is and how
far in the view is zoomed. The ellipse also stops casting its local
coordinates to f32 on the way in — the vertex path splits f64 into a
double-single pair precisely so that precision survives to the shader,
and throwing it away first defeated that.

What does not change is the kind of object each entity hands over.
EXTRUDE, REVOLVE and SWEEP read their profile and path out of Contour or
Curve and have no arm for a bare point list, so an arc or an ellipse
given as Lines would quietly stop being usable as either. The arc keeps
its circle_arc edge for that reason; only the ellipse's control points
change, and they keep the same two-half-edge wire.

The ellipse now decides it is closed from its own endpoints rather than
from its stored parameters, so a file that writes an end a hair short of
a full turn still produces a wire a plane can attach to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:55:29 +03:00
Hakan SevenandClaude Opus 5 c2cc6fd7c4 refactor(trim): read boundaries and samples through the entity curve
Three per-type matches went; what is left is only the shape of the
boundary record, which the edit options need to mutate — imply_edge_geos
turns a segment into an infinite line and opens an arc into a full
circle, neither of which a bare curve says.

Two things change with it. A boundary spline is carried as a NURBS
rather than as 64 chords, so a trim cuts where the spline actually runs;
the sampled copy stays only for the two hand-rolled ray walks in the
polyline-extend paths. And an extruded arc or circle is read in its own
OCS instead of having its stored coordinates taken for world ones, so it
acts as a boundary where it is drawn.

sample_entity_xy is now one tessellation at the density the renderer
uses, so a fence pass and a preview cut follow the drawn geometry rather
than a coarser approximation of it. It declines to guess a length for a
ray or an infinite line; fence_sample_xy, which has an answer, still
supplies one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:43:53 +03:00
Hakan SevenandClaude Opus 5 b36e85e46e refactor(snap): take an entity's snap points from its curve
Each entity built its own, and what they offered had drifted apart from
what the geometry actually has:

- An arc offered no quadrants at all. It now offers the ones its sweep
  covers, and no more.
- A spline offered its control points as endpoints. For a control-point
  spline those are not on the curve, so the cursor landed in empty space
  beside the geometry. It now offers its two real ends and its midpoint,
  with the fit points — which are on the curve — still on offer.
- An elliptical arc had no midpoint and no ends.

A new SnapHint::Endpoint carries an end that cannot go in key_vertices.
Consecutive entries there are taken to be joined by a straight segment
and their midpoint offered, which is right for a line or a polyline and
wrong for anything curved: an arc's two ends would also produce a snap
out on the chord. Splines lose their key_vertices for the same reason —
the chord between two fit points of a curvy spline does not run along
it.

A polyline's midpoints and arc-segment centres stay out of snap_pts
deliberately. The engine already derives a chain's midpoints from its
vertices, and a wire carrying a centre is treated as round elsewhere,
which a polyline with one bulge in it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:43:44 +03:00
Hakan SevenandClaude Opus 5 57661c5754 feat(entities): give an entity its geometry as a curve
The same shape was being read out of an entity's fields in three places
and the three had drifted. An arc's tessellation cut it at 20 segments
per radian, TRIM's sampling at 16, and its snap points came from a
fourth expression again; a spline was sampled at a fixed 96 points
however long it was; TRIM read an arc's centre as world coordinates
when the entity stores it in its OCS, so an arc carrying the (0, 0, −1)
normal a MIRROR leaves behind acted as a boundary on the wrong side of
the drawing.

entity_curve returns a PlanarCurve: the shape, plus the plane its
coordinates are read in. The plane comes from the entity's OCS — its
extrusion normal through the DXF arbitrary-axis algorithm — not from the
UCS. A UCS is the working plane a user drew in and an entity does not
remember it: draw a circle in a UCS turned 30° about Z and the stored
normal is still +Z, which is why the header keeps the UCS separately.
The algorithm itself stays in transform.rs, where the format is
understood and where #142's degenerate case was fixed once.

Entities that store world coordinates and may not be planar at all —
LINE, SPLINE — are checked rather than assumed. A spline whose points
wander in space returns None instead of being flattened onto XY, which
would move it.

entity_curve_xy folds the plane away for the editing commands, which
work in plan view and need every boundary in one frame before two of
them can be intersected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:43:35 +03:00
Hakan Seven 8847724127 fix(cursor): restore default geometry
Keep CURSORSIZE 5 and PICKBOX 3 mapped to the pre-option 60 px arms, 15 px box, and 8 px selection aperture.\n\nRefs #413
2026-08-08 21:53:26 +03:00
Hakan Seven 1738f9a79b feat(cursor): add crosshair options
Add persistent cursor sizing, pick aperture, color, pointer mode, isometric drafting planes, and SNAPANG-aligned grid behavior.\n\nRefs #413
2026-08-08 21:32:44 +03:00
Hakan SevenandClaude Opus 5 188ac80b2e chore: take the stack without nalgebra
The codec declared nalgebra and never used it — its Vector2 and Vector3
are plain structs of its own. It was pulling eleven crates into every
build here for nothing.

Also picks up cadkernel's Vec2, which replaces the vector arithmetic the
kernel had been writing out by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:15:24 +03:00
Hakan Seven adb8ced8b5 feat(view): complete ZOOM options
Add command-line navigation modes and persistent wheel controls.\n\nRefs #412
2026-08-08 20:37:53 +03:00
Hakan SevenandClaude Opus 5 3f103d2f8b refactor(trim): give rays and infinite lines their real parameters
Trimming a Ray or an XLine mapped it onto a virtual 0..=1 across a
million-unit stand-in for infinity, and read the surviving ends back with
a threshold — 0.9999 of that stand-in meant "still infinite". Both now use
the curve's own parameter, `t = 0` at the base and advancing by one
direction vector per unit, with no upper limit. An end that reaches
infinity says so, and `cut_intervals` bounds the target by its extent
instead of always by 0..=1.

The stand-in was not merely inelegant. A million units is the same order
as the coordinates themselves in a survey drawing, so a boundary beyond it
was silently missed, and the threshold meant a cut very near the far end
was rounded to "unbounded" and kept the wrong shape.

Implied and extended boundary edges become infinite lines rather than
segments stretched by the same constant. That is what they always meant —
the boundary cuts wherever its line reaches — and the kernel solves it
exactly.

The hover preview shared the virtual mapping and would have been left
feeding the old parameters into the new interpretation, so it is converted
with the rest.

TRIM_EXTENT survives only in `fence_sample_xy`, where a finite polyline is
genuinely what the fence test needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:04:08 +03:00
Hakan SevenandClaude Opus 5 dde5ac07cf refactor(trim): find boundary crossings with cadkernel
`line_seg_ts`, `arc_seg_ts`, `ellipse_seg_ts` and `spline_seg_ts` each
walked every boundary kind and solved that pair by hand — four nested
matches over the same seven cases. They are now four descriptions of the
target as a `Curve` handed to one `cut_params`, which asks the kernel.
Their signatures are unchanged, so the twenty call sites are untouched.

Three things follow from it.

Boundary splines and target splines stop being sampled. A cut on a spline
now lands where the boundary actually crosses rather than where a fixed
sampling said it did, because the kernel subdivides and converges to a
tolerance instead.

Rays and infinite lines as boundaries are handled by the kernel's extent
rather than by a case here — a ray rejecting what lies behind its origin
is the kernel's job now.

`extend_spline` shoots a real ray. It used to build a line a million units
long and cut against that, which both invents a limit and, at survey
coordinates, is comparable to the coordinates themselves. The ray's
direction is a unit vector, so what comes back is the distance to the
boundary. That also fixes the hit position, which multiplied by the
million-unit extent a second time and landed the extended end far past
where it was supposed to.

`cut_params` clamps to 0..=1 only for a bounded target; a ray has to keep
the parameter it comes back with, since the distance is the answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:53:55 +03:00
Hakan Seven 5bd5c884e0 fix(aliases): map CP to COPY 2026-08-08 19:20:34 +03:00
Hakan Seven 65b882dd76 fix(hatch): harden boundary extraction
Use a sweep broad phase, preserve f64 coordinates, and split collinear overlaps so large drawings remain responsive and precise.
2026-08-08 18:59:56 +03:00
Hakan Seven 598de3e47a Merge pull request #703 from gianlucafiore/feature/699-hatch-pick-boundary
Add internal-point hatch boundary detection
2026-08-08 18:36:46 +03:00
Hakan SevenandClaude Opus 5 042a479244 fix(spline): edit fit-point splines, and stop deleting the kept half
Two faults, reported together.

Fit-point splines could not be edited at all. A SPLINE is stored either as
a control polygon the curve approaches or as fit points it must pass
through with optional end tangents, and `spline_to_nurbs` only read the
first. A fit-point spline has no control points, so every command that
touched one — TRIM, BREAK, LENGTHEN, OFFSET — got nothing back and did
nothing. Two fit points with start and end tangents, which is what a
drawing stores for the simplest curve, was the clearest case. Fit points
now interpolate into an exact curve through cadkernel, so they are as
editable as any other spline.

Trimming deleted the whole spline when the surviving piece reached the
curve's own start. `trim_spline` cut away everything before the interval
and everything after it, and a cut at either end is a no-op that reports
nothing to do — which the end case handled and the start case propagated
as failure, dropping the piece. That is why one side of the cutting line
worked and the other emptied the drawing.

Elevation is read from whichever point list the spline actually has. A
fit-point spline has no control points, so it was falling to z = 0 on the
first edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 17:08:17 +03:00
Hakan SevenandClaude Opus 5 0f8f4864ba refactor(spline): evaluate splines with cadkernel, weights and all
The spline path ran on truck's BSplineCurve<Point3>, which is not
rational. Weights were dropped on the way in and cleared on every split,
so a NURBS circle — which is how most circles arrive once they have been
through a modeller — was evaluated as a parabola and stayed one after a
break. cadkernel's NurbsCurve carries the weights and applies them in
homogeneous coordinates, and `spline_cut` splits by raising the cut to
full knot multiplicity, so both halves are exact and both stay rational.

`spline_ops` keeps the knot-domain call shapes the commands were written
against — `spline_range` replaces reaching for `range_tuple`, `spline_cut`
replaces `cut` plus the rebuild — so TRIM, BREAK, LENGTHEN and OFFSET
changed only where they named those.

Knot vectors for a rebuilt control polygon come from the kernel too, so
the clamped uniform vector is defined once rather than assumed in two
places.

truck is now absent from the whole spline path. It remains for 3D solids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:55:07 +03:00
Hakan SevenandClaude Opus 5 95e61f3e8d refactor(fillet): take the corner arc from cadkernel
`compute_fillet` keeps the entity work — resolving which side of each line
the pick kept, and trimming the lines back — and hands the geometry out.
The kernel takes the corner as an apex plus the two keep directions, which
is what the side resolution here already produces, and returns the tangent
points, centre and the endpoints in fill-the-corner order.

`line_circle_ts` and `circle_circle_pts` were further copies of maths the
kernel already had; the second now calls `circle_circle_points`, added for
callers that want the crossings as points rather than as angles to cut at.

`arc_span` and `clamp_angle_to_arc` stay put on purpose. This file's
`arc_span` treats any span under 1e-6 as a full turn, where the kernel's
only does so at zero or below. That difference is real for a degenerate
arc, and picking a winner is a separate question from moving the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:20:34 +03:00
Hakan SevenandClaude Opus 5 397fb38292 refactor(offset): take polyline offsetting from cadkernel
The offset core moves out: normalising the source, the cavalier_contours
call, the sharp-corner join fixup and the conversion back. What stays is
entity work — reading an LwPolyline in, writing offset LwPolylines out,
and the per-type offsets for lines, arcs, circles, ellipses and splines,
which are analytic and never needed the polyline machinery.

`BulgeArc` moves with it, since the offset preprocessing splits over-half-
turn arcs and cannot work without it. `entities::common` now re-exports it
from the kernel, so the twelve modules already reaching for
`entities::common::BulgeArc` are untouched.

`norm_rad` was a fourth copy of angle normalisation, after the three
removed from trim, fillet and explode. It now aliases the kernel's.

cavalier_contours leaves this crate's manifest: nothing here calls it any
more, and it arrives through the kernel's `offset` feature instead, which
acadifc forwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:01:43 +03:00
Hakan SevenandClaude Opus 5 06a0fc4ddf fix(trim): cut a closed ellipse at the right span
Two faults met on the same command, both of them older than the move to
cadkernel and both hidden until ellipse previews started rendering
truthfully.

The survivor came back in two pieces. `trim_ellipse` handed its cuts to
`trim_intervals`, which is the open-curve routine: it anchors on bounds at
0.0 and 1.0, so a closed ellipse gained invented cuts at the parameter
seam and the surviving arc arrived split either side of it. Worse, a click
in the wrapping piece landed in one of those two halves, so the wrong span
disappeared. A closed ellipse now takes the cyclic route circles already
used — find the cut-to-cut gap holding the click, keep everything else,
and let end < start signal the wrap.

The cut span was also short. That came from `line_ellipse`, fixed upstream
in cadkernel 46e76cc: it returned the geometric angle at the centre rather
than the ellipse parameter, which are the same thing only on a circle. On
anything eccentric the cuts sat tens of degrees off where the line
actually crossed, so the removed piece was consistently too small. The
lock bump brings that fix in.

Elliptical arcs, lines, rays, xlines and open splines keep using
`trim_intervals`; nothing about the open-curve path changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:42:30 +03:00
Hakan SevenandClaude Opus 5 d0cc3284d2 refactor(modify): take plane geometry from cadkernel
TRIM, FILLET and EXPLODE each carried their own copy of the same plane
maths. The copies had already drifted apart: `ll` differed only in
parameter names between trim.rs and fillet.rs, `arc_pts` only in which
angle helper it called, and `norm` meant angle normalisation in trim.rs
and explode.rs while meaning vector normalisation in two other files.

The implementations now live in cadkernel's geom2d. A new local `geom`
module adapts the call shapes — the kernel takes `[f64; 2]` points and
returns `[f64; 3]`, while these commands pass loose scalars and feed
`WireModel::points`, which is `[f32; 3]`. Narrowing stays on this side
because only the caller knows whether it is a preview wire, which leaves
`points_low` empty and accepts the loss, or resident geometry, which
splits the f64 into a high/low pair instead.

Call sites are untouched: the kernel functions are imported under the old
local names, so the signatures they were written against still hold.

This also fixes ELLIPSE trim and extend previews. `ellipse_pts` wrote its
components as [x, z, y] while `arc_pts` — reached from the same match in
`entity_pts`, feeding the same `[f32; 3]` — wrote [x, y, z], and
`WireModel::points` is documented as world-space positions. Ellipse
previews were collapsing onto y = 0 and displacing along Z. The kernel's
`ellipse_arc` orders them correctly, so delegating corrects it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:27:45 +03:00
Hakan SevenandClaude Opus 5 051272ad71 build: reach the CAD stack through acadifc
The DWG/DXF/ACIS codec now lives in its own crate and acadifc re-exports
it alongside the geometry kernel, so this is the single dependency:

    OpenCADStudio -> acadifc -> { cadcodec, cadkernel }

acadifc is aliased to `acadrust`, which leaves the 2569 `use acadrust::…`
paths across 253 files untouched — the alias is a manifest-level rename,
not a source change.

The old `[patch.crates-io]` entry goes away with it: the dependency is a
git revision now rather than a crates.io version being redirected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:11:33 +03:00
Hakan SevenandClaude Opus 5 3d5f400b9d fix(statusbar): keep the cursor visible over a disabled menu row
A greyed-out option in a status-bar menu made the mouse pointer vanish.
A disabled button reports no mouse interaction at all, and a menu is an
overlay: when the overlay reports none, the cursor is decided by whatever
lies beneath it, which over the drawing is the canvas hiding the pointer
so it can draw the crosshair itself. Enabled rows were fine only because
they ask for a pointer and so never fall through.

Every row now answers for the cursor over it, doing something or not.
`mouse_area` fills in only where the content stays silent, so an enabled
row keeps the pointer it asked for.

Fixed for every status-bar menu rather than the one that showed it: they
all build their rows through the same place, and the next menu with a
disabled row would have had the same hole.

Closes #684

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:56:58 +03:00
Hakan SevenandClaude Opus 5 6e3356fb27 fix(statusbar): let the cursor reach a menu without closing it
A status-bar menu closed as soon as the cursor left its own rectangle.
These menus hang off a pill barely wider than its icon, so reaching for a
row's text means moving up and across at once, and the diagonal passes
through the gap between the pill and the menu. Moving straight up worked;
moving toward the thing you were aiming at did not.

The safe area now extends one row of the bar in every direction, which is
the room that diagonal needs. The bar's row height stops being a literal
at its own call site and becomes ROW_HEIGHT, so the two stay together if
the bar is ever resized.

Closes #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:45:35 +03:00
Hakan SevenandClaude Opus 5 6ca4463a26 fix(web): give the browser build a load_file to fail through
Reading a drawing by path is gated off on wasm, and two features added
since 0.9.3 call it unconditionally: importing a drawing as a block, and
taking layer standards from one. Both compile into the web build, so the
web bundle stopped building and the v0.9.4 Pages deploy failed.

Neither feature can work in the browser — a file arrives through the page,
not from a filesystem the app can open — so the fix is not to make them
work but to let them say so. `load_file` now exists on wasm and returns
that as its error, which both callers already surface. Keeping the
signature means neither has to know that the web has no files, and the
next feature that reads a second drawing will not break the bundle
either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:34:57 +03:00
Hakan SevenandClaude Opus 5 17b2f158f4 chore(release): prepare v0.9.4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:23:27 +03:00
Hakan SevenandClaude Opus 5 2feac73eec fix(locale): put TCOUNT's new prompts back in the catalog
The placement options arrived with their prompts and messages written as
plain Rust strings, so they were the only command left speaking English
whatever the interface was set to.

They go through t! / tf! now like every other prompt, and the strings are
registered so the call actually reaches a translation: a source string
only resolves if the generated lookup knows it, and t! on an unknown
string just hands the source back. Keys follow what the table already
uses — the slug of the source, cut to forty-six characters, and the first
ten hex digits of its SHA-1 — which reproduces 2978 of the 3161 entries
exactly; the rest are hand-written ones predating the generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:19:54 +03:00
Hakan Seven 7ef5b431e1 Merge pull request #698 from gianlucafiore/fix/697
Fix TCOUNT numbering and add placement options
2026-08-08 03:07:09 +03:00
Hakan SevenandClaude Opus 5 7307ea3136 fix(dim): measure a two-line angle between the lines that define it
A two-line angular dimension stores two lines, not two rays:
first_point→second_point is one, angle_vertex→definition_point the other,
and the angle is the one they make where they cross. The renderer read
angle_vertex as the centre and the other two points as the rays from it,
which measures something else entirely — a ten degree angle came out as
two hundred and seventy, and a shallow one as very nearly a full circle.

The lines are now intersected for the real vertex, and the arc point
picks which of the four angles at that crossing is meant. Each line
offers its direction and its reverse, and the sweep is tried from either
line: taking only the first→second order leaves out half the angles, and
the half left out is the one wanted whenever the lines are nearly
parallel. Parallel lines cross nowhere, so only the extension lines are
drawn.

A zero sweep was also being turned into a full turn, because the wrap
that pushes a negative sweep forwards ran on zero too. Two rays pointing
the same way enclose no angle and now draw none.

Checked against the three angular dimensions in a drawing that carries
its own baked arcs: the vertex and sweep computed from the stored line
endpoints reproduce the baked centre and angle to the decimal — 7.48,
10.61 and 2.29 degrees — from entirely separate inputs.

This is the path taken when a dimension has no baked block: one OCS drew
itself, or one whose block was dropped by an edit. Both paths now agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:59:34 +03:00
Hakan SevenandClaude Opus 5 db43c21d55 fix(annotative): scale text by its own annotativeness, not its style's
An annotative object is drawn at the annotation scale only for the scales
it has a representation for. Text was being scaled whenever its text
style carried the annotative flag, whether or not the text itself had any
representation at all.

A drawing of twenty floors here has 1144 pieces of text on a style
someone flagged annotative and no object contexts anywhere. At 1:100 they
were drawn a hundred times too tall — a 411-unit height reaching 41067 —
while every other style in the same drawing stayed put.

`is_annotative` already said the right thing, and said why: a text style
can be turned annotative long after text was drawn in it, and that text
keeps its stored height until it is explicitly converted. The scale
resolver contradicted it. Text now scales on its own annotativeness
alone: an object context, a legacy XDATA marker, or the entity's flag.

Dimensions, leaders, tolerances and tables are unchanged — there the
style is the standard signal and still counts on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:45:59 +03:00
Hakan SevenandClaude Opus 5 2ea3bca093 feat(properties): stop offering a height the style has fixed
A text style with a non-zero height fixes the size of everything drawn in
it — the CAD that writes the file skips the height prompt for such a
style, and shows the height without letting it be changed. OCS offered
the field either way, so a drawing could be edited into disagreeing with
its own style.

The height row is now read-only when the style fixes it, for TEXT, MTEXT
and both attribute kinds. `num_prop` already draws a numeric row either
way, so the value still reads the same, just grey.

The panel builders are handed an entity, not the document it came from,
and widening that trait would touch every entity type. The lookup rides
beside the unit context instead, seeded from the same place, since it
answers the same kind of question: something the drawing settles that a
row needs in order to render itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:45:46 +03:00
Hakan SevenandClaude Opus 5 188d1a9ccb fix(dim): draw a dimension from the picture it carries
A DIMENSION references the block "that contains the entities that make up
the dimension picture", and that block is the picture: AutoCAD requires
it and draws it, BricsCAD draws it when present and only renders from the
dimension variables when it is missing. OCS re-derived the picture from
DIMVARS every time and ignored the block entirely.

That works while a style agrees with what the drawing actually drew. A
Revit-exported schedule here does not: its style stores DIMTXT already in
drawing units beside a DIMSCALE meant to scale it, so the text came out
three hundred times too tall, and each dimension carries a DIMFXL
override that is likewise already in drawing units, so the extension
lines ran nearly a million units past the points they measure.

Neither is guesswork about units now. The block holds the geometry the
drawing was authored with, so it is drawn: the contents are already in
world space and the block's base point is the origin, and the identity
instance that carries them exists only to reuse the scene graph's
ByBlock / layer-0 colour resolution. Every wire answers to the dimension,
so a pick still selects the dimension rather than one of its pieces.
Re-deriving stays as the fallback for a dimension with no block.

Annotative dimensions keep the old path: their several representations
are separate blocks and choosing between them is what the annotation
machinery already does, while the doctrine above assumes one picture.

A picture is only good while its inputs hold, so it is dropped when they
change: editing a dimension's properties, and saving the dimension style
it is drawn with. Entity edits already dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:44:30 +03:00
Hakan SevenandClaude Opus 5 039f703d7f fix(input): stop drafting-aid keys from cancelling the command
F8 reached ORTHO as an ordinary command, and dispatching a command tears
down whatever is running first: the active command dropped, its preview
cleared, its base point forgotten. So pressing F8 partway through a LINE
abandoned the line, and during a MOVE it took the dragged ghost with it.

A function key gets pressed mid-command — that is what it is for.
Toggles that only flip a flag now skip the teardown: ORTHO, GRID, SNAP,
POLAR, and the drafting-settings popup. None of them starts a command,
opens a document or reads geometry, so there is nothing for the teardown
to protect.

F7, F9 and F10 came through the same path and were broken the same way;
they are fixed with F8. F3, F11 and F12 already had messages of their
own and were never affected.

Closes #677

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:48:25 +03:00
Hakan SevenandClaude Opus 5 eb513ac5f8 fix(stretch): let the selection stage behave like every other one
STRETCH ended itself when a crossing window caught nothing, so a missed
aim meant restarting the command to try again. A missed aim is not a
decision to stop: say what happened and ask for the corner afresh, the
way a selection that picks nothing leaves MOVE still asking.

Enter now ends the selection stage too. With objects already picked the
window is only there to narrow them, so there is nothing left to choose
— Enter takes a window containing all of them, which stretches every
vertex, which is the whole selection moving. That is what a
pick-selected stretch means. With nothing picked there is nothing to
end, so Enter still cancels. The prompt says so.

Not changed: with a prior selection, a window that misses every vertex
still goes on to ask for the base point. The command holds wires, not
entities, and the entity rules do not follow the wires — a small window
inside a circle catches its centre, and so the whole circle, while no
tessellated vertex is inside. Guarding on the wires would refuse that
stretch.

Closes #676

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:42:16 +03:00
Hakan SevenandClaude Opus 5 8e09a312aa fix(dim): let the dimension line say which distance is measured
DIMLINEAR chose its measurement axis from the two extension line origins
alone, taking the longer of their x and y differences. Every line tilted
less than 45° therefore came out horizontal and stayed horizontal, no
matter where the dimension line was dragged.

The third pick is what answers the question. A linear dimension reports
the run or the rise; pulling the dimension line clear of the points above
or below asks for the horizontal distance, pulling it out to the side
asks for the vertical one.

"Clear of the points" is measured against their extent, not their
midpoint. A dimension line dragged straight up from near one end of a
long pair is still above them even though it is a long way from the
middle, and a midpoint test would call that sideways. When the location
is inside the pair on both axes it has not said anything yet, so the
longer side still decides.

Nothing else needed changing: the measurement already projects onto the
rotation axis, and the block builder already derives the dimension line
and both extension lines from it.

Closes #669

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:34:03 +03:00
Hakan SevenandClaude Opus 5 1a753e821c refactor(snap): find both tracking directions in one pass
Perpendicular tracking arrived as a second full scan of the drawing's
segments, run next to the edge-direction scan on every acquisition. The
two ask different questions of the same segments — does this one end
here, does this one pass through here — and the scan is the expensive
part, so they now ask together and the drawing is walked once.

Two things fall out of sharing the walk. The perpendicular path picks up
the non-finite guard the edge path already had, so a NaN vertex can no
longer reach the projection. And a perpendicular is now skipped when the
edge rays already cover its direction: at a square corner one segment's
perpendicular is the other segment's own ray, and two rays down the same
line are two identical-looking things for the cursor to choose between.

Behaviour is otherwise unchanged — same tolerance, same near-parallel
dedup, same cap of six each, same skipping of round wires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:23:53 +03:00
Hakan Seven 2920c4deee Merge pull request #696 from gianlucafiore/fix/695-perpendicular-tracking
Add perpendicular object snap tracking
2026-08-08 00:18:43 +03:00
Hakan SevenandClaude Opus 5 a4698c5b6b fix(units): leave the sheet viewport alone when converting
DWGUNITS reframed every viewport by the conversion factor, on the
reasoning that a viewport's framing is measured in model units and has to
follow them. That holds for a window onto the model; it does not hold for
a layout's sheet viewport, whose view is the paper itself, in paper
units. Scaling it left the sheet the size it was while the view of it
moved by a thousand.

Only a paper-space viewport can be a sheet — the model-space scale
reached everything else, and what it reached frames the model by
definition. Among the rest, the layout names its sheet outright, and a
file arriving without that link still gives itself away by sitting at the
paper origin, where only the sheet sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:11:57 +03:00
Hakan SevenandClaude Opus 5 62dfbfe2be fix(paper): give the sheet back its desk and its edge
A paper layout painted its whole canvas in the page colour, so the sheet
had no visible edge — zooming out never reached one, because the surround
was the same colour as the page. Floating viewports then painted
themselves black over it.

Both come from the same idea, that a viewport's canvas is a colour it can
be handed. What lies behind a viewport depends on what the viewport is:

- The full-canvas sheet shows the desk. `paper_bg_color` is the page, and
  the page is a bounded rectangle `paper_sheet_fill` already draws; using
  it for the canvas too erased the boundary between them. The desk is now
  its own colour, defined once in the scene and read by both the widget
  and the renderer, so the two cannot drift apart.
- A floating viewport sits on that sheet and must paint no canvas at all.
  Handing it a transparent one does not work: the background shader
  returns an opaque colour whatever alpha it is given, so alpha zero came
  out black. Skipping the pass is the only way to ask for see-through, so
  `skip_background` now suppresses both the clear and the pass. It is part
  of the frame signature too — without that, a cached frame would keep
  showing the canvas the flag was just turned off for.

Shading and fog still blend toward what is behind a floating viewport,
which is the page, so the uniform keeps that colour even though nothing
paints it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:11:47 +03:00
Hakan SevenandClaude Opus 5 6cbec232bb feat(units): read back every form the drawing writes
The readout learned to write lengths as 5'-6 1/2" and angles as
N 45d0'0" E, but typed input was still a bare parse::<f64>() — a
coordinate could be displayed and not entered. Give the formatters
their inverses.

parse_length takes feet and inches separated by a dash, a space, or
nothing, with the closing quote optional, and whole-plus-fraction in
any of those forms: 5', 60", 5'-9", 5'9-1/2, 9-1/2, 1/2. A leading
minus is a sign; the one inside 5'-9" is a separator, since after feet
there is nothing left to subtract from.

parse_angle reads the mark that says which convention an angle is in —
g for grads, r for radians, d/'/" for degrees-minutes-seconds, compass
letters for a surveyor's bearing — and reads a bare number in whichever
convention the drawing is set to.

ANGBASE and ANGDIR now apply, but only where they mean something. Which
way a thing points is measured from the drawing's zero and runs the way
it says; how wide an arc opens is the same number whatever zero is
counted from. format_direction and parse_direction are the pair for
directions, and the polar readout and polar input both go through them,
so the bearing shown is the bearing accepted. format_angle stays for
sizes, where applying a base angle would corrupt the value.

The command prompts follow the same rule, each classified rather than
swept: OFFSET distance, FILLET radius, CHAMFER distances, ARRAY and
MINSERT spacing, TRACE width, HELIX, and EXTRUDE height read lengths;
the four dimension text-rotation prompts, ARRAY's total angle and
INSERT's rotation read angles. MLINE's scale and INSERT's scale factor
are left alone — a multiplier has no unit to be written in. INSERT read
both through one parse; they are now separate.

The typed variants also take a decimal comma, which a single-value
prompt has no other use for. parse_length itself does not: inside a
coordinate the comma separates the axes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:46:49 +03:00
Hakan SevenandClaude Opus 5 523b85f42b fix(units): make the units selector reach what it names
The status-bar units button wrote INSUNITS, which nothing on screen reads.
INSUNITS only tells the drawing how to scale content arriving from
elsewhere, so picking a unit relabelled the drawing and changed nothing
visible — the selector looked broken because the variable it moved was
invisible.

Point the button at LUNITS instead, the setting that decides how every
coordinate and distance is written, and give the three questions the word
"units" runs together a place each:

- The button offers the five length formats, each shown beside a sample of
  itself, so Engineering and Architectural are told apart by sight.
- UNITS / DDUNITS opens a real dialog: length and angle format and
  precision, the direction angle zero points, whether positive angles run
  clockwise, and the insertion unit. Its sample comes from the same
  formatters the drawing uses, so it cannot drift from the readout.
- DWGUNITS converts. Model geometry, viewport framing, the header's length
  defaults and each dimension style's scale all move together in one undo
  step; paper space is left alone, since the sheet is still the same sheet.
  Locked layers are converted too — a half-converted drawing would be at
  two scales at once.

The coordinate readout formatted at four decimal places whatever the
drawing asked for; it now goes through the same format_length the
properties panel uses. That made the angle formats reachable in turn, so
degrees-minutes-seconds now carries the d/'/" marks that say which
convention it is, and surveyor's units are written at all.

Typed input is still plain decimal: a length the readout writes as
5'-6 1/2" cannot yet be typed back. ANGBASE and ANGDIR are stored but not
yet applied to displayed directions.

Closes #668

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:59:09 +03:00
Hakan SevenandClaude Opus 5 cfc7c06c6c fix(scale): keep the standard scales when the first one is written
A drawing that owns no annotation scales is shown the standard set, which
`scale_list` substitutes whenever the drawing's own list is empty. The
first thing to need a real scale object — an annotative dimension, a pick
from the status bar, a viewport scale — wrote just the one it wanted. The
drawing then had a list of its own, of exactly one entry, so the
substitution stopped and every other scale disappeared from the picker.

Bring the whole set in before writing that first scale. It is the set the
drawing was already being shown, so nothing appears that was not on offer
a moment earlier, and the drawing ends up owning its scales the way one
that shipped with a list does.

The seeding itself already existed for the scale manager, which stages it
on open; it simply was not reachable from the path that writes a scale.

Closes #666
Closes #683

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:19:41 +03:00
Hakan SevenandClaude Opus 5 26b4f29a13 feat(options): offer the file association in Options
Registering as the .dwg/.dxf handler was reachable only by typing
FILEASSOC, which is also the only thing that installs the icons the
desktop shows — so a user with no reason to know the command had no way
to arrive at either.

The checkbox makes the same two calls the command does, so the stored
setting and the actual registration cannot drift apart. A registration
the system refuses puts the checkbox back rather than leaving it showing
a state that is not true.

The line under it says the icons come with it, since that is what sends
people looking for this in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:51:06 +03:00
Hakan SevenandClaude Opus 5 7c8271c716 fix(icon): draw every icon from the logo file
Two places still showed the old mark after the logo was redrawn.

The window icon was built by drawing the letterforms stroke by stroke in
code, background colour and all, which made it the one icon in the
application not coming from assets/logo.svg. It now rasterises that file,
fitted to the square without distorting whatever aspect the logo has. It
returns nothing when the logo cannot be rendered, so the window falls back
to the platform default instead of to something invented here -- which is
how it ended up stale in the first place.

The icon installed into the icon theme was only ever written from inside
the desktop-entry write, behind that write's own "nothing changed" early
return. The entry's text does not change when the logo does, so once it
had been written the icon was never refreshed again. The two change for
different reasons; the icon is now checked first and on its own terms. It
already compares bytes, so an unchanged logo still costs only a read.

Worth knowing on Wayland: the compositor ignores the window icon entirely
and takes the dock icon from the desktop entry, so there the installed
theme file is the only one that matters -- and it refreshes when the file
associations are next set up, not at launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:37:59 +03:00
Hakan SevenandClaude Opus 5 36e328f7d7 feat(layers): add LAYTRANS to translate layers onto a standard
Drawings arrive from elsewhere carrying someone else's layer names, and
folding them into a set of house layers meant renaming and merging by
hand, one layer at a time.

LAYTRANS reads a layer set out of another drawing and moves this one onto
it. Given a file it maps every name the two already share and translates
at once, which is all most incoming drawings need. Bare, it opens a
translator: the drawing's layers on one side, the loaded set on the
other, mappings built up between them and applied only on Translate, so a
mapping can be reconsidered and the whole translation is a single undo
step. Mappings can be saved and read back, objects can be forced to
ByLayer so the target layer's properties are what actually shows, and the
result can be written out as a log.

The move itself is the one LAYMRG already made for a single pair, so
LAYMRG now goes through it too rather than keeping its own copy.

Three things fell out for free or nearly so. A standards file is a
drawing, so .dws and .dwt need nothing but a place in the file filter.
The entity list is flat and holds block definitions, so objects inside
blocks travel with everything else instead of being stranded on a layer
that no longer exists. And a target layer that this drawing already has
is left alone: the drawing has an opinion about it, and restyling it
would reach past what was asked for -- only newly created targets take
the standard's properties.

Mappings are saved beside the drawing rather than inside a DWS. That is
where AutoCAD keeps them, but putting them there would mean inventing a
place in the drawing that nothing else reads back.

Closes #624

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:51:15 +03:00
Hakan SevenandClaude Opus 5 f7590b1aa8 feat(plugin-api): let add-ons ask for a visual style by name
Removing the binary wireframe event left add-ons with no way to set a
visual style except by spelling out a command string. Give them the
intent back, in the vocabulary everything else now speaks.

The name is carried as text rather than the render-mode type: this crate
is dependency-free by default and only pulls acadrust in behind the host
feature, which is not a weight to add to the manifest contract for one
enum. The host resolves it against the same list its ribbon, its picker
and its commands read, so an add-on can offer exactly the styles the
application does and no others. An unknown name says so and lists the
ones that exist, from that same list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:29:01 +03:00
Hakan SevenandClaude Opus 5 b5a1146966 feat(view): one list of visual styles behind every way of choosing one
The ribbon offered four styles and dispatched each one's id as a command.
Two of those ids were not visual-style commands: "Shaded" carried SOLID,
which draws a 2D filled polygon, so choosing it started a draw command;
"Hidden" carried HIDDEN, which matched nothing at all, so it did nothing.
The intent had been for the button to fire its tool's event, but the
dropdown never reads one -- it dispatches the id.

Behind that sat two generations of the same feature. The render-mode
picker offered the seven styles a viewport can actually be drawn in;
everything else -- the ribbon, VSCURRENT, SHADEMODE, VISUALSTYLES and a
handful of bare verbs -- went through a binary wireframe-or-shaded flag
that could only approximate them, reporting "Hidden (shown shaded)" and
"X-Ray (shown as wireframe)" when asked for something it had no way to
draw. Four descriptions of one choice, each drifting on its own.

There is one list now. Each style names itself once -- mode, label, icon,
and the command that applies it -- and the ribbon, the picker, the
VISUALSTYLES verb and the interactive prompt all read from it, down to
the line that lists the choices, so what is offered cannot disagree with
what works. Keywords are the render modes' own names, since there is one
set of styles left to name.

The binary path is gone rather than kept alongside: its message, its
module event, the tool definitions that produced it, the ribbon's
special-cased highlight arms, the bare style verbs and the older keyword
spellings. X-Ray goes with it -- no render mode draws one, and it was
already coming out as a plain wireframe. Three files that had been left
holding a single icon constant each fold into the list.

Note: ModuleEvent::SetWireframe leaves the plugin API with it.

Closes #621

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:15:59 +03:00
Hakan SevenandClaude Opus 5 5c5c7606a1 feat(select): offer the selection modes at "Select objects"
Only Previous and Last were on offer, so Window, Crossing, Fence, the two
polygons and All could not be reached at all -- and with no keyboard to
type them into, the buttons were the only way in.

Window and Crossing fix the sense of the next box. Dragging derives that
from the direction the corner travels and would overwrite it the moment
the corner moved, so the derivation stands down while a keyword holds it.
Add and Remove decide what a pick does for the rest of the round: Remove
subtracts the way Shift already does, rather than through PICKADD, which
decides whether a pick *replaces* the set and so would have wiped
everything gathered so far. Both are dropped when the next command
starts, since neither outlives the selection that asked for them.

Fence and the polygons are picked point by point, which TRIM and EXTEND
already ask for. That gesture -- the points, the rubber-banded dashed
preview, whether the path closes -- moves into one place both bind to,
and the two preview builders duplicated in trim.rs go with it.

A fence is not the polygon test with crossing set: that closes the point
list and counts whatever lies inside, so a line drawn past a row of
objects would take everything behind it too. It gets a hit test that only
counts a real intersection, and skips the area-based geometry entirely.

The lasso gathered its hits from wires, hatches, block hatches, meshes and
block meshes inline; the new modes need the same list, and a second copy
would drift. Both now come through `Scene::path_hit_handles`.

Closes #596

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:11 +03:00
Hakan SevenandClaude Opus 5 22890a9411 ci: keep the issue-welcome run in Actions history
The job deleted its own run once the comment was posted, so a failure to
post left nothing behind to look at. Keep the run, and drop the
`actions: write` permission it needed only in order to delete itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:02:16 +03:00
Hakan SevenandClaude Opus 5 ca152de1c0 refactor(text): let acadrust carry the rotation through a transform
The merged fix added the rotation update on this side, matching only
`EntityTransform::Rotate`. That left `Affine` -- which several entity
paths and the UCS world transform emit -- still dropping the angle, and
it sat against the contract this crate already relies on: MIRROR is
delegated precisely because acadrust's entity-aware paths "handle
direction flags, stored-angle conventions and bulges themselves", and
`mirror_text` did exactly that for the same field.

Moved upstream (acadrust 9ecaffc), where one helper carries the angle for
every entity that keeps one. That covers rotation, affine and reflection
in a single place, and closes the same gap for SHAPE and the ATTRIB pair,
which a rotation left facing the old way too -- attributes noticeably so,
since INSERT already turned itself while its attributes did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:48:53 +03:00
Hakan Seven 05f875b1ff Merge pull request #692 from gianlucafiore/fix/690-text-rotation
Fix text rotation transforms
2026-08-07 11:40:12 +03:00
Hakan SevenandClaude Opus 5 0af6b470ed refactor(snap): measure typed distance along the guide that is drawn
Extension answered "which acquired ray is this snap on?" twice, from two
different tests: the overlay picked its guide by off-ray distance in
screen pixels, while typed-distance entry re-searched the tracking set in
world space. Two tests can disagree, and the ways they disagree are both
bad -- a guide on screen that typing ignores, or a distance running off
along a ray the user cannot see. The second search could also come up
empty on its own, since only four acquisitions are kept and the oldest is
dropped as new ones arrive.

Ask once. The search that draws the guide now also reports the endpoint it
drew from, the snap carries it, and typed distance reads the ray straight
off that -- the direction is just the way from the endpoint to the snap,
which sits beyond it by construction. The screen-space test stays the only
one, which is what the guide wanted anyway: it stays scale-independent
where a world test would not.

Also correct the tracking-point doc: it claims XZ with Y=0, but
`edge_dirs_at` builds every direction from X/Y with Z zeroed and the rays
follow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:29:15 +03:00
Hakan SevenandClaude Opus 5 b8f9727a27 fix(grip): stop double-offsetting the UCS grip snap marker
The extension anchors needed the same pane-to-canvas lift the snap
marker already got, but the block that added it was placed next to the
one that was already there rather than merged into it, so `screen` took
the tile origin twice. A single full-canvas tile has no origin to add, so
it only shows once the viewport is split. Fold the anchors into the
existing block.

Also require the ray to pass through the snapped point before offering it
as a distance reference. Only four acquisitions are kept and the oldest is
dropped as new ones arrive, so the ray that produced a snap can be gone by
the time a distance is typed; picking the nearest survivor unconditionally
sent the point off along an unrelated ray instead of falling back to plain
point input. `extension_snap` projects onto the ray in exact f64, so the
tolerance only has to clear rounding, scaled with the coordinate to hold at
UTM magnitudes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:22:04 +03:00
Hakan Seven 687bc2079d Merge pull request #691 from gianlucafiore/fix/grip-direct-distance
Support tracking and direct distance input for grip editing
2026-08-07 11:16:49 +03:00
Hakan SevenandClaude Opus 5 e5974a8895 fix(setvar): refresh geometry the variable is baked into
LTSCALE scales the dash pattern baked into every wire, and PDMODE /
PDSIZE decide the point glyph built at tessellation time. Each has its
own command that invalidates for exactly that reason -- and PDMODE /
PDSIZE are also reached from the point style dialog and the viewport
setting, both of which invalidate too. Only SETVAR, the fourth way in,
set the header and stopped there, so the drawing kept rendering the old
value until some unrelated edit happened to rebuild it.

Invalidate the same way each variable's own command does, alongside the
TEXTFILL case already handled here.

The rest of the SETVAR list needs nothing: FILLMODE is read at render
time and hashed into viewport state, CELTSCALE and TEXTSIZE only seed
new entities, SPLFRAME is never read outside this file, and the others
are behaviour rather than geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:10:42 +03:00
Hakan SevenandClaude Opus 5 30b8974b73 fix(solid3d): pick up the SAT record-splitting fix
A drawing whose bodies are built only of planes opened without error but
drew nothing, while the solids still selected. Pre-7.0 SAT writes strings
as a length and the characters, so one may contain the record
terminator; ODA's builder emits `8 STEP Id#` inside a dxid-attrib. The
parser split there, every later record answered to the wrong index, and
faces resolved their surface pointer to a coedge or a vertex, so nothing
meshed.

Fixed upstream in acadrust 41149d7. All 22 solids in the reported drawing
now parse to exactly the record count their header declares, and all 22
mesh.

Closes #670

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:27:18 +03:00
Hakan SevenandClaude Opus 5 5c7aeca321 fix(solid3d): close torn cylinder walls from truck revolutions
A revolution comes back from rsweep as three 120-degree segment faces,
but every hole was attached to the first of them. A window wider than a
segment cannot sit on one face, so that face turned invalid and its
whole third of the tube vanished -- the pump's cylinders lost 120
degrees of wall each and the solid read as open where they met the body.
One of them shed a segment with no hole involved at all, so chasing the
individual causes would not have covered it.

Measure the result instead: after a cone face whose boundary closes the
revolution is triangulated, check that the meshed vertices actually span
it, and hand the face to the parametric sampler when they do not. That
catches a tear whatever produced it, while genuinely partial cones and
every non-cone surface pass straight through -- the tee's pierced run
cylinder still meshes through the kernel.

Also bring truck's chord tolerance in line with the detailed LOD. Truck
emits a single mesh rather than an LOD ladder, so a tenth of the radius
left a wide pipe as a hexagonal prism whose flats sank nine units inside
the true surface, pulling the wall away from the caps and planar faces
that meet it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:29:41 +03:00
Hakan SevenandClaude Opus 5 2a1a8f4b94 fix(solid3d): read cone radius from the major axis
The cone-surface record's trailing real is a parameter scale, not
geometry. It equals the radius on many bodies, so cylinders generally
came out right, but where the two diverge the lateral surface was built
at the wrong size while the flat caps stayed on their own rims -- a
gauge dial drew as a ring around its own face, its band at 43.2 against
caps at 34.56.

The major axis is the radius vector at the reference cross-section, so
its length is the radius. Route every read through one helper and keep
the record's field only as a fallback for a degenerate major axis, so
the band, the silhouette generators, the isolines and the coaxial-rim
check all size themselves the same way. Taper is unchanged: the major
axis is still the h=0 radius that radius + h*tan(a) extends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:00:22 +03:00
Hakan SevenandClaude Opus 5 01d8b87db1 fix(solid3d): trim sphere caps and pierced cone legs
Sphere faces drew as complete balls. ACIS parametrises a sphere about
any pole, so a vessel's dished head is commonly bounded by a circle that
is no line of latitude in that frame, and a theta/phi bounding box of
such a boundary covers nearly the whole ball. Derive the window
analytically instead: when a face's loops are circles lying on the
sphere, re-pole the frame onto their common axis, where each seam is an
exact latitude at acos(d / R). One ring bounds a cap, two bound a zone;
the material side comes from the boundary winding, reusing the helper
the torus path already had.

Separately, the longitude wrap flag was resetting the colatitude window
to the whole meridian. A cap centred on the pole covers every longitude
while still ending at its seam, so that reset grew every cap back into a
ball. Trim the two angles independently and rename the flag theta_full
so the split reads at each use, including the silhouette generator,
which skipped the colatitude test the same way.

Cone faces took their height span from the first boundary loop alone. A
lateral face closed off by one rim and one pierce curve -- a pipe branch
meeting its run -- carries its two ends on different loops, so the tube
was sized to whichever came first and the leg above the junction went
undrawn. Read every loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:44:49 +03:00
Hakan Seven 67a767e23b fix(view): pause hover during navigation 2026-08-06 00:21:44 +03:00
Hakan Seven afcf7baead fix(solid3d): honor torus trim winding 2026-08-06 00:07:25 +03:00
Hakan Seven c45daeffb6 feat(render): add scene environment pipeline 2026-08-05 23:42:18 +03:00
Hakan Seven b667eeeb05 refactor(render): unify scene rendering paths
Route model, paper, viewport and nested block content through one traversal while preserving leaf renderers. Carry decoded material state into mesh shading and harden solid geometry conversion.
2026-08-05 21:44:59 +03:00
Hakan Seven afc94dce13 feat(qselect): expand filter workflow
Refs #511
2026-08-05 13:40:53 +03:00
Hakan Seven e07f4f959b fix(view): preserve projection framing
Use full 3D bounds to derive a safe perspective eye distance without changing the camera target, orientation, or field of view.

Refs #502
2026-08-05 12:47:46 +03:00
Hakan Seven 5a0c286a10 chore(release): prepare v0.9.3 2026-08-05 01:03:51 +03:00
Hakan Seven deaecdfb1e fix(ribbon): mask tools on start
Refs #494

Refs #507
2026-08-05 00:52:52 +03:00
Hakan Seven b92b8df997 fix(trim): handle lasso and box selection
Keep selection gestures in the active trim flow so crossed segments are modified instead of selected.\n\nRefs #487
2026-08-05 00:26:29 +03:00
Hakan Seven 474bfcc7db fix(input): restore arrow key navigation
Route editor and command-line arrows by active context and recall successfully dispatched commands.\n\nRefs #485
2026-08-05 00:08:35 +03:00
Hakan Seven 657d023fb7 feat(ucs): integrate coordinate systems
Route coordinate input, geometry creation, transforms, properties,\nviews, and viewport persistence through a shared working plane.\n\nRefs #484
2026-08-04 23:17:54 +03:00
Hakan Seven b1d55c6a7e feat(view): add visual style previews
- preview render modes on hover without committing
- distinguish 2D and 3D wireframe fill and depth behavior

Refs #483
2026-08-04 21:23:25 +03:00
Hakan Seven 44d0c65ae9 feat(ui): add viewport tooltips
Reuse ribbon tooltip styling for every viewport toolbar action and expose translated action names with related commands.

Refs #482
2026-08-04 19:10:47 +03:00
Hakan Seven 7c893d8f5f feat(ui): refine drawing tabs
Use theme-driven active and hover states, integrate close controls, and keep tab spacing stable across drawing states.

Refs #481
2026-08-04 19:04:59 +03:00
Hakan Seven 9cf0c9f945 fix(ui): refine command line and clipboard
- preserve command history colors after reopening
- remove collapsed command bar residue and split the unsaved prompt
- read system text when the drawing clipboard is empty

Refs #467
Refs #468
Refs #470
2026-08-04 18:53:08 +03:00
Hakan Seven d1f9055901 feat(ui): refine ribbon and view navigation 2026-08-04 18:21:52 +03:00
Hakan Seven 675538eb8d fix(layers): disable redundant set current
Refs #459
2026-08-04 17:37:19 +03:00
Hakan Seven c9550b48f9 feat(properties): refine quick properties
- Keep the floating panel near the selection and inside the viewport
- Persist the toggle and hide the panel without a selection
- Show only Material in 3D Visualization
2026-08-04 17:32:06 +03:00
Hakan Seven 5ad95d1d2e fix(context-menu): keep menu inside viewport
Refs #457
2026-08-04 17:10:27 +03:00
Hakan Seven b0806679c9 feat(command-line): resize history panel
Refs #456
2026-08-04 16:54:59 +03:00