Autosave files are unsaved drawings. The autosave folder was made with
create_dir_all, by the first write or for the lock file, so on Unix it
got the default mode and other accounts on the machine could read the
files. A folder the autosaver creates is now 0o700. A folder that already
exists keeps its mode, since SAVEFILEPATH can be any folder the user
picked.
The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
* Evaluate spline basis functions on the stack
`basis()` allocated three Vecs for every evaluated point, and a large
drawing evaluates millions of points while computing its extents on
open: `basis` was 10% and `calloc` 6% of `cadcraft-cli info` on the
1.1 M-entity drawing of #1076. `eval` now works in fixed arrays up to
degree 10 (the cap of the DXF reader and the SPLINE command) and falls
back to the heap above it. Results are bit-identical to the old code,
which the test keeps as its reference.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Validate a spline once per tessellation
`tessellate` called `eval` for every sample, and `eval` re-ran
`is_valid` (a walk over the whole knot vector) and `domain()` each
time, so a spline with many control points cost O(knots) per point.
Validate and read the domain once, then evaluate the samples with the
checked variant. Part of the open time of large drawings (#1076).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Size spline bounds sampling relative to the spline
`Spline::bounds` tessellated at an absolute tolerance of 1e-3, so every
spline of a drawing in millimetres hit the 4000-point cap: the extents
computed on open evaluated 12 M points for the 3072 five-point cubics
of the 1.1 M-entity drawing of #1076, a third of its open time in the
CLI and the GUI alike. Sample at 1e-3 of the spline's own control-box
diagonal instead, with at least four samples per knot span, straight
into the box without an intermediate Vec. The box of a typical spline
now takes a few hundred evaluations and stays within 1e-3 of the
spline's size of the finely sampled one (3.4e-4 at worst over the test
set); a degree-1 spline gets its exact control box.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Give a spline with a non-finite control point its control box
`Bounds2::from_points` skips a non-finite point, so the control box
and its diagonal were finite while a NaN or infinite control point
poisoned every sample (`c * 0.0` is still NaN): `bounds` returned the
empty box instead of the box of the finite control points that the
other degenerate cases get. Check the control points before sampling.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Size spline bounds sampling by a dimensionless ratio
`bounds` passed `diag * 1e-3` to `sample_count`, whose absolute floor
of 1e-9 on the tolerance made the count absolute again for a spline
with a diagonal under 1e-6: at a scale of 1e-12 every spline got the
minimum eight segments per degree whatever its shape, and an extreme
between two samples exceeded the 1e-3 margin the box promises. Derive
the count from the ratio of control-polygon length to tolerance in a
helper that `tessellate` (with its floor, unchanged) and `bounds`
(with the ratio to the diagonal) share, so a scaled copy of a spline
samples exactly like the original. The scale test now also runs at
1e-9 and 1e-12.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Sample spline bounds over every knot span
`bounds` sampled one uniform grid over the parameter domain; the
four-per-span floor only raised the count. A narrow non-empty knot
span (a degree-2 peak supported on 0.5003..0.5004, say) fell between
grid points, at any count up to the cap, so the box missed part of
the curve and zoom extents clipped it. Spread the samples over the
non-empty knot spans in proportion to their length with a floor of
four per span, ends included, still folded straight into the box. The
cap holds over all spans: the floor drops to two or three when four
would exceed it, and a spline with more spans than two each allow
falls back to the grid. A typical spline evaluates as many points as
before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Hold a narrow knot span to eight samples, and say what that buys
The doc said the box falls short of an extreme by far under 1% of the
spline's size, which holds where the budget sets the count but not in a
span held to the floor: four segments across a degree-2 piece can miss
an off-grid peak by about 3% of the control box. Eight segments bring
that under 1%, and the comment now says both figures. A test puts the
peak between the grid's samples and checks the margin; four segments
would miss it by more.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Give a spline with more spans than the cap samples its control box
Past two thousand knot spans the grid of the cap's four thousand
samples can step over a narrow span and leave its extreme out of the
box, which selection uses as a pre-filter. The control box contains the
curve whenever no weight is negative (the convex hull property), so
such a spline gets that box, conservative rather than sampled; only a
negative weight, which voids the property, keeps the grid.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the degree-1 shortcut for positive weights
A degree-1 spline is its control polygon only under the convex hull
property, which a negative weight voids: with control (1, 0), (2, 0)
and weights 1, -0.5 the curve passes (4, 0), outside the control box
that bounds() returned for every degree-1 spline. The shortcut, like
the many-spans one, now needs every weight positive; otherwise the
spline is sampled like any other.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Sample a degenerate spline whose weights are not positive
Coincident control points with zero weights evaluate to the origin,
not to the point they sit on: eval() leaves the unnormalised sum alone
when the weights sum to nothing. The zero-diagonal shortcut returned
that point's box all the same. Like the degree-1 and many-spans
shortcuts, it now needs positive weights; otherwise the curve is
sampled, and the box holds what eval() gives.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A block reference's own linetype scale (DXF group 48) was ignored for the objects in the block:
a DASHED line inside a block inserted at linetype scale 2 kept dashes of scale 1. It now
multiplies their linetype scale, as LTSCALE does for the whole drawing (and as a dimension's
does for its block).
Claude-Session: https://claude.ai/code/session_01VP2GTYMskgGjiMw5GT5NL9
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Interface Language dialog: list the languages alphabetically by their own name
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
* Options dialog lists interface languages in the same order as the picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* AutoLISP: entmake defines blocks with BLOCK ... ENDBLK (fixes#1662)
A BLOCK list wasn't an entity, so entmake returned nil and the entities
after it went into model space. Now a BLOCK list begins a definition
(name, flags, base point, description), the entities made meanwhile go
into it, and ENDBLK adds the block (redefining one of the same name, or
naming an anonymous *U block) and returns its name. An open definition,
an xref, an invalid name or a block that inserts itself is refused with
nil, using BLOCK's own name and self-reference checks.
* AutoLISP entmake blocks: review fixes (anonymous names keep a star, no redefinition during REFEDIT, a failed routine drops its open definition)
An anonymous block (flag 1) named without a * got a name like FO1; it now
gets *U. ENDBLK refuses to redefine a block REFEDIT is editing, as BLOCK
does. A routine that fails drops the definition it began, so the next
routine's entities go into the drawing; one that ends normally still
leaves it open.
* AutoLISP entmake blocks: refuse an anonymous block that inserts itself
The self-reference check ran only for named blocks, so an anonymous *U
block holding an INSERT of the name it was about to get (*U1 in an empty
drawing) was stored. The check now runs after the anonymous name is
given, for both kinds.
* AutoLISP entmake blocks: create a definition's layers only when the block is added
Each pending entity created its layer at once, so a definition ENDBLK
refused (a self-reference, REFEDIT) or a failed routine dropped left an
orphan layer. end_block now creates the layers after its checks pass,
just before it adds the block.
Found while testing crates/io/src/svg_read.rs:576-593 (parse_length_mm), used at 605-612 (viewport)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* Make properties.set apply thickness and properties report it (#1722)
* test: CIRCLE returns its new object as `handle`
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Report DWGPREFIX, DWGTITLED, FILEDIA, CMDECHO and more from GETVAR (#1715)
* Keep CMDECHO and FILEDIA in session settings so they survive drawing switches (#1715)
* Leave LOGINNAME out of GETVAR so scripts and agents can't read the user's login name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Choose crosshair and pickbox ink from the painted paper or surround
under the pointer, including while editing through a layout viewport.
Reuse the existing contrast rule and preserve model-space appearance.
Add paint-output regression tests for backgrounds, navigation,
space changes, and isometric and full-screen cursors.
Fixes#1685.
Found while testing crates/io/src/svg_read.rs:892-894 (arc flags read via Numbers::next_num, 675-709)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* i18n(sv): translate 52 new strings, use the agreed terms in 19
Brings the Swedish catalog up to date with upstream main (0b32c2e).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n(sv): use the agreed terms in 1
Brings the Swedish catalog up to date with upstream main (e86eacf).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(snap): keep an arc's sweep for TAN, PER and deferred snaps
An arc was snapped as its full circle: TAN and PER solutions on the circle
that fall outside the arc's sweep were offered as if they touched the arc,
and a deferred tangent lost the sweep when it was stored.
- SnapCurve gains an Arc variant carrying start and end, so a deferred snap
keeps the sweep and resolves onto the arc.
- TAN and PER filter their solutions with Arc::contains_angle; PER clamps to
the arc with Arc::closest instead of the circle's closest point.
- Tangents and perpendiculars between two deferred snaps are filtered on
both ends.
- The viewport projection maps the new variant.
- Tests: a tangent in the gap is not offered, the perpendicular clamps to an
end, and a deferred tangent keeps its sweep and solves onto the arc.
Fixes#1697
* fix(snap): correct direct PER, support Arc in deferred pairs
Addresses the review on #1698:
- Direct PER on an arc used `arc.closest(bp)`, which clamps a gap solution to an
end and misses the valid opposite antipode; both antipodes are now offered,
filtered to the arc's sweep.
- `deferred_pairs` matched only circles and lines, so any deferred pair with an
arc operand fell through to None. An arc is now matched as its carrier circle
for the math, with the sweep filter still applied to the solutions.
- Tests: the direct PER case now expects the antipode on the arc, and a
TAN-TAN pair between an arc and a circle only keeps the tangent on the arc.
* test(snap): use a margin over the boundary in the arc PER test
Addresses the review on #1698: the cursor is 5 units from the antipode, so the
aperture is 5.5 rather than 5.0 to keep the distance comparison off the
boundary; the comment now describes the antipode rather than the nearest end.
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* i18n:Add missing translations for the print dialog and page settings.
* i18n:Add missing translations for the print dialog and page settings.
* i18n(zh): drop rows that repeat existing keys and the unused 'Page setup of'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add new translations to zh.tsv
Added translations for some newly added features.
* i18n : Add missing hatch dialog translations
* Localize modal labels and button texts
* Refactor layout label for better localization
Update layout label to use internationalization for 'None'.
* Add various measurement units in Chinese to zh.tsv
* Refactor closing prompt to use crate translation
* zh: drop rows main already translates (duplicate keys fail the catalog test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* DXF/DWG: keep a file's own arrowhead blocks with standard names
Reading dropped every block with a standard arrowhead name (`_Dot`,
`_DotSmall`, ...) that no INSERT used, since CADCraft regenerates its own on
save. A block only a dimension style names (DIMBLK) was dropped too, so
AutoCAD's `_Dot` (a polyline with width) or a user's redefinition came back
as CADCraft's disc of 24 SOLIDs. Now only blocks whose content is exactly what
CADCraft writes are dropped; any other content is kept and saved as it was,
through DXF and DWG. PURGE keeps arrowhead blocks that dimension styles or
dimension overrides name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Arrowheads: compare all properties, trim names in PURGE, test every standard arrow
is_generated_arrow now requires the whole Common to match, so only blocks
exactly as CADCraft writes them are regenerated. PURGE trims arrowhead names
from dimension styles and overrides. A new test writes every standard
arrowhead to DXF and DWG and checks none reads back as a user block. Review
of #1694.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
* i18n(pt-BR): native-speaker terminology review
Fix mistranslations (Thaw Layer read "Ativar camada", Snap type and the
snap settings label were translated as object snap, Revolve read
"Revolucionar", "Sobre-restrito" spelling) and make the CAD vocabulary
consistent with Brazilian usage: "snap" for every snap mode, "viewport"
and "grip" kept as loanwords, "Multilinha", "Explodir".
Update the Portuguese row in localization-parity.md (it still read 0%)
and the pt-BR note in localization.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2
* i18n(pt-BR): align offset and layer on/off wording with Brazilian usage
Brazilian university AutoCAD handouts (UFSC, UFSCar, UFRGS) keep
Offset as a loanword and describe layers as ligar/desligar.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2
* i18n(pt-BR): fix Bind, keep prompt keywords and SPDX in English
Bind merges an xref into the drawing, so Integrar, not Vincular.
Prompt keywords (Top/Zero/Bottom, Multiple) stay in English because
the command line only accepts the English keywords.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2
---------
Co-authored-by: Rodrigo Gressler <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Inserting a DWG/DXF as a block merged its layers, linetypes and text,
dimension, multileader and table styles, but not its multiline styles.
A drawing that brings its wall styles in by inserting a library file
(AutoLISP add-ons do this: INSERT walls.dwg, ERASE the reference, then
MLINE with one of its styles) got "Cannot find multiline style". The
styles now merge like the other tables: a name the drawing already has
keeps the drawing's definition.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The workflow-level contents: write granted every job the right to push
tags and releases; the reusable release workflow is the only job that
needs it. Workflow level drops to read.
🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
* Xrefs and data links: use resolve_absolute for stored paths
* CI: check that git dependencies come from storytold
* CI: release only with git dependencies from storytold
* Parse Cargo.lock as TOML when checking git sources
* Use the shared git-sources check in CI
* Parse cargo config, legacy .cargo/config included, in the git-sources check
ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
* Build: line tables only in dev, no debug info for dependencies
* Tests: one integration-test binary per crate
Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.
* xtask ci: nextest, tests without examples, one wasm check call
All flagged scripts run under bash (shebang or '# shellcheck shell=bash'), so
[[ ]] is safe. Positional parameters in helper functions are copied into named
locals, and helper functions end with a bare 'return', which keeps the exit
status of the last command (no behaviour change, notably for sha256 under
pipefail). packaging/linux/postinst.sh is POSIX sh and is left alone.
user-scalable=no stops people with low vision from zooming the page. The
canvas keeps its own pinch/pan handling: eframe already prevent_default()s
touch and gesture events on the canvas, so dropping the flag only restores
browser zoom (and accessibility zoom) outside of the app's gestures.
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).
It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).
- `resolve_font` rejects absolute and `..` manifest paths and requires the
canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
directive; `one_line` collapses CR/LF in checkout-derived diagnostics.
Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.
The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.
Addresses finding row 26.