mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-12 00:45:16 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee840e9a70 | ||
|
|
d9b79174e2 | ||
|
|
b69ced1840 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -55,13 +55,15 @@ const MAX_GAUSS_DEPTH: usize = 10;
|
||||
|
||||
/// `gauss` over [a, b], halving until the two halves agree with the whole: a fixed panel count
|
||||
/// is far off on a tight bend, and the arc-length inverse must agree with the measure.
|
||||
fn adaptive_gauss(a: f64, b: f64, whole: f64, f: &impl Fn(f64) -> f64, depth: usize) -> f64 {
|
||||
/// `floor` is an absolute error nobody can resolve (rounding noise of the control coordinates): on
|
||||
/// a constant spline the speed is pure noise, which a relative test alone never accepts.
|
||||
fn adaptive_gauss(a: f64, b: f64, whole: f64, f: &impl Fn(f64) -> f64, floor: f64, depth: usize) -> f64 {
|
||||
let m = (a + b) / 2.0;
|
||||
let (l, r) = (gauss(a, m, f), gauss(m, b, f));
|
||||
if depth == 0 || !(l + r).is_finite() || (l + r - whole).abs() <= 1e-10 * (l + r).abs() {
|
||||
if depth == 0 || !(l + r).is_finite() || (l + r - whole).abs() <= 1e-10 * (l + r).abs() + floor {
|
||||
return l + r;
|
||||
}
|
||||
adaptive_gauss(a, m, l, f, depth - 1) + adaptive_gauss(m, b, r, f, depth - 1)
|
||||
adaptive_gauss(a, m, l, f, floor, depth - 1) + adaptive_gauss(m, b, r, f, floor, depth - 1)
|
||||
}
|
||||
|
||||
/// Clamped knots over Bézier pieces meeting at `breaks`, for degree `p`.
|
||||
@@ -527,7 +529,22 @@ impl Spline {
|
||||
/// sample.
|
||||
fn panel_length(&self, a: f64, b: f64, derivative: fn(&Spline, f64) -> Vec2) -> f64 {
|
||||
let speed = |t: f64| derivative(self, t).len();
|
||||
adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH)
|
||||
adaptive_gauss(a, b, gauss(a, b, speed), &speed, self.length_floor(a, b), MAX_GAUSS_DEPTH)
|
||||
}
|
||||
|
||||
/// The absolute length error below which the panel [a, b] counts as converged: rounding of the
|
||||
/// coordinates of the control points that shape it (1e-15 of the largest), so a distant control
|
||||
/// point does not relax the tolerance. Costs one span's control points, not the whole spline.
|
||||
fn length_floor(&self, a: f64, b: f64) -> f64 {
|
||||
let Some(n) = self.control.len().checked_sub(1) else { return 0.0 };
|
||||
let span = find_span(n, self.degree, (a + b) / 2.0, &self.knots);
|
||||
let first = span.saturating_sub(self.degree);
|
||||
let last = span.min(n);
|
||||
let local = self.control.get(first..=last).unwrap_or_default();
|
||||
// A control point's pull on the curve is its coordinate times its share of the weight, so a
|
||||
// far point of tiny weight does not loosen the floor.
|
||||
let heaviest = (first..=last).map(|i| self.weight(i)).fold(f64::MIN_POSITIVE, f64::max);
|
||||
1e-15 * local.iter().enumerate().map(|(k, c)| c.x.abs().max(c.y.abs()) * (self.weight(first + k) / heaviest).min(1.0)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Arc length between parameters `a` and `b` (adaptive Gauss–Legendre over each knot span).
|
||||
@@ -618,10 +635,11 @@ impl Ellipse {
|
||||
let h = sweep / n as f64;
|
||||
// Each panel halved where it needs it: a flat ellipse turns sharply at its ends.
|
||||
let speed = |t: f64| self.derivative(t).len();
|
||||
let floor = 0.0;
|
||||
let l: f64 = (0..n)
|
||||
.map(|i| {
|
||||
let (a, b) = (t0 + h * i as f64, t0 + h * (i + 1) as f64);
|
||||
adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH)
|
||||
adaptive_gauss(a, b, gauss(a, b, speed), &speed, floor, MAX_GAUSS_DEPTH)
|
||||
})
|
||||
.sum();
|
||||
if l.is_finite() { l } else { 0.0 }
|
||||
@@ -659,6 +677,40 @@ impl Ellipse {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A constant spline's speed is rounding noise: the integration accepts it at once rather than
|
||||
/// halving to the depth cap (1,024 panels per knot span).
|
||||
#[test]
|
||||
fn noise_speed_converges_with_the_absolute_floor() {
|
||||
let calls = std::cell::Cell::new(0usize);
|
||||
let noise = |t: f64| {
|
||||
calls.set(calls.get() + 1);
|
||||
1e-16 * (t * 977.0).sin().abs()
|
||||
};
|
||||
let l = adaptive_gauss(0.0, 1.0, gauss(0.0, 1.0, noise), &noise, 1e-15, MAX_GAUSS_DEPTH);
|
||||
assert!(l < 1e-15 && calls.get() < 100, "{l} {}", calls.get());
|
||||
let c = Spline::from_control(vec![Vec2::new(1e6, 1e6); 5], 3);
|
||||
assert!(c.length() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_low_weight_control_point_does_not_relax_the_floor() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1e13, 0.0), Vec2::new(0.0, 0.0)], 2);
|
||||
s.knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
s.weights = vec![1.0, 1e-13, 0.25];
|
||||
let floor = s.length_floor(0.0, 1.0);
|
||||
assert!(floor <= 1.5e-15, "{floor}");
|
||||
}
|
||||
|
||||
/// A distant control point does not relax the tolerance of a span it does not shape.
|
||||
#[test]
|
||||
fn the_error_floor_is_local_to_the_span() {
|
||||
let mut s =
|
||||
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.2, 0.0), Vec2::new(1e13, 0.0), Vec2::new(1e13, 0.0)], 2);
|
||||
s.knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 2.0];
|
||||
let got = s.length_between(0.0, 1.0);
|
||||
assert!((got - 41.0 / 45.0).abs() < 1e-9, "{got}");
|
||||
}
|
||||
|
||||
fn cubic() -> Spline {
|
||||
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
|
||||
}
|
||||
|
||||
+65
-11
@@ -58,11 +58,18 @@ struct Graph {
|
||||
src: Vec<(usize, usize)>,
|
||||
/// The input polyline of every edge (lower vertex index first).
|
||||
edge_src: HashMap<(usize, usize), usize>,
|
||||
/// Neighbour angles still to be measured by face tracing: a hub with thousands of spokes is
|
||||
/// scanned once per face through it, so the scans are bounded in all ([`MAX_TRACE_WORK`]).
|
||||
trace_work: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
/// Records the crossing of segments `i` and `j`, if any, as split parameters on both.
|
||||
fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize) {
|
||||
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { return };
|
||||
/// Neighbour-angle measurements one graph allows its face traces in all (a few seconds' work); a
|
||||
/// star of more than about 14,000 spokes needs more and finds no region.
|
||||
const MAX_TRACE_WORK: u64 = 200_000_000;
|
||||
|
||||
/// Records the crossing of segments `i` and `j`, if any, as split parameters on both; whether there was one.
|
||||
fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize) -> bool {
|
||||
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { return false };
|
||||
let li = Line::new(si.0, si.1);
|
||||
let lj = Line::new(sj.0, sj.1);
|
||||
if let Some(x) = line_line(&li, &lj) {
|
||||
@@ -72,7 +79,9 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize
|
||||
if let Some(v) = splits.get_mut(j) {
|
||||
v.push(lj.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Upper bound on candidate-pair *visits*. Every visit counts, including repeats of a pair that shares several grid
|
||||
@@ -82,6 +91,9 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
/// Upper bound on distinct segment pairs tested for a crossing.
|
||||
const MAX_PAIRS_TESTED: usize = 20_000_000;
|
||||
/// Upper bound on crossings kept (two split parameters each, 16 bytes): one region operation holds
|
||||
/// at most about 128 MB of them, far more than any real hatch has.
|
||||
const MAX_CROSSINGS: usize = 8_000_000;
|
||||
|
||||
/// Split parameters of every segment (always `0` and `1`, plus every crossing), found with a uniform grid.
|
||||
fn pair_splits(segs: &[(Vec2, Vec2)], bb: &Bounds2) -> Option<Vec<Vec<f64>>> {
|
||||
@@ -132,6 +144,7 @@ fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested: usize = 0;
|
||||
let mut crossings: usize = 0;
|
||||
let mut visits: u64 = 0;
|
||||
for (c, list) in grid.iter().enumerate() {
|
||||
let (gx, gy) = (c / cells, c % cells);
|
||||
@@ -147,9 +160,9 @@ fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max
|
||||
if i == j || gx != ix.max(jx) || gy != iy.max(jy) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
crossings += usize::from(split_pair(segs, &mut splits, i, j));
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
if tested > max_pairs || crossings > MAX_CROSSINGS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -161,9 +174,9 @@ fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max
|
||||
if i == j || (j < i && is_long.get(j).copied().unwrap_or(false)) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
crossings += usize::from(split_pair(segs, &mut splits, i, j));
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
if tested > max_pairs || crossings > MAX_CROSSINGS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -200,7 +213,13 @@ fn build_with(polys: &[Vec<Vec2>], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)
|
||||
let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]));
|
||||
let n = segs.len();
|
||||
let splits = find_splits(&segs, &bb)?;
|
||||
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
|
||||
let mut g = Graph {
|
||||
pts: Vec::new(),
|
||||
adj: Vec::new(),
|
||||
src: Vec::with_capacity(n),
|
||||
edge_src: HashMap::new(),
|
||||
trace_work: std::cell::Cell::new(MAX_TRACE_WORK),
|
||||
};
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
|
||||
let k = key(p, bb.min, tol);
|
||||
@@ -304,7 +323,13 @@ fn trace(g: &Graph, u0: usize, v0: usize, max_steps: usize) -> Option<Vec<usize>
|
||||
let pv = *g.pts.get(v)?;
|
||||
let back = (*g.pts.get(u)? - pv).angle();
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for &w in g.adj.get(v)? {
|
||||
let ns = g.adj.get(v)?;
|
||||
let Some(left) = g.trace_work.get().checked_sub(ns.len() as u64) else {
|
||||
g.trace_work.set(0);
|
||||
return None;
|
||||
};
|
||||
g.trace_work.set(left);
|
||||
for &w in ns {
|
||||
if w == u && g.adj.get(v).is_some_and(|l| l.len() > 1) {
|
||||
continue;
|
||||
}
|
||||
@@ -355,7 +380,12 @@ fn outer_face(g: &Graph, p: Vec2) -> Option<Vec<usize>> {
|
||||
for (_, a, b) in hits.into_iter().take(64) {
|
||||
let (pa, pb) = (*g.pts.get(a)?, *g.pts.get(b)?);
|
||||
let (u, v) = if (pb - pa).cross(p - pa) > 0.0 { (a, b) } else { (b, a) };
|
||||
if let Some(ids) = trace(g, u, v, max_steps)
|
||||
let traced = trace(g, u, v, max_steps);
|
||||
// A spent budget ends the search: a cheaper, more distant face must not stand in for the nearer one.
|
||||
if traced.is_none() && g.trace_work.get() == 0 {
|
||||
return None;
|
||||
}
|
||||
if let Some(ids) = traced
|
||||
&& ids.len() >= 3
|
||||
&& let Some(lp) = points(g, &ids)
|
||||
&& shoelace(&lp) > 0.0
|
||||
@@ -409,7 +439,7 @@ fn prune(g: &Graph) -> Graph {
|
||||
}
|
||||
}
|
||||
}
|
||||
Graph { pts: g.pts.clone(), adj, src: Vec::new(), edge_src: HashMap::new() }
|
||||
Graph { pts: g.pts.clone(), adj, src: Vec::new(), edge_src: HashMap::new(), trace_work: std::cell::Cell::new(MAX_TRACE_WORK) }
|
||||
}
|
||||
|
||||
/// The outline of the outside of a group of curves on cycles, traced counter-clockwise from its
|
||||
@@ -806,6 +836,30 @@ mod tests {
|
||||
assert_eq!(old.as_deref().map(sorted_bits), new.as_deref().map(sorted_bits), "n={} visits<={max_visits} pairs<={max_pairs}", segs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_kept_are_capped() {
|
||||
// 2,900 horizontal x 2,900 vertical lines cross 8.41 million times.
|
||||
let n = 2_900;
|
||||
let mut segs: Vec<(Vec2, Vec2)> = (0..n).map(|i| (Vec2::new(0.0, i as f64 + 0.5), Vec2::new(n as f64, i as f64 + 0.5))).collect();
|
||||
segs.extend((0..n).map(|i| (Vec2::new(i as f64 + 0.5, 0.0), Vec2::new(i as f64 + 0.5, n as f64))));
|
||||
let bb = bounds_of(&segs);
|
||||
assert!(pair_splits_limited(&segs, &bb, u64::MAX, usize::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_tracing_through_a_hub_is_budgeted() {
|
||||
// 200 spokes from one hub to a ring: each face passes the hub and scans all its neighbours.
|
||||
let k = 200;
|
||||
let ring: Vec<Vec2> = (0..=k).map(|i| Vec2::new(10.0, 0.0).rotate(i as f64 * crate::TAU / k as f64)).collect();
|
||||
let mut polys = vec![ring.clone()];
|
||||
polys.extend(ring.iter().take(k).map(|q| vec![Vec2::ZERO, *q]));
|
||||
let p = Vec2::new(5.0, 0.01);
|
||||
let g = build(&polys, 1e-9).unwrap();
|
||||
assert!(outer_face(&g, p).is_some());
|
||||
g.trace_work.set(10);
|
||||
assert!(outer_face(&g, p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_search_matches_oracle_with_budgets() {
|
||||
// Small inputs in few cells: many pairs share several cells. Sweeping each budget over
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user