Compare commits

..
36 changed files with 467 additions and 656 deletions
+36 -2
View File
@@ -23,5 +23,39 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+39 -1
View File
@@ -24,9 +24,47 @@ jobs:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: credits
needs: [git-sources, credits]
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
Generated
+17 -18
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"acadrust",
"log",
@@ -697,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -798,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1224,7 +1224,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -5994,7 +5993,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"chrono",
"serde_json",
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.20.1"
version = "0.20.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -71,7 +71,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+1 -85
View File
@@ -16,31 +16,10 @@ use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
/// combine permissions from one version of a path with the group of another.
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let key = match (absolute.parent(), absolute.file_name()) {
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
_ => absolute,
};
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
locks.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
lock
} else {
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
lock
}
}
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
@@ -54,15 +33,13 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
// A symlinked destination is resolved so the link stays a link and its target is replaced.
let resolved = resolve_symlink(path)?;
let path = resolved.as_deref().unwrap_or(path);
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
write_atomic_with(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
@@ -90,30 +67,10 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
write_atomic_with_locked(path, permissions, fill)
}
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
pub fn write_atomic_with_permissions(
path: &Path,
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = permissions()?;
write_atomic_with_locked(path, permissions, fill)
}
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
#[cfg(unix)]
let permissions = keep_group(&file, path, permissions);
file.set_permissions(permissions)?;
}
fill(&mut file)?;
@@ -133,20 +90,6 @@ fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill:
Ok(())
}
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
/// if the file is still in the old file's group, so the group is restored first. When it can't be
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
#[cfg(unix)]
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
return permissions;
}
Permissions::from_mode(permissions.mode() & !0o070)
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
@@ -288,33 +231,6 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
/// says) together with its mode, so the group bits never name a different group.
#[cfg(unix)]
#[test]
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&dest).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&dest, None, Some(gid)).unwrap();
}
let want = std::fs::metadata(&dest).unwrap();
write_atomic(&dest, b"new").unwrap();
let got = std::fs::metadata(&dest).unwrap();
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
+12 -9
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
if !name.eq_ignore_ascii_case(&self.name) {
restore_saved_definition(&mut d, host, &self.name);
keep_description(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,13 +176,16 @@ impl BlockEdit {
}
}
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -299,7 +302,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
restore_saved_definition(editor, &d, &was);
keep_description(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+1 -56
View File
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-xref", "External References", run_dash_xref)
.alias(&["-xr"])
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
CommandSpec::new("xbind", "Bind...", run_xbind)
.menu(&["Modify", "Object", "External Reference", "Bind..."])
@@ -201,46 +201,6 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
/// The xrefs a JSON call names (`names`, default all); an error when none match.
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
if p.get("exact").and_then(Value::as_bool) == Some(true) {
let lits: Vec<&str> = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
Some(Value::String(t)) => vec![t.as_str()],
_ => str_param(p, "name").into_iter().collect(),
};
let d = s.doc()?;
let mut names = Vec::new();
let mut selected = std::collections::HashSet::new();
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
let mut folded = std::collections::HashMap::new();
for literal in &lits {
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
continue;
}
let normalized = literal.to_ascii_lowercase();
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
if let Some(block) = matches.next() {
if matches.next().is_some() {
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
}
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
}
}
if names.is_empty() {
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
}
return Ok(names);
}
let pattern = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
Some(Value::String(t)) => t.clone(),
@@ -263,19 +223,6 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(r)
}
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
/// command must refuse before it changes anything.
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
for n in names {
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
}
}
Ok(())
}
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
let host = host(s);
@@ -305,7 +252,6 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"detach" | "d" => {
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
let mut erased = 0;
for n in &names {
erased += load::detach(s.doc_mut()?, n);
@@ -330,7 +276,6 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
let d = s.doc_mut()?;
for n in &names {
if let Some(b) = d.blocks.get_mut(n) {
+1 -17
View File
@@ -313,23 +313,7 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
// Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
let mut seen = std::collections::HashMap::new();
names
.iter()
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(n.as_str());
load_with(d, n, host, chosen, &mut cx)
}
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
name: String::new(),
status: XrefStatus::Unresolved,
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
}],
})
.collect()
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
}
impl Ctx {
+11 -95
View File
@@ -625,40 +625,6 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
#[cfg(unix)]
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
let mut mode = src_mode & 0o777;
if let Some((old_mode, old_gid)) = old {
mode &= old_mode;
if old_gid != src_gid {
mode = without_group(src_mode, mode);
}
}
mode
}
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
/// drawing's group bits (`src_mode`).
#[cfg(unix)]
fn without_group(src_mode: u32, mode: u32) -> u32 {
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
}
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
#[cfg(unix)]
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
return Ok(());
}
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
@@ -682,23 +648,19 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let source_metadata = src.metadata()?;
#[cfg(unix)]
let first_backup = std::cell::Cell::new(false);
#[cfg(unix)]
let permissions = || {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
first_backup.set(old.is_none());
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
};
#[cfg(not(unix))]
let permissions = || Ok(None);
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
#[cfg(unix)]
if first_backup.get() {
adopt_source_group(out, &source_metadata)?;
}
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
@@ -1069,52 +1031,6 @@ mod tests {
use crate::Session;
use crate::temp_dir::TempDir;
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
/// it would keep the old `.bak`'s group while the drawing is in another.
#[cfg(unix)]
#[test]
fn backup_mode_drops_group_bits_for_a_different_group() {
use super::backup_mode;
assert_eq!(backup_mode(0o640, 10, None), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
}
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
/// group, as `id -G` says), never the group the new file would be created in.
#[cfg(unix)]
#[test]
fn first_backup_takes_the_drawings_group() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = TempDir::new("first-bak-grp");
let src = dir.join("a.dxf");
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&src).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&src, None, Some(gid)).unwrap();
}
super::backup(&src).unwrap();
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
}
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
#[cfg(unix)]
#[test]
fn without_group_drops_group_bits_and_caps_other() {
assert_eq!(super::without_group(0o640, 0o640), 0o600);
assert_eq!(super::without_group(0o604, 0o604), 0o600);
assert_eq!(super::without_group(0o644, 0o644), 0o604);
}
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
+4 -52
View File
@@ -26,30 +26,11 @@ const MAX_PURGE_PASSES: usize = 64;
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
pub(super) fn default_path(s: &Session, layout: &str) -> String {
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
// beside the drawing, so those become `_`.
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file),
None => std::path::PathBuf::from(file),
};
if !candidate.exists() {
return candidate.to_string_lossy().into_owned();
let file = format!("{layout}.dwt");
match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
None => file,
}
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
let alternative = (2u64..)
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
.find(|alternative| !alternative.exists())
.unwrap_or(candidate.clone());
alternative.to_string_lossy().into_owned()
}
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
@@ -152,32 +133,3 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
}
}
#[cfg(test)]
mod default_path_tests {
use super::*;
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
#[test]
fn layout_name_is_one_file_name() {
let s = Session::empty();
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
let p = default_path(&s, name);
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
assert!(p.ends_with(".dwt"));
}
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
}
/// Windows device names are prefixed so the suggestion is never a device.
#[test]
fn reserved_device_names_are_prefixed() {
let s = Session::empty();
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
}
for name in ["COM0", "CONSOLE", "COM10"] {
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
}
}
}
+2 -3
View File
@@ -108,9 +108,8 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
pub fn wildcard(pat: &str, text: &str) -> bool {
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
// Iterative matcher with single back-track point (linear-ish, no recursion).
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
+4 -47
View File
@@ -27,14 +27,8 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
if !writing {
match read_text_file(&path) {
Some(t) => {
// Each line has a String header and a separately allocated buffer. Charge a
// conservative 64 bytes per line for both, including the allocator's minimum
// allocation size, before collecting so many short lines cannot evade the budget.
let count = t.lines().count();
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
f.lines = std::collections::VecDeque::with_capacity(count);
f.lines.extend(t.lines().map(str::to_string));
charge_bytes(t.len())?;
f.lines = t.lines().map(str::to_string).collect();
}
None => return Ok(Flow::Value(Value::Nil)),
}
@@ -106,19 +100,8 @@ fn can_write(_path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn write_text_file(path: &str, text: &str, append: bool) {
use std::io::Write;
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
// instead of stalling the interpreter; only a regular file is then truncated and written.
let mut o = std::fs::OpenOptions::new();
o.create(true).write(true).append(append);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.custom_flags(libc::O_NONBLOCK);
}
if let Ok(mut f) = o.open(path)
&& f.metadata().is_ok_and(|m| m.is_file())
&& (append || f.set_len(0).is_ok())
{
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
if let Ok(mut f) = f {
let _ = f.write_all(text.as_bytes());
}
}
@@ -135,29 +118,3 @@ fn file_exists(path: &str) -> bool {
fn file_exists(_path: &str) -> bool {
false
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
#[test]
fn write_to_a_fifo_without_a_reader_does_not_block() {
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
write_text_file(fifo.to_str().unwrap(), "x", false);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
let f = dir.join("f.txt");
std::fs::write(&f, "long old text").unwrap();
write_text_file(f.to_str().unwrap(), "new", false);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
write_text_file(f.to_str().unwrap(), "+", true);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
std::fs::remove_dir_all(&dir).ok();
}
}
-37
View File
@@ -1,37 +0,0 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
-22
View File
@@ -190,28 +190,6 @@ fn reload_unload_path_and_detach() {
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
// and reload-all loads one of them once.
let mut twin = Block::new("SITE");
twin.xref_path = Some("site.dxf".into());
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
// Detaching one of them, or giving both a new path, is refused before anything changes.
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
assert!(s.doc().unwrap().block("site").is_some());
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
for k in ["site", "SITE"] {
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
}
s.doc_mut().unwrap().blocks.remove("SITE");
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
assert!(s.doc().unwrap().block("site|Tree").is_none());
+3 -28
View File
@@ -43,16 +43,13 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
// The line's text is not repeated: it comes from the checkout and goes to the build output.
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line {n}"));
return Err(format!("malformed manifest line: {line}"));
};
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
@@ -76,28 +73,6 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
Ok(out)
}
/// The most a manifest may hold; the real one is a few KiB.
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
use std::io::Read as _;
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !file.metadata().is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let mut text = String::new();
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
}
Ok(text)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
+1 -10
View File
@@ -184,9 +184,6 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
let mut under_start: Option<f64> = None;
let mut over_start: Option<f64> = None;
let mut gap = stroke::GAP * sc * wf;
// Retained fallback-outline points over the line, as the TrueType path caps them: a short string
// repeating one detailed glyph would otherwise copy its outline per occurrence.
let mut points_left = ttf::MAX_SHAPED_POINTS;
// The simple path lays a line out left to right: one asked to run right to left goes through
// the bidi path even without right-to-left letters.
if (complex::needed(s) || base_rtl == Some(true))
@@ -205,13 +202,7 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
Some(g) => {
let (y, scale) = stroke_fallback_metrics(&g, c, h);
let contours = match points_left.checked_sub(g.points) {
Some(rest) => {
points_left = rest;
ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear)
}
None => Vec::new(),
};
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
-54
View File
@@ -153,40 +153,6 @@ pub struct MTextLayout {
pub bounds: Bounds2,
/// Plain text of each laid-out line.
pub lines: Vec<String>,
/// Whether the geometry stops short: the layout reached its allowance of retained points
/// ([`Allowance`]) and the segments after that are not drawn.
pub truncated: bool,
}
/// The points all the segments of one layout may keep together, each counted for both copies the
/// layout holds: the allowance one shaped line has (`MAX_SHAPED_POINTS`), so a short repeated detailed glyph over many segments cannot multiply it.
pub(crate) struct Allowance(usize);
impl Allowance {
pub(crate) fn new() -> Self {
Allowance(crate::ttf::MAX_SHAPED_POINTS)
}
/// Whether any allowance is left, before a segment is shaped.
pub(crate) fn left(&self) -> bool {
self.0 > 0
}
/// Charge the points of `sh`; false (and nothing left) when it does not fit.
pub(crate) fn charge(&mut self, sh: &Shaped) -> bool {
// Kept twice: in the piece and again in the layout's flat `strokes`.
let n = 2 * (sh.strokes.iter().map(Vec::len).sum::<usize>() + sh.glyphs.iter().flatten().map(Vec::len).sum::<usize>());
match self.0.checked_sub(n) {
Some(rest) => {
self.0 = rest;
true
}
None => {
self.0 = 0;
false
}
}
}
}
/// Inline character format.
@@ -884,7 +850,6 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = Allowance::new();
for (l, base_y) in lines.iter().zip(baselines) {
let x0 = match col {
0 => 0.0,
@@ -894,15 +859,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for it in &l.items {
let Some(sg) = &it.seg else { continue };
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(sg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(x0, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
@@ -939,17 +896,6 @@ mod tests {
assert_eq!(l.lines[0], "alpha beta gamma");
}
/// The points of all the segments of one layout are charged against one allowance.
#[test]
fn a_layout_shares_one_point_allowance() {
let big = Shaped { strokes: vec![vec![Vec2::ZERO; crate::ttf::MAX_SHAPED_POINTS / 4 + 1]], ..Default::default() };
let mut a = Allowance::new();
assert!(a.left() && a.charge(&big), "the first segment fits");
assert!(!a.charge(&big), "the second does not, though each alone would");
assert!(!a.left(), "and nothing is left to shape another");
assert!(!layout_mtext("plain text", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0).truncated);
}
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
-9
View File
@@ -677,7 +677,6 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = super::Allowance::new();
for (l, (c, base_y)) in lines.iter().zip(placed) {
let pp = paras.props(l.para);
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
@@ -697,15 +696,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
};
for it in &l.items {
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(&it.seg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
+1 -1
View File
@@ -932,7 +932,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
pub(crate) points: usize,
points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
+3 -2
View File
@@ -12,16 +12,17 @@ pub(crate) fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
};
let lw = match e.common.lineweight {
Lineweight::ByBlock => ctx.block_lw,
Lineweight::ByLayer => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default),
Lineweight::ByLayer => ctx.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default),
x => x,
};
let lt = match e.common.linetype.to_ascii_lowercase().as_str() {
"byblock" => ctx.block_ltype.clone(),
"bylayer" => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()),
"bylayer" => ctx.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()),
_ => e.common.linetype.clone(),
};
Ctx {
d: ctx.d,
layers: ctx.layers,
xf: ctx.xf.then_before(m),
block_color: color,
block_layer: layer,
+70 -2
View File
@@ -256,8 +256,16 @@ pub struct PolyClip {
band_h: f64,
/// The region as triangles (for clipping fills), with each triangle's bounds.
tris: Vec<([Vec2; 3], Bounds2)>,
/// Indices into `tris` of the triangles whose y range meets each band (at most
/// [`TRI_BAND_SPAN`] bands of it), so a fill triangle meets only the clip triangles near it.
tri_bands: Vec<Vec<u32>>,
/// Indices of the triangles spanning more bands than that: tested against every fill triangle.
tall_tris: Vec<u32>,
}
/// Most bands one clip triangle is registered in; a taller one goes to [`PolyClip::tall_tris`].
const TRI_BAND_SPAN: usize = 8;
impl PolyClip {
/// A clip region for `poly` (see [`clean_polygon`]); `None` for a degenerate outline.
pub fn new(poly: &[Vec2]) -> Option<PolyClip> {
@@ -266,7 +274,15 @@ impl PolyClip {
let band_h = bounds.height() / BANDS as f64;
let mut bands: Vec<Vec<u32>> = vec![Vec::new(); BANDS];
let n = poly.len();
let mut clip = PolyClip { poly: Vec::new(), bounds, bands: Vec::new(), band_h, tris: Vec::new() };
let mut clip = PolyClip {
poly: Vec::new(),
bounds,
bands: Vec::new(),
band_h,
tris: Vec::new(),
tri_bands: vec![Vec::new(); BANDS],
tall_tris: Vec::new(),
};
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
let (lo, hi) = (clip.band(a.y.min(b.y)), clip.band(a.y.max(b.y)));
@@ -282,6 +298,17 @@ impl PolyClip {
.map(|t| (*t, Bounds2::from_points(t.iter().copied())))
.filter(|(_, b)| b.width() > 0.0 && b.height() > 0.0)
.collect();
for k in 0..clip.tris.len() {
let Some((_, tb)) = clip.tris.get(k) else { continue };
let (lo, hi) = (clip.band(tb.min.y), clip.band(tb.max.y));
if hi - lo >= TRI_BAND_SPAN {
clip.tall_tris.push(k as u32);
} else {
for band in clip.tri_bands.iter_mut().take(hi + 1).skip(lo) {
band.push(k as u32);
}
}
}
clip.poly = poly;
clip.bands = bands;
Some(clip)
@@ -404,7 +431,15 @@ impl PolyClip {
out.extend_from_slice(t);
continue;
}
for (c, cb) in &self.tris {
// The clip triangles near `t`, in clip order (the same output as a scan of all of them).
let (lo, hi) = (self.band(tb.min.y), self.band(tb.max.y));
let mut near: Vec<u32> = self.tall_tris.clone();
for band in self.tri_bands.iter().take(hi + 1).skip(lo) {
near.extend_from_slice(band);
}
near.sort_unstable();
near.dedup();
for (c, cb) in near.iter().filter_map(|&k| self.tris.get(k as usize)) {
if !cb.intersects(&tb) {
continue;
}
@@ -590,6 +625,39 @@ mod tests {
assert!(t.iter().all(|p| !(p.x > 5.0 + 1e-9 && p.y > 5.0 + 1e-9)));
}
/// A fill clipped through the band index equals the same fill against a plain scan of every clip
/// triangle (the unindexed result), for a boundary with tall and short triangles.
#[test]
fn indexed_triangle_clipping_equals_a_full_scan() {
// A comb: tall teeth over a base, so its triangles span many bands.
let mut poly = vec![Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0)];
for i in (0..20).rev() {
let x = i as f64 * 2.0;
poly.extend([
Vec2::new(x + 2.0, 20.0),
Vec2::new(x + 1.0, 20.0),
Vec2::new(x + 1.0, 2.0),
Vec2::new(x + 0.5, 2.0),
Vec2::new(x + 0.5, 20.0),
Vec2::new(x, 20.0),
]);
}
let c = PolyClip::new(&poly).unwrap();
assert!(!c.tall_tris.is_empty());
let fill: Vec<Vec2> = (0..30)
.flat_map(|i| {
let (x, y) = (i as f64 * 1.3, (i % 7) as f64 * 2.5);
[Vec2::new(x, y), Vec2::new(x + 6.0, y), Vec2::new(x + 2.0, y + 9.0)]
})
.collect();
let got = c.clip_triangles(&fill);
let mut scan = c.clone();
scan.tri_bands = vec![(0..scan.tris.len() as u32).collect(); BANDS];
scan.tall_tris.clear();
assert_eq!(got, scan.clip_triangles(&fill));
assert!(!got.is_empty());
}
#[test]
fn degenerate_and_hostile_outlines() {
assert!(PolyClip::new(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)]).is_none());
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn dimension(
let dim_rgb = ink(style.dim_line_color, layer_color, own);
let ext_rgb = ink(style.ext_line_color, layer_color, own);
let txt_rgb = ink(style.text_color, layer_color, own);
let layer = d.layer(layer_name);
let layer = ctx.layer(layer_name);
let weight = |v: i16| -> f32 {
if !b.opts.lineweights {
return 0.0;
+118 -15
View File
@@ -1,40 +1,109 @@
//! Polygon filling: even-odd regions to triangles (slab decomposition), wide polylines.
use cadcraft_doc::LwPolyline;
use cadcraft_geom::{Polyline, Vec2};
use cadcraft_geom::{Bounds2, Polyline, Vec2};
/// Triangulate an even-odd region bounded by closed loops (holes allowed) by cutting it into
/// horizontal slabs at every vertex y and emitting trapezoids. Two edges that cross inside a slab
/// (a bowtie, two loops crossing each other) swap order there, so such a slab is cut again at the
/// crossing and each part is paired on its own. Robust for any input; output size is
/// O(slabs × crossings), capped for hostile inputs.
/// O(slabs × crossings), capped for hostile inputs. Groups of loops whose bounding boxes are
/// separated (by a vertical or a horizontal gap, recursively) cannot affect each other's even-odd
/// parity, so each group is cut on its own: disjoint outlines do not multiply each other's slabs.
pub fn triangulate_evenodd(loops: &[Vec<Vec2>]) -> Vec<Vec2> {
let mut edges: Vec<(Vec2, Vec2)> = Vec::new();
for l in loops {
// (original index, bounds, edges) per loop.
let mut items: Vec<(usize, Bounds2, Vec<(Vec2, Vec2)>)> = Vec::new();
let mut total = 0usize;
for (k, l) in loops.iter().enumerate() {
let n = l.len();
if n < 3 {
continue;
}
let mut edges = Vec::new();
for i in 0..n {
let (Some(&a), Some(&b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
if (a.y - b.y).abs() > 1e-15 && a.is_finite() && b.is_finite() {
edges.push(if a.y < b.y { (a, b) } else { (b, a) });
}
}
total += edges.len();
if total > 200_000 {
return Vec::new();
}
if !edges.is_empty() {
let bounds = Bounds2::from_points(edges.iter().flat_map(|(a, b)| [*a, *b]));
items.push((k, bounds, edges));
}
}
if edges.is_empty() || edges.len() > 200_000 {
return Vec::new();
let mut out = Vec::new();
// Slabs created by cutting at crossings, over the whole region; past this the remaining
// crossings are left unsplit rather than letting a hostile input run away.
let mut cuts_left = MAX_CROSSING_CUTS;
// Loop visits all the partitioning may make: an input that peels one loop off a big group at a
// time would rescan the rest each time, so past this a group is cut as it stands (as before partitioning existed).
let mut split_work = 64 * items.len() + 4_000_000;
let mut stack = vec![items];
while let Some(mut group) = stack.pop() {
if group.is_empty() {
continue;
}
let parts = match split_work.checked_sub(2 * group.len()) {
Some(rest) => {
split_work = rest;
let parts = split_groups(&mut group, true);
if parts.len() > 1 { parts } else { split_groups(&mut group, false) }
}
None => Vec::new(),
};
if parts.len() > 1 {
// Lowest-first when popped.
stack.extend(parts.into_iter().rev());
continue;
}
group.sort_by_key(|g| g.0);
let edges: Vec<(Vec2, Vec2)> = group.into_iter().flat_map(|g| g.2).collect();
if slabs(edges, &mut out, &mut cuts_left) {
break;
}
}
out
}
type Item = (usize, Bounds2, Vec<(Vec2, Vec2)>);
/// `group` cut into the chains of loops whose extents along x (or y) overlap, in order along it;
/// one chain when nothing separates them (then `group` is left as it was, but for its order).
fn split_groups(group: &mut Vec<Item>, along_x: bool) -> Vec<Vec<Item>> {
let span = |b: &Bounds2| if along_x { (b.min.x, b.max.x) } else { (b.min.y, b.max.y) };
group.sort_by(|a, b| span(&a.1).0.total_cmp(&span(&b.1).0));
let mut parts: Vec<Vec<Item>> = Vec::new();
let mut reach = f64::NEG_INFINITY;
for item in std::mem::take(group) {
match parts.last_mut() {
Some(part) if span(&item.1).0 <= reach => {
reach = reach.max(span(&item.1).1);
part.push(item);
}
_ => {
reach = span(&item.1).1;
parts.push(vec![item]);
}
}
}
if parts.len() == 1 {
*group = parts.pop().unwrap_or_default();
}
parts
}
/// The slab decomposition of `edges` appended to `out`; true once `out` passes its cap.
fn slabs(mut edges: Vec<(Vec2, Vec2)>, out: &mut Vec<Vec2>, cuts_left: &mut usize) -> bool {
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y));
let mut out = Vec::new();
let mut active: Vec<(Vec2, Vec2)> = Vec::new();
let mut next = 0usize;
// Slabs created by cutting at crossings, over the whole region; past this the remaining
// crossings are left unsplit rather than letting a hostile input run away.
let mut cuts_left = MAX_CROSSING_CUTS;
let mut pending: Vec<(f64, f64)> = Vec::new();
for w in ys.windows(2) {
let (Some(&y0), Some(&y1)) = (w.first(), w.get(1)) else { continue };
@@ -54,7 +123,7 @@ pub fn triangulate_evenodd(loops: &[Vec<Vec2>]) -> Vec<Vec2> {
let ymid = (ya + yb) / 2.0;
let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|e| (x_at(e, ymid), x_at(e, ya), x_at(e, yb))).collect();
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
if cuts_left > 0 {
if *cuts_left > 0 {
let mut cuts: Vec<f64> = Vec::new();
for pair in xs.windows(2) {
let (Some(l), Some(r)) = (pair.first(), pair.get(1)) else { continue };
@@ -65,8 +134,8 @@ pub fn triangulate_evenodd(loops: &[Vec<Vec2>]) -> Vec<Vec2> {
if !cuts.is_empty() {
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
cuts.truncate(cuts_left);
cuts_left -= cuts.len();
cuts.truncate(*cuts_left);
*cuts_left -= cuts.len();
// Push the parts top-down so the lowest is processed first.
let mut top = yb;
for &c in cuts.iter().rev() {
@@ -84,11 +153,11 @@ pub fn triangulate_evenodd(loops: &[Vec<Vec2>]) -> Vec<Vec2> {
}
}
if out.len() > 6_000_000 {
return out;
return true;
}
}
}
out
false
}
/// How many extra slab boundaries crossings may add over one region.
@@ -164,6 +233,40 @@ mod tests {
assert!((area(&t) - 96.0).abs() < 1e-9);
}
/// A thousand disjoint squares side by side (every slab of one would otherwise be cut at the
/// vertex heights of all the others) triangulate in linear output.
#[test]
fn disjoint_outlines_do_not_multiply_each_others_slabs() {
let n = 1_000;
let loops: Vec<Vec<Vec2>> = (0..n)
.map(|i| {
let (x, jitter) = (i as f64 * 3.0, i as f64 * 0.001);
vec![Vec2::new(x, jitter), Vec2::new(x + 2.0, jitter), Vec2::new(x + 2.0, 2.0 + jitter), Vec2::new(x, 2.0 + jitter)]
})
.collect();
let t = triangulate_evenodd(&loops);
assert!((area(&t) - 4.0 * n as f64).abs() < 1e-6);
// Two triangles per square: no slab was cut at another square's height.
assert_eq!(t.len(), 6 * n);
}
/// Rectangles that peel off one at a time along alternating axes (each split isolates a single loop
/// from a large group) still triangulate correctly, with the partition work bounded.
#[test]
fn alternating_peeling_is_bounded_and_correct() {
let m = 1_000;
let (mf, mut loops) = (m as f64, Vec::new());
let rect = |x0: f64, x1: f64, y0: f64, y1: f64| vec![Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)];
for i in 0..m {
let i = i as f64;
loops.push(rect(2.0 * i, 2.0 * i + 1.0, 2.0 * i, 2.0 * mf));
loops.push(rect(2.0 * i + 2.0, 2.0 * mf + 1.0, 2.0 * i, 2.0 * i + 1.0));
}
let expected: f64 = (0..m).map(|i| (2.0 * mf - 2.0 * i as f64) + (2.0 * mf - 2.0 * i as f64 - 1.0)).sum();
let t = triangulate_evenodd(&loops);
assert!((area(&t) - expected).abs() < 1e-6 * expected, "{} vs {expected}", area(&t));
}
#[test]
fn triangle_area() {
let t = triangulate_evenodd(&[vec![Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, 3.0)]]);
+33 -1
View File
@@ -78,10 +78,32 @@ fn max_scale(m: &Mat3) -> f64 {
if s.is_finite() { s } else { 0.0 }
}
/// A drawing's layers by lower-cased name (the first of a duplicated name wins, as the table's own
/// search does), built once per display list: a lookup per object over the layer vector multiplies
/// the object count by the layer count.
struct LayerIndex(Option<std::collections::HashMap<String, usize>>);
impl LayerIndex {
fn new(d: &Drawing) -> Self {
let mut m = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
m.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
LayerIndex(Some(m))
}
/// No index: lookups scan the layer vector, as `Drawing::layer` does. For a few objects (a
/// rubber band redrawn at every pointer move), where building an index would cost more.
fn linear() -> Self {
LayerIndex(None)
}
}
/// Graphics state inherited through block references.
#[derive(Clone)]
struct Ctx<'a> {
d: &'a Drawing,
layers: &'a LayerIndex,
xf: Mat3,
block_color: Color,
block_layer: Option<String>,
@@ -103,12 +125,19 @@ struct Ctx<'a> {
}
impl Ctx<'_> {
/// The layer called `name` (any case), as [`Drawing::layer`] finds it.
fn layer(&self, name: &str) -> Option<&cadcraft_doc::Layer> {
let Some(map) = &self.layers.0 else { return self.d.layer(name) };
let i = if name.bytes().any(|b| b.is_ascii_uppercase()) { map.get(&name.to_ascii_lowercase()) } else { map.get(name) };
i.and_then(|i| self.d.layers.get(*i))
}
/// A layer's colour, honouring the viewport's override.
fn layer_color(&self, name: &str) -> Option<Color> {
if let Some((_, c)) = self.vp_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)) {
return Some(*c);
}
self.d.layer(name).map(|l| l.color)
self.layer(name).map(|l| l.color)
}
}
@@ -125,6 +154,8 @@ struct Builder<'a> {
shared_budget: Option<&'a std::sync::atomic::AtomicUsize>,
/// Transparency (percent) of the entity being drawn.
transparency: u8,
/// The drawing's layers by name.
layers: &'a LayerIndex,
}
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
@@ -340,6 +371,7 @@ impl<'a> Builder<'a> {
named: self.named,
shared_budget: self.shared_budget,
transparency: self.transparency,
layers: self.layers,
}
}
/// Take one unit of the block-expansion budget; false once it is spent.
+56 -10
View File
@@ -3,7 +3,9 @@ use super::display::append;
use super::display::{DisplayList, Kind, NO_LAYER, PlotText, VIEWPORT_CONTENT};
use super::draw::entity;
use super::viewports::{MAX_VIEWPORTS, viewport};
use super::{Bounds2, Builder, Color, Ctx, Drawing, Entity, EntityKind, Handle, Lineweight, Mat3, Space, Vec2, paper, pen, plotstyle, point};
use super::{
Bounds2, Builder, Color, Ctx, Drawing, Entity, EntityKind, Handle, LayerIndex, Lineweight, Mat3, Space, Vec2, paper, pen, plotstyle, point,
};
/// Build options.
#[derive(Clone, Debug)]
@@ -80,9 +82,17 @@ pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
build_space(d, space, opts, true)
}
pub(crate) fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_colors: &'a [(String, Color)]) -> Ctx<'a> {
pub(crate) fn top_ctx<'a>(
d: &'a Drawing,
layers: &'a LayerIndex,
xf: Mat3,
top: Handle,
frozen: &'a [String],
vp_colors: &'a [(String, Color)],
) -> Ctx<'a> {
Ctx {
d,
layers,
xf,
block_color: Color::Index(7),
block_layer: None,
@@ -101,7 +111,17 @@ pub(crate) fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [St
fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList {
let opts = &with_point_view(d, space, opts);
let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t));
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 };
let layers = LayerIndex::new(d);
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting,
styles: styler.as_ref(),
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
@@ -124,7 +144,7 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
}
continue;
}
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut b, &top_ctx(d, &layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
}
// A layout is drawn in paper units, so its pens become geometry now.
@@ -148,14 +168,22 @@ fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStor
use rayon::prelude::*;
let ents: Vec<&std::sync::Arc<Entity>> = store.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let (opts, plotting) = (b.opts, b.plotting);
let (opts, plotting, layers) = (b.opts, b.plotting, b.layers);
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
.map(|chunk| {
let mut part =
Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget), transparency: 0 };
let mut part = Builder {
list: DisplayList::default(),
opts,
plotting,
styles: None,
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
for e in chunk {
entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut part, &top_ctx(d, layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
part.list
})
@@ -194,6 +222,10 @@ pub(crate) fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Opt
o
}
/// Loose entity lists shorter than this, with no block reference (whose contents expand to many
/// objects), look layers up by scanning rather than building an index.
const SMALL_LIST: usize = 64;
/// Build the display list for a set of loose entities (previews, rubber bands).
pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents: I, opts: &Options) -> DisplayList {
let ents: Vec<&Entity> = ents.into_iter().collect();
@@ -205,9 +237,23 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
} else {
opts
};
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None, transparency: 0 };
let layers = if ents.len() < SMALL_LIST && !ents.iter().any(|e| matches!(e.kind, EntityKind::Insert(_))) {
LayerIndex::linear()
} else {
LayerIndex::new(d)
};
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting: false,
styles: None,
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
for e in ents {
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut b, &top_ctx(d, &layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
b.list
}
+1 -1
View File
@@ -59,7 +59,7 @@ impl Styler {
let normal = || by_name(cadcraft_doc::NORMAL_STYLE);
let p = c.plot_style.trim();
if p.is_empty() || p.eq_ignore_ascii_case("bylayer") {
ctx.d.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
ctx.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
} else if p.eq_ignore_ascii_case("byblock") {
ctx.block_pstyle.or_else(normal)
} else {
+1 -1
View File
@@ -14,7 +14,7 @@ pub(crate) fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool
let d = ctx.d;
// Layer "0" inside a block takes the insert's layer.
let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() };
let layer = d.layer(layer_name);
let layer = ctx.layer(layer_name);
let visible =
c.visible && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name));
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
+17
View File
@@ -1217,3 +1217,20 @@ fn rotated_and_reversed_copies_of_a_curved_loop_fill_once() {
});
assert_eq!(loops_of(&h).len(), 2);
}
/// The per-list layer index finds what `Drawing::layer` finds: any case, the first of a duplicate.
#[test]
fn the_layer_index_agrees_with_the_layer_table() {
let mut d = Drawing::new_imperial();
let mut first = cadcraft_doc::Layer::new("Walls");
first.color = Color::Index(1);
let mut dup = cadcraft_doc::Layer::new("WALLS");
dup.color = Color::Index(2);
d.layers.extend([first, dup, cadcraft_doc::Layer::new("Doors")]);
for index in [LayerIndex::new(&d), LayerIndex::linear()] {
let top = crate::pipeline::top_ctx(&d, &index, cadcraft_geom::Mat3::IDENTITY, cadcraft_doc::Handle(1), &[], &[]);
for name in ["Walls", "walls", "WALLS", "doors", "0", "Missing"] {
assert_eq!(top.layer(name).map(|l| (&l.name, l.color)), d.layer(name).map(|l| (&l.name, l.color)), "{name}");
}
}
}
+19 -5
View File
@@ -5,7 +5,9 @@ use super::draw::entity;
#[cfg(not(target_arch = "wasm32"))]
use super::pipeline::PARALLEL_CHUNK;
use super::pipeline::{Options, style_table, top_ctx, with_point_view};
use super::{Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_EXPANSION, Mat3, Pen, Space, Vec2, clip, paper, pen, plotstyle};
use super::{
Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, LayerIndex, MAX_BLOCK_EXPANSION, Mat3, Pen, Space, Vec2, clip, paper, pen, plotstyle,
};
/// Upper bound on viewports drawn per layout (hostile files).
pub(crate) const MAX_VIEWPORTS: usize = 256;
@@ -21,7 +23,7 @@ pub(crate) fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_d
let poly = viewport_clip(d, e.handle, vp);
// Border (on the viewport's layer; layer off hides only the border).
if poly.is_none() {
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(b, &top_ctx(d, b.layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
if b.opts.skip_viewport == Some(e.handle) {
return;
@@ -67,8 +69,17 @@ pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds
}
let opts = &with_point_view(d, space, opts);
let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t));
let mut b =
Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 };
let layers = LayerIndex::new(d);
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting: false,
styles: styler.as_ref(),
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
contents(&mut b, d, vp, area, &Clip::Rect(area), paper::unit_mm_of(d, handle));
b.list
}
@@ -97,7 +108,8 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
let ctx = || Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) };
let layers = b.layers;
let ctx = || Ctx { lt_factor, ..top_ctx(d, layers, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) };
let seen = |me: &Entity| {
if matches!(me.kind, EntityKind::Viewport(_)) {
return false;
@@ -120,6 +132,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
#[cfg(not(target_arch = "wasm32"))]
let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK;
@@ -143,6 +156,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
let c = ctx();
for me in chunk.iter().filter(|me| seen(me)) {
+5 -17
View File
@@ -161,25 +161,13 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned.
fn module_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
// `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped,
// not walked again as a new subtree.
// An unreadable entry must fail the scan, not silently shrink the translation report.
let mut paths: Vec<_> = entries
.map(|e| {
let e = e.expect("read module directory entry");
let kind = e.file_type().expect("read module entry type");
(e.path(), kind)
})
.collect();
paths.sort_by(|a, b| a.0.cmp(&b.0));
for (path, kind) in paths {
if kind.is_dir() {
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
module_files(&path, files);
} else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") {
} else if path.extension().is_some_and(|x| x == "rs") {
files.push(path);
}
}
+4 -4
View File
@@ -197,7 +197,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
let name = r.name.clone();
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
let op = |option: &str| json!({ "option": option, "names": name, "exact": true });
let op = |option: &str| json!({ "option": option, "names": name });
ui.horizontal_wrapped(|ui| {
ui.add_space(8.0);
if ui.button(crate::tl!("Reload")).clicked() {
@@ -221,7 +221,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
.clicked()
{
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true })));
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
}
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
*act = Some(Act::Run("xopen", json!({ "name": name })));
@@ -235,7 +235,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
if (a || o) && overlay != r.overlay {
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay })));
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
}
});
ui.add_space(4.0);
@@ -248,7 +248,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
if apply {
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() })));
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
ui.data_mut(|d| d.remove::<String>(path_id));
} else if text != r.path {
ui.data_mut(|d| d.insert_temp(path_id, text));
-27
View File
@@ -1,27 +0,0 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
@@ -17,8 +17,6 @@ runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,8 +18,6 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
+4 -14
View File
@@ -136,18 +136,6 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
mod tests {
use super::{Counts, count_tests};
/// A new private directory in the temp folder. It is created, never reused: an existing path
/// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes.
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id()));
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(&p).unwrap();
p
}
#[test]
fn counts_attributes_and_proptest_blocks() {
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
@@ -159,7 +147,8 @@ mod tests {
#[test]
fn walk_does_not_follow_directory_symlinks() {
use std::os::unix::fs::symlink;
let base = fresh_dir("xtask");
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let real = base.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
@@ -173,7 +162,8 @@ mod tests {
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
#[test]
fn walk_reports_budget_exhaustion() {
let base = fresh_dir("stats-budget");
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
assert!(error.contains("budget exhausted"));