Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 664c9812d2 | |||
| 08cd8d6442 | |||
| c5dab0b9a8 | |||
| cca77b176f | |||
| 1bd0040d25 | |||
| 49a3ba1c03 | |||
| d78895de4d | |||
| 112e0a3d9c | |||
| eb0b427a36 | |||
| ba69e5488c | |||
| 9308095744 | |||
| 5bf3940c04 | |||
| 2f483a00c0 | |||
| d5fd14135f | |||
| 85be50df0d | |||
| 71edae80df | |||
| 93baf88e2d | |||
| 4060069fcd | |||
| a4c9bc380f | |||
| f219d9b795 | |||
| 21888e8000 | |||
| 9b73ec8d9d | |||
| 0d4739fffb | |||
| 693cf911f1 | |||
| b21c377296 | |||
| 878bc1b421 | |||
| 562bb379a5 | |||
| 1a47ac13de | |||
| 1c957c105d | |||
| ec527a32ab | |||
| d01de87552 | |||
| fefca6bfa0 | |||
| 6a2bdbf144 | |||
| 413c145c56 | |||
| b708170028 | |||
| 8679063629 | |||
| f87b66d759 | |||
| a09c24e9bf | |||
| ebafe07487 | |||
| 3575c99750 | |||
| de7bf4fc4d | |||
| f18616e19c | |||
| 657c733166 | |||
| 4c4f25115b | |||
| 672ef13eb8 | |||
| 4c63a88fc3 | |||
| 5e23f58f62 | |||
| 01a542d6e8 | |||
| f0cc73eb83 |
@@ -4348,6 +4348,7 @@ dependencies = [
|
||||
"gethostname",
|
||||
"gltf",
|
||||
"log",
|
||||
"png",
|
||||
"quick-xml",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4356,7 +4357,9 @@ dependencies = [
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4410,6 +4413,7 @@ dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
|
||||
@@ -173,6 +173,12 @@ and the [foundation ledger](docs/drawing-foundations.md) track the bounded imple
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Drawing UI scaffold: the native workspace now supplies the shared four-tab ribbon,
|
||||
source Browser, compact dockable sheet strip and resizable Sheet Settings/options
|
||||
panel. Planned tools remain unavailable; only existing source services execute.
|
||||
Drawing files and paper rendering are still deferred. See the
|
||||
[UI implementation roadmap](docs/drawing-workspace-ui.md).
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
|
||||
@@ -63,6 +63,10 @@ fn replay(report: &Value) -> Result<(), String> {
|
||||
Some(d) => super::load(d)?,
|
||||
None => solidcraft_engine::Session::default(),
|
||||
};
|
||||
// The ids the session drew from there, so the commands give the same objects the same ids.
|
||||
if let Some(seed) = script["ids"].as_u64() {
|
||||
s.seed_ids(seed);
|
||||
}
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
|
||||
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
|
||||
|
||||
@@ -458,12 +458,13 @@ fn snapshot_runs_its_script_on_the_in_design() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown command's error suggests the id that was meant.
|
||||
/// An unknown command's error suggests the id that was meant first; other close ids (such as
|
||||
/// `drawing.open`) may follow it.
|
||||
#[test]
|
||||
fn unknown_command_suggests_the_closest_id() {
|
||||
let output = cli().args(["exec", "file.open"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
|
||||
|
||||
@@ -681,11 +681,13 @@ mod web {
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => {
|
||||
solidcraft_engine::sketch::fallback::provide_web_font(bytes.clone());
|
||||
self.cjk_bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
solidcraft_engine::sketch::fallback::web_font_failed();
|
||||
false
|
||||
}
|
||||
};
|
||||
@@ -720,10 +722,11 @@ mod web {
|
||||
self.font_language.clone_from(&self.app.preferences.language);
|
||||
}
|
||||
|
||||
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
|
||||
/// fetch the font, so the text does not stay tofu (#720).
|
||||
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name), or a
|
||||
/// drawing's text waiting for one: fetch the font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
let wanted = solidcraft_ui_egui::fonts::painted_cjk(ctx) || solidcraft_engine::sketch::fallback::web_font_wanted();
|
||||
if self.font.watching_text() && wanted && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ pattern_rect false
|
||||
perforate false
|
||||
perspective false
|
||||
pipe false
|
||||
placeholder true
|
||||
plane false
|
||||
plane_along_path false
|
||||
plane_angle false
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Align drawing annotations</title>
|
||||
<path d="M12 6L12 58" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.5 12L51.5 12A2.5 2.5 0 0 1 54 14.5L54 17.5A2.5 2.5 0 0 1 51.5 20L22.5 20A2.5 2.5 0 0 1 20 17.5L20 14.5A2.5 2.5 0 0 1 22.5 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.5 28L40.5 28A2.5 2.5 0 0 1 43 30.5L43 33.5A2.5 2.5 0 0 1 40.5 36L22.5 36A2.5 2.5 0 0 1 20 33.5L20 30.5A2.5 2.5 0 0 1 22.5 28Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.5 44L47.5 44A2.5 2.5 0 0 1 50 46.5L50 49.5A2.5 2.5 0 0 1 47.5 52L22.5 52A2.5 2.5 0 0 1 20 49.5L20 46.5A2.5 2.5 0 0 1 22.5 44Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 962 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Align drawing views</title>
|
||||
<path d="M10.5 24L23.5 24A2.5 2.5 0 0 1 26 26.5L26 43.5A2.5 2.5 0 0 1 23.5 46L10.5 46A2.5 2.5 0 0 1 8 43.5L8 26.5A2.5 2.5 0 0 1 10.5 24Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M39.5 12L52.5 12A2.5 2.5 0 0 1 55 14.5L55 43.5A2.5 2.5 0 0 1 52.5 46L39.5 46A2.5 2.5 0 0 1 37 43.5L37 14.5A2.5 2.5 0 0 1 39.5 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M6 52L58 52" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M17 29L17 40M46 18L46 39" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing balloon</title>
|
||||
<path d="M9.5 37L29.5 37A2.5 2.5 0 0 1 32 39.5L32 52.5A2.5 2.5 0 0 1 29.5 55L9.5 55A2.5 2.5 0 0 1 7 52.5L7 39.5A2.5 2.5 0 0 1 9.5 37Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20 45L35 30L40 25" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15 50L19 37L28 45Z" fill="#236ead"/>
|
||||
<circle cx="45" cy="17" r="12" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Base drawing view</title>
|
||||
<path d="M9.5 10L54.5 10A2.5 2.5 0 0 1 57 12.5L57 51.5A2.5 2.5 0 0 1 54.5 54L9.5 54A2.5 2.5 0 0 1 7 51.5L7 12.5A2.5 2.5 0 0 1 9.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M17.5 19L42.5 19A2.5 2.5 0 0 1 45 21.5L45 39.5A2.5 2.5 0 0 1 42.5 42L17.5 42A2.5 2.5 0 0 1 15 39.5L15 21.5A2.5 2.5 0 0 1 17.5 19Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="30" cy="30.5" r="6" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M47 46L53 46M50 43L50 49" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing centre mark</title>
|
||||
<circle cx="32" cy="32" r="18" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 6L32 21M32 27L32 37M32 43L32 58M6 32L21 32M27 32L37 32M43 32L58 32" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Check drawing</title>
|
||||
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M21 19L42 19M21 29L35 29" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M27 44L37 53L57 30" fill="none" stroke="#ffffff" stroke-width="6.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M27 44L37 53L57 30" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing datum</title>
|
||||
<path d="M6 53L58 53" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 52L24 38L40 38Z" fill="#236ead"/>
|
||||
<path d="M32 38L32 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M21.5 7L42.5 7A2.5 2.5 0 0 1 45 9.5L45 24.5A2.5 2.5 0 0 1 42.5 27L21.5 27A2.5 2.5 0 0 1 19 24.5L19 9.5A2.5 2.5 0 0 1 21.5 7Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Detail drawing view</title>
|
||||
<path d="M9.5 11L30.5 11A2.5 2.5 0 0 1 33 13.5L33 45.5A2.5 2.5 0 0 1 30.5 48L9.5 48A2.5 2.5 0 0 1 7 45.5L7 13.5A2.5 2.5 0 0 1 9.5 11Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M13 18L23 18L23 33L29 33" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="23" cy="30" r="9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M31 35L39 41" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="47" cy="46" r="12" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M40 39L46 39L46 48L53 48" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1000 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing dimension</title>
|
||||
<path d="M10 10L10 55M54 10L54 55" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 14L54 14L54 36L10 36Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20 49L44 49" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M11 49L23 43L23 55Z" fill="#236ead"/>
|
||||
<path d="M53 49L41 55L41 43Z" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Edit drawing table</title>
|
||||
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
|
||||
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M25 26L54 26L54 37L25 37Z" fill="#b9ddf8"/>
|
||||
<path d="M32 32L47 32M42 27L47 32L42 37" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Hole callout</title>
|
||||
<circle cx="18" cy="43" r="13" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="18" cy="43" r="6" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M27 35L39 22L57 22" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23 40L27 28L34 35Z" fill="#236ead"/>
|
||||
<circle cx="44" cy="10" r="4.5" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M39 16L49 4" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Insert drawing image</title>
|
||||
<path d="M9.5 11L54.5 11A2.5 2.5 0 0 1 57 13.5L57 50.5A2.5 2.5 0 0 1 54.5 53L9.5 53A2.5 2.5 0 0 1 7 50.5L7 13.5A2.5 2.5 0 0 1 9.5 11Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="43" cy="23" r="5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 45L24 27L36 41L42 34L54 47Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing leader</title>
|
||||
<path d="M8.5 34L33.5 34A2.5 2.5 0 0 1 36 36.5L36 51.5A2.5 2.5 0 0 1 33.5 54L8.5 54A2.5 2.5 0 0 1 6 51.5L6 36.5A2.5 2.5 0 0 1 8.5 34Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18 45L39 18L56 18" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15 49L19 35L26 41Z" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing parts list</title>
|
||||
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
|
||||
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="15.5" cy="31.5" r="3.5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="15.5" cy="46.5" r="3.5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M30 31L48 31M30 46L48 46" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 958 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Print drawing</title>
|
||||
<path d="M18.5 7L45.5 7A2.5 2.5 0 0 1 48 9.5L48 29.5A2.5 2.5 0 0 1 45.5 32L18.5 32A2.5 2.5 0 0 1 16 29.5L16 9.5A2.5 2.5 0 0 1 18.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8.5 24L55.5 24A2.5 2.5 0 0 1 58 26.5L58 49.5A2.5 2.5 0 0 1 55.5 52L8.5 52A2.5 2.5 0 0 1 6 49.5L6 26.5A2.5 2.5 0 0 1 8.5 24Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="48" cy="32" r="2.5" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18.5 37L45.5 37A2.5 2.5 0 0 1 48 39.5L48 55.5A2.5 2.5 0 0 1 45.5 58L18.5 58A2.5 2.5 0 0 1 16 55.5L16 39.5A2.5 2.5 0 0 1 18.5 37Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23 44L41 44M23 51L36 51" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Projected drawing view</title>
|
||||
<path d="M9.5 33L31.5 33A2.5 2.5 0 0 1 34 35.5L34 52.5A2.5 2.5 0 0 1 31.5 55L9.5 55A2.5 2.5 0 0 1 7 52.5L7 35.5A2.5 2.5 0 0 1 9.5 33Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="20.5" cy="44" r="5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9.5 8L31.5 8A2.5 2.5 0 0 1 34 10.5L34 19.5A2.5 2.5 0 0 1 31.5 22L9.5 22A2.5 2.5 0 0 1 7 19.5L7 10.5A2.5 2.5 0 0 1 9.5 8Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9 25L9 30M32 25L32 30M38 44L54 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="3 5"/>
|
||||
<path d="M44 28L44 13" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M44 7L48 14L40 14Z" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Browse drawing resources</title>
|
||||
<path d="M15 7L51 7A3 3 0 0 1 54 10L54 48A3 3 0 0 1 51 51L15 51A3 3 0 0 1 12 48L12 10A3 3 0 0 1 15 7Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L46 12A3 3 0 0 1 49 15L49 53A3 3 0 0 1 46 56L10 56A3 3 0 0 1 7 53L7 15A3 3 0 0 1 10 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M14 20L42 20L42 47L14 47Z" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M14 38L42 38M28 38L28 47" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 27L34 27L34 36L22 36Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Add drawing revision</title>
|
||||
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20 19L43 19M20 29L37 29" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M43 33L58 57L28 57Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M43 33L58 57L28 57Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Drawing revision table</title>
|
||||
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
|
||||
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15 27L10 35L20 35ZM15 42L10 50L20 50Z" fill="#236ead"/>
|
||||
<path d="M31 31L49 31M31 46L49 46" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Section drawing view</title>
|
||||
<path d="M8 15L56 15L56 50L37 50L37 27L27 27L27 50L8 50Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 38L26 22M10 25L20 15M10 50L24 36M39 35L54 20M39 48L54 33M49 50L56 43" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Space drawing annotations</title>
|
||||
<path d="M28.5 9L53.5 9A2.5 2.5 0 0 1 56 11.5L56 14.5A2.5 2.5 0 0 1 53.5 17L28.5 17A2.5 2.5 0 0 1 26 14.5L26 11.5A2.5 2.5 0 0 1 28.5 9Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M28.5 28L53.5 28A2.5 2.5 0 0 1 56 30.5L56 33.5A2.5 2.5 0 0 1 53.5 36L28.5 36A2.5 2.5 0 0 1 26 33.5L26 30.5A2.5 2.5 0 0 1 28.5 28Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M28.5 47L53.5 47A2.5 2.5 0 0 1 56 49.5L56 52.5A2.5 2.5 0 0 1 53.5 55L28.5 55A2.5 2.5 0 0 1 26 52.5L26 49.5A2.5 2.5 0 0 1 28.5 47Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15 19L15 25M15 38L15 44" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15 14L10 21L20 21ZM15 30L10 23L20 23ZM15 33L10 40L20 40ZM15 49L10 42L20 42Z" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Update drawing</title>
|
||||
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.5 16L41.5 16A2.5 2.5 0 0 1 44 18.5L44 28.5A2.5 2.5 0 0 1 41.5 31L22.5 31A2.5 2.5 0 0 1 20 28.5L20 18.5A2.5 2.5 0 0 1 22.5 16Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M38 35A11 11 0 1 0 45 45" fill="none" stroke="#ffffff" stroke-width="6.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M38 35A11 11 0 1 0 45 45" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M44 40L32 37L40 28Z" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 900 B |
@@ -803,6 +803,8 @@
|
||||
"cmd.appearance_pick_up": "Aussehen aufnehmen",
|
||||
"cmd.appearance_replace": "Erscheinungsbild ersetzen",
|
||||
"cmd.assembly_bom": "Stückliste",
|
||||
"cmd.assembly_bom_item_set": "Positionsnummer ändern",
|
||||
"cmd.assembly_bom_renumber": "Positionen neu nummerieren",
|
||||
"cmd.body_rename": "Körper umbenennen",
|
||||
"cmd.browser_collapse_all": "Alles einklappen",
|
||||
"cmd.browser_expand_all": "Alles ausklappen",
|
||||
@@ -975,6 +977,7 @@
|
||||
"cmd.motion_trace": "Bewegungsbahn",
|
||||
"cmd.motion_trace_edit": "Bewegungsbahn bearbeiten",
|
||||
"cmd.motion_trace_keep": "Bewegungsbahn behalten",
|
||||
"cmd.occurrence_bom_type": "Stücklistentyp",
|
||||
"cmd.occurrence_copy": "Einfügen (Instanz)",
|
||||
"cmd.occurrence_drag": "Instanz ziehen",
|
||||
"cmd.occurrence_ground": "Festsetzen",
|
||||
@@ -1817,6 +1820,7 @@
|
||||
"prefs.dialogs_left_column": "In der linken Spalte",
|
||||
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
|
||||
"prefs.display": "Anzeige",
|
||||
"prefs.drawings": "Zeichnungen",
|
||||
"prefs.edges": "Kanten und Schattierung",
|
||||
"prefs.extend": "Erweitern",
|
||||
"prefs.extra_navigation": "Mit Strg und gedrückter linker Maustaste auf einer freien Fläche drehen. Leertaste gedrückt halten, um die Ansicht zu verschieben.",
|
||||
@@ -3480,6 +3484,7 @@
|
||||
"ui.fine": "Fein",
|
||||
"ui.finest": "Sehr fein",
|
||||
"ui.finish_sketch": "Skizze beenden",
|
||||
"ui.first_angle": "Erster Winkel",
|
||||
"ui.fit": "Einpassen",
|
||||
"ui.fix_unfix": "Fixieren/Lösen",
|
||||
"ui.flat": "Flach",
|
||||
@@ -3738,6 +3743,7 @@
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
|
||||
"ui.projection_of_new_drawings": "Projektion neuer Zeichnungen",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
@@ -3894,6 +3900,7 @@
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Design",
|
||||
"ui.thickness": "Dicke",
|
||||
"ui.third_angle": "Dritter Winkel",
|
||||
"ui.thread": "Gewinde",
|
||||
"ui.tie_ends": "Verknüpfen",
|
||||
"ui.tie_ends_tip": "Alle unverknüpften Enden mit Abhängigkeiten versehen",
|
||||
|
||||
@@ -819,6 +819,8 @@
|
||||
"cmd.appearance_pick_up": "Pick Up Appearance",
|
||||
"cmd.appearance_replace": "Replace Appearance",
|
||||
"cmd.assembly_bom": "Bill of Materials",
|
||||
"cmd.assembly_bom_item_set": "Change Item Number",
|
||||
"cmd.assembly_bom_renumber": "Renumber Items",
|
||||
"cmd.body_rename": "Rename Body",
|
||||
"cmd.browser_collapse_all": "Collapse All",
|
||||
"cmd.browser_expand_all": "Expand All",
|
||||
@@ -909,6 +911,36 @@
|
||||
"cmd.document_metadata_set": "Change Design Properties",
|
||||
"cmd.document_revision": "Design Revision",
|
||||
"cmd.document_units": "Change Design Units",
|
||||
"cmd.drawing_balloon_add": "Add Balloon",
|
||||
"cmd.drawing_balloon_edit": "Edit Balloon",
|
||||
"cmd.drawing_balloon_inspect": "Inspect Balloon",
|
||||
"cmd.drawing_close": "Close Drawing",
|
||||
"cmd.drawing_create": "Create Drawing",
|
||||
"cmd.drawing_delete": "Delete Drawing",
|
||||
"cmd.drawing_dimension_add": "Add Drawing Dimension",
|
||||
"cmd.drawing_dimension_edit": "Edit Drawing Dimension",
|
||||
"cmd.drawing_dimension_inspect": "Inspect Drawing Dimension",
|
||||
"cmd.drawing_export": "Export Sheet",
|
||||
"cmd.drawing_inspect": "Inspect Drawing",
|
||||
"cmd.drawing_list": "List Drawings",
|
||||
"cmd.drawing_open": "Open Drawing",
|
||||
"cmd.drawing_properties": "Drawing Properties",
|
||||
"cmd.drawing_properties_remove": "Remove Drawing Property",
|
||||
"cmd.drawing_properties_set": "Change Drawing Properties",
|
||||
"cmd.drawing_revision_add": "Add Revision",
|
||||
"cmd.drawing_revision_delete": "Delete Revision",
|
||||
"cmd.drawing_revision_edit": "Edit Revision",
|
||||
"cmd.drawing_scene": "Draw Sheet",
|
||||
"cmd.drawing_set": "Drawing Settings",
|
||||
"cmd.drawing_sheet_add": "Add Sheet",
|
||||
"cmd.drawing_sheet_delete": "Delete Sheet",
|
||||
"cmd.drawing_sheet_reorder": "Reorder Sheets",
|
||||
"cmd.drawing_sheet_set": "Sheet Settings",
|
||||
"cmd.drawing_view_copy_to_sheet": "Copy View to Sheet",
|
||||
"cmd.drawing_view_edit": "Edit Drawing View",
|
||||
"cmd.drawing_view_inspect": "Inspect Drawing View",
|
||||
"cmd.drawing_view_move_to_sheet": "Move View to Sheet",
|
||||
"cmd.drawing_view_snapshot": "Drawing View Snapshot",
|
||||
"cmd.edit_checkpoint": "Checkpoint",
|
||||
"cmd.edit_repeat": "Repeat Last Command",
|
||||
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
|
||||
@@ -993,6 +1025,7 @@
|
||||
"cmd.motion_trace_edit": "Edit Motion Trace",
|
||||
"cmd.motion_trace_keep": "Keep Motion Trace",
|
||||
"cmd.occurrence_align": "Align Occurrence",
|
||||
"cmd.occurrence_bom_type": "Bill of Materials Type",
|
||||
"cmd.occurrence_copy": "Paste (Instance)",
|
||||
"cmd.occurrence_drag": "Drag Occurrence",
|
||||
"cmd.occurrence_ground": "Ground",
|
||||
@@ -1870,6 +1903,7 @@
|
||||
"prefs.dialogs_left_column": "In the left column",
|
||||
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
|
||||
"prefs.display": "Display",
|
||||
"prefs.drawings": "Drawings",
|
||||
"prefs.edges": "Edges and shading",
|
||||
"prefs.extend": "Extend",
|
||||
"prefs.extra_navigation": "Ctrl+left drag on empty space orbits; hold Space to pan.",
|
||||
@@ -4140,6 +4174,7 @@
|
||||
"ui.fine": "Fine",
|
||||
"ui.finest": "Finest",
|
||||
"ui.finish_sketch": "Finish Sketch",
|
||||
"ui.first_angle": "First angle",
|
||||
"ui.fit": "Fit",
|
||||
"ui.fix_unfix": "Fix/Unfix",
|
||||
"ui.flat": "Flat",
|
||||
@@ -4424,6 +4459,7 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.projection_of_new_drawings": "Projection of new drawings",
|
||||
"ui.properties": "Properties",
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
@@ -4599,6 +4635,7 @@
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Theme",
|
||||
"ui.thickness": "Thickness",
|
||||
"ui.third_angle": "Third angle",
|
||||
"ui.thread": "Thread",
|
||||
"ui.tie_ends": "Tie",
|
||||
"ui.tie_ends_tip": "Constrain all untied ends",
|
||||
|
||||
@@ -819,6 +819,8 @@
|
||||
"cmd.appearance_pick_up": "外観を取得",
|
||||
"cmd.appearance_replace": "外観を置換",
|
||||
"cmd.assembly_bom": "部品表",
|
||||
"cmd.assembly_bom_item_set": "項目番号の変更",
|
||||
"cmd.assembly_bom_renumber": "項目番号の振り直し",
|
||||
"cmd.body_rename": "ボディの名前を変更",
|
||||
"cmd.browser_collapse_all": "すべて折りたたむ",
|
||||
"cmd.browser_expand_all": "すべて展開",
|
||||
@@ -909,6 +911,36 @@
|
||||
"cmd.document_metadata_set": "デザインのプロパティを変更",
|
||||
"cmd.document_revision": "デザイン リビジョン",
|
||||
"cmd.document_units": "デザインの単位を変更",
|
||||
"cmd.drawing_balloon_add": "バルーンを追加",
|
||||
"cmd.drawing_balloon_edit": "バルーンを編集",
|
||||
"cmd.drawing_balloon_inspect": "バルーンを確認",
|
||||
"cmd.drawing_close": "図面を閉じる",
|
||||
"cmd.drawing_create": "図面を作成",
|
||||
"cmd.drawing_delete": "図面を削除",
|
||||
"cmd.drawing_dimension_add": "図面寸法を追加",
|
||||
"cmd.drawing_dimension_edit": "図面寸法を編集",
|
||||
"cmd.drawing_dimension_inspect": "図面寸法を確認",
|
||||
"cmd.drawing_export": "シートを書き出す",
|
||||
"cmd.drawing_inspect": "図面を確認",
|
||||
"cmd.drawing_list": "図面を一覧表示",
|
||||
"cmd.drawing_open": "図面を開く",
|
||||
"cmd.drawing_properties": "図面のプロパティ",
|
||||
"cmd.drawing_properties_remove": "図面のプロパティを削除",
|
||||
"cmd.drawing_properties_set": "図面のプロパティを変更",
|
||||
"cmd.drawing_revision_add": "リビジョンを追加",
|
||||
"cmd.drawing_revision_delete": "リビジョンを削除",
|
||||
"cmd.drawing_revision_edit": "リビジョンを編集",
|
||||
"cmd.drawing_scene": "シートを描画",
|
||||
"cmd.drawing_set": "図面設定",
|
||||
"cmd.drawing_sheet_add": "シートを追加",
|
||||
"cmd.drawing_sheet_delete": "シートを削除",
|
||||
"cmd.drawing_sheet_reorder": "シートを並べ替え",
|
||||
"cmd.drawing_sheet_set": "シート設定",
|
||||
"cmd.drawing_view_copy_to_sheet": "ビューをシートにコピー",
|
||||
"cmd.drawing_view_edit": "図面ビューを編集",
|
||||
"cmd.drawing_view_inspect": "図面ビューを確認",
|
||||
"cmd.drawing_view_move_to_sheet": "ビューをシートに移動",
|
||||
"cmd.drawing_view_snapshot": "図面ビューのスナップショット",
|
||||
"cmd.edit_checkpoint": "チェックポイント",
|
||||
"cmd.edit_repeat": "最後のコマンドを繰り返す",
|
||||
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
|
||||
@@ -993,6 +1025,7 @@
|
||||
"cmd.motion_trace_edit": "モーション トレースを編集",
|
||||
"cmd.motion_trace_keep": "モーション トレースを保持",
|
||||
"cmd.occurrence_align": "オカレンスを整列",
|
||||
"cmd.occurrence_bom_type": "部品表の種類",
|
||||
"cmd.occurrence_copy": "貼り付け(インスタンス)",
|
||||
"cmd.occurrence_drag": "オカレンスをドラッグ",
|
||||
"cmd.occurrence_ground": "空間固定",
|
||||
@@ -1870,6 +1903,7 @@
|
||||
"prefs.dialogs_left_column": "左の列",
|
||||
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
|
||||
"prefs.display": "表示",
|
||||
"prefs.drawings": "図面",
|
||||
"prefs.edges": "エッジと陰影",
|
||||
"prefs.extend": "拡張",
|
||||
"prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。",
|
||||
@@ -4140,6 +4174,7 @@
|
||||
"ui.fine": "細かい",
|
||||
"ui.finest": "最も細かい",
|
||||
"ui.finish_sketch": "スケッチを終了",
|
||||
"ui.first_angle": "第一角法",
|
||||
"ui.fit": "フィット",
|
||||
"ui.fix_unfix": "固定/固定解除",
|
||||
"ui.flat": "展開",
|
||||
@@ -4424,6 +4459,7 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.projection_of_new_drawings": "新規図面の投影法",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
@@ -4599,6 +4635,7 @@
|
||||
"ui.text": "テキスト",
|
||||
"ui.theme": "テーマ",
|
||||
"ui.thickness": "厚さ",
|
||||
"ui.third_angle": "第三角法",
|
||||
"ui.thread": "ねじ",
|
||||
"ui.tie_ends": "拘束",
|
||||
"ui.tie_ends_tip": "未拘束をすべて拘束",
|
||||
|
||||
@@ -711,6 +711,8 @@
|
||||
"cmd.appearance_pick_up": "모양 가져오기",
|
||||
"cmd.appearance_replace": "모양 바꾸기",
|
||||
"cmd.assembly_bom": "자재 명세서",
|
||||
"cmd.assembly_bom_item_set": "항목 번호 변경",
|
||||
"cmd.assembly_bom_renumber": "항목 번호 다시 매기기",
|
||||
"cmd.body_rename": "바디 이름 변경",
|
||||
"cmd.browser_group": "새 그룹",
|
||||
"cmd.browser_move": "브라우저 항목 이동",
|
||||
@@ -870,6 +872,7 @@
|
||||
"cmd.motion_study": "운동 연구",
|
||||
"cmd.motion_trace": "모션 궤적",
|
||||
"cmd.motion_trace_keep": "모션 궤적 유지",
|
||||
"cmd.occurrence_bom_type": "자재 명세서 유형",
|
||||
"cmd.occurrence_copy": "붙여넣기(인스턴스)",
|
||||
"cmd.occurrence_ground": "고정",
|
||||
"cmd.occurrence_move": "인스턴스 이동",
|
||||
@@ -1552,6 +1555,7 @@
|
||||
"prefs.dialogs_left_column": "왼쪽 열",
|
||||
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
|
||||
"prefs.display": "표시",
|
||||
"prefs.drawings": "도면",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
"prefs.extra_navigation": "Ctrl+왼쪽 드래그로 빈 공간에서 회전하고, Space를 누른 채 이동합니다.",
|
||||
@@ -3222,6 +3226,7 @@
|
||||
"ui.fine": "세밀",
|
||||
"ui.finest": "가장 세밀",
|
||||
"ui.finish_sketch": "스케치 종료",
|
||||
"ui.first_angle": "제1각법",
|
||||
"ui.fit": "맞춤",
|
||||
"ui.fix_unfix": "고정/고정 해제",
|
||||
"ui.flat": "플랫",
|
||||
@@ -3482,6 +3487,7 @@
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
|
||||
"ui.projection_of_new_drawings": "새 도면의 투상법",
|
||||
"ui.properties": "속성",
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
@@ -3636,6 +3642,7 @@
|
||||
"ui.text": "텍스트",
|
||||
"ui.theme": "테마",
|
||||
"ui.thickness": "두께",
|
||||
"ui.third_angle": "제3각법",
|
||||
"ui.thread": "나사산",
|
||||
"ui.tie_ends": "연결",
|
||||
"ui.tie_ends_tip": "연결되지 않은 모든 끝점 구속",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_image_add": "Add Image"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_image_add": "إضافة صورة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_image_add": "Bild hinzufügen"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_image_add": "הוספת תמונה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_image_add": "画像を追加"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_image_add": "이미지 추가"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_image_add": "Thêm hình ảnh"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_image_add": "添加图像"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_status": "Drawing Status"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_status": "حالة الرسم"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_status": "Zeichnungsstatus"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_status": "מצב השרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_status": "図面の状態"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_status": "도면 상태"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_status": "Trạng thái bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_status": "绘图状态"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_apply": "Apply Drawing Template"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_apply": "تطبيق قالب لوحة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_apply": "Zeichnungsvorlage anwenden"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_apply": "החלת תבנית שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_apply": "図面テンプレートを適用"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_apply": "도면 템플릿 적용"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_apply": "Áp dụng mẫu bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_apply": "应用绘图模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_save": "Save as Drawing Template"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_save": "حفظ كقالب لوحة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_save": "Als Zeichnungsvorlage speichern"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_save": "שמירה כתבנית שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_save": "図面テンプレートとして保存"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_save": "도면 템플릿으로 저장"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_save": "Lưu thành mẫu bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_save": "另存为绘图模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_list": "List Drawing Templates",
|
||||
"ui.template_for_new_drawings": "Template for new drawings"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_list": "سرد قوالب اللوحات",
|
||||
"ui.template_for_new_drawings": "قالب اللوحات الجديدة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_list": "Zeichnungsvorlagen auflisten",
|
||||
"ui.template_for_new_drawings": "Vorlage für neue Zeichnungen"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_list": "רשימת תבניות שרטוט",
|
||||
"ui.template_for_new_drawings": "תבנית לשרטוטים חדשים"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_list": "図面テンプレートを一覧表示",
|
||||
"ui.template_for_new_drawings": "新しい図面のテンプレート"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_list": "도면 템플릿 목록",
|
||||
"ui.template_for_new_drawings": "새 도면의 템플릿"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_list": "Liệt kê mẫu bản vẽ",
|
||||
"ui.template_for_new_drawings": "Mẫu cho bản vẽ mới"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_list": "列出绘图模板",
|
||||
"ui.template_for_new_drawings": "新绘图的模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_view_base": "Base View",
|
||||
"cmd.drawing_view_projected": "Projected View"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_view_base": "العرض الأساسي",
|
||||
"cmd.drawing_view_projected": "العرض المُسقط"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_view_base": "תצוגת בסיס",
|
||||
"cmd.drawing_view_projected": "תצוגה מוטלת"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_view_base": "ベースビュー",
|
||||
"cmd.drawing_view_projected": "投影ビュー"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_view_base": "Chế độ xem cơ sở",
|
||||
"cmd.drawing_view_projected": "Chế độ xem chiếu"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_view_base": "基础视图",
|
||||
"cmd.drawing_view_projected": "投影视图"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
{
|
||||
"ar": {
|
||||
"0 objects": "0 عناصر",
|
||||
"ASSEMBLY": "التجميع",
|
||||
"Add Drawing Revision": "إضافة مراجعة للوحة",
|
||||
"Add Revision": "إضافة مراجعة",
|
||||
"Add sheet (not implemented)": "إضافة ورقة (غير منفّذ)",
|
||||
"Align Annotations": "محاذاة التعليقات التوضيحية",
|
||||
"Align Notes": "محاذاة الملاحظات",
|
||||
"Align Views": "محاذاة المساقط",
|
||||
"Annotate": "إضافة تعليقات توضيحية",
|
||||
"Apply (not implemented)": "تطبيق (غير منفّذ)",
|
||||
"Auto (Theme)": "تلقائي (السمة)",
|
||||
"Balloon": "بالون",
|
||||
"Base View": "المسقط الأساسي",
|
||||
"Border & template": "الإطار والقالب",
|
||||
"Browse Drawing Resources": "تصفح موارد اللوحات",
|
||||
"CHANGES": "التغييرات",
|
||||
"Centre Mark": "علامة المركز",
|
||||
"Check Drawing": "فحص اللوحة",
|
||||
"Close drawing panel": "إغلاق لوحة إعدادات الرسم",
|
||||
"DRAWING ANNOTATE": "تعليقات اللوحة",
|
||||
"DRAWING LAYOUT": "تخطيط اللوحة",
|
||||
"DRAWING PARTS": "أجزاء اللوحة",
|
||||
"DRAWING REVIEW": "مراجعة اللوحة",
|
||||
"Datum": "مرجع",
|
||||
"Detail View": "مسقط تفصيلي",
|
||||
"Dock at bottom": "إرساء في الأسفل",
|
||||
"Dock at top": "إرساء في الأعلى",
|
||||
"Dock beneath Browser": "إرساء أسفل المتصفح",
|
||||
"Dock left": "إرساء يسارًا",
|
||||
"Dock right": "إرساء يمينًا",
|
||||
"Dock sheet tabs": "إرساء علامات تبويب الأوراق",
|
||||
"Drafting defaults": "إعدادات الرسم الافتراضية",
|
||||
"Drawing & title block": "اللوحة وخانة العنوان",
|
||||
"Drawing Dimension": "بُعد اللوحة",
|
||||
"Drawing Line": "خط اللوحة",
|
||||
"Drawing Note": "ملاحظة اللوحة",
|
||||
"Drawing defaults (not available)": "إعدادات اللوحة الافتراضية (غير متاحة)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "لم تُنفّذ ملفات اللوحات ولا عارض الورق بعد. تعرض الأدوات المخطط لها خياراتها حيثما حُدّدت.",
|
||||
"Drawing workspace": "مساحة عمل اللوحات",
|
||||
"Edit Drawing Table": "تعديل جدول اللوحة",
|
||||
"Edit Table": "تعديل الجدول",
|
||||
"Export Drawing": "تصدير اللوحة",
|
||||
"Finish the current operation before entering Drawing": "أكمل العملية الحالية قبل الدخول إلى مساحة اللوحات",
|
||||
"Hole Callout": "بيان الثقب",
|
||||
"Image": "صورة",
|
||||
"Inherited from parent view": "موروث من المسقط الأصل",
|
||||
"Insert Image": "إدراج صورة",
|
||||
"Leader": "خط إشارة",
|
||||
"Leader Note": "ملاحظة بخط إشارة",
|
||||
"Manage sheets (not implemented)": "إدارة الأوراق (غير منفّذ)",
|
||||
"Manage sheets and docking": "إدارة الأوراق والإرساء",
|
||||
"NOTES": "الملاحظات",
|
||||
"No drawing document": "لا يوجد مستند لوحة",
|
||||
"No drawing geometry available": "لا تتوفر هندسة للوحة",
|
||||
"No drawing objects": "لا توجد عناصر في اللوحة",
|
||||
"No drawing sheet is available yet.": "لا تتوفر ورقة لوحة بعد.",
|
||||
"No drawing view available": "لا يتوفر مسقط للوحة",
|
||||
"No sheet available": "لا تتوفر ورقة",
|
||||
"Not available": "غير متاح",
|
||||
"Not configured": "غير مُعدّ",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "غير منفّذ. تصف هذه الخيارات الواجهة المخطط لها؛ لا تتغير أي بيانات للوحة.",
|
||||
"Note": "ملاحظة",
|
||||
"Paper & layout": "الورق والتخطيط",
|
||||
"Paper colour": "لون الورق",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "سيتوفر لون الورق عند تنفيذ عارض الورقة.",
|
||||
"Parent view": "المسقط الأصل",
|
||||
"Parts & Tables": "الأجزاء والجداول",
|
||||
"Parts List": "قائمة الأجزاء",
|
||||
"Print": "طباعة",
|
||||
"Print Drawing": "طباعة اللوحة",
|
||||
"Profile & units": "ملف التعريف والوحدات",
|
||||
"Projected View": "مسقط مشتق بالإسقاط",
|
||||
"RESOURCES": "الموارد",
|
||||
"REVISION": "المراجعة",
|
||||
"References": "المراجع",
|
||||
"Resources": "الموارد",
|
||||
"Return to Design to use this command.": "عُد إلى مساحة التصميم لاستخدام هذا الأمر.",
|
||||
"Review": "مراجعة",
|
||||
"Revision Table": "جدول المراجعات",
|
||||
"Revision Tbl.": "جدول المراجعات",
|
||||
"SOURCE": "المصدر",
|
||||
"SYMBOLS": "الرموز",
|
||||
"Screen appearance": "مظهر الشاشة",
|
||||
"Section View": "مسقط مقطعي",
|
||||
"Sheet Settings": "إعدادات الورقة",
|
||||
"Sheet unavailable": "الورقة غير متاحة",
|
||||
"Size & orientation": "الحجم والاتجاه",
|
||||
"Source BOM": "قائمة مواد المصدر",
|
||||
"Source Flat": "النمط المسطح للمصدر",
|
||||
"Source design": "التصميم المصدر",
|
||||
"Source properties": "خصائص المصدر",
|
||||
"Space": "توزيع المسافات",
|
||||
"Space Annotations": "توزيع المسافات بين التعليقات التوضيحية",
|
||||
"Space Notes": "توزيع المسافات بين الملاحظات",
|
||||
"TABLES": "الجداول",
|
||||
"Text style": "نمط النص",
|
||||
"Title": "العنوان",
|
||||
"Title block": "خانة العنوان",
|
||||
"UI preview": "معاينة الواجهة",
|
||||
"Units & precision": "الوحدات والدقة",
|
||||
"Update Drawing": "تحديث اللوحة",
|
||||
"View": "مسقط"
|
||||
},
|
||||
"he": {
|
||||
"0 objects": "0 עצמים",
|
||||
"ASSEMBLY": "מכלול",
|
||||
"Add Drawing Revision": "הוספת גרסה לשרטוט",
|
||||
"Add Revision": "הוספת גרסה",
|
||||
"Add sheet (not implemented)": "הוספת גיליון (טרם מומש)",
|
||||
"Align Annotations": "יישור הערות הסבר",
|
||||
"Align Notes": "יישור הערות",
|
||||
"Align Views": "יישור מבטים",
|
||||
"Annotate": "הוספת הערות הסבר",
|
||||
"Apply (not implemented)": "החלה (טרם מומש)",
|
||||
"Auto (Theme)": "אוטומטי (ערכת נושא)",
|
||||
"Balloon": "בלון",
|
||||
"Base View": "מבט בסיס",
|
||||
"Border & template": "מסגרת ותבנית",
|
||||
"Browse Drawing Resources": "עיון במשאבי שרטוט",
|
||||
"CHANGES": "שינויים",
|
||||
"Centre Mark": "סימון מרכז",
|
||||
"Check Drawing": "בדיקת שרטוט",
|
||||
"Close drawing panel": "סגירת לוח השרטוט",
|
||||
"DRAWING ANNOTATE": "הערות הסבר בשרטוט",
|
||||
"DRAWING LAYOUT": "פריסת שרטוט",
|
||||
"DRAWING PARTS": "חלקים בשרטוט",
|
||||
"DRAWING REVIEW": "סקירת שרטוט",
|
||||
"Datum": "ייחוס",
|
||||
"Detail View": "מבט מפורט",
|
||||
"Dock at bottom": "עיגון בתחתית",
|
||||
"Dock at top": "עיגון בראש החלון",
|
||||
"Dock beneath Browser": "עיגון מתחת לדפדפן",
|
||||
"Dock left": "עיגון משמאל",
|
||||
"Dock right": "עיגון מימין",
|
||||
"Dock sheet tabs": "עיגון לשוניות הגיליונות",
|
||||
"Drafting defaults": "ברירות מחדל לשרטוט",
|
||||
"Drawing & title block": "שרטוט וטבלת כותרת",
|
||||
"Drawing Dimension": "מידת שרטוט",
|
||||
"Drawing Line": "קו שרטוט",
|
||||
"Drawing Note": "הערת שרטוט",
|
||||
"Drawing defaults (not available)": "ברירות מחדל לשרטוט (לא זמינות)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "קובצי שרטוט ומנגנון רינדור הנייר טרם מומשו. כלים מתוכננים מציגים את האפשרויות שלהם במקומות שבהם הוגדרו.",
|
||||
"Drawing workspace": "סביבת עבודה לשרטוט",
|
||||
"Edit Drawing Table": "עריכת טבלת שרטוט",
|
||||
"Edit Table": "עריכת טבלה",
|
||||
"Export Drawing": "ייצוא שרטוט",
|
||||
"Finish the current operation before entering Drawing": "יש לסיים את הפעולה הנוכחית לפני המעבר לסביבת השרטוט",
|
||||
"Hole Callout": "ציון פרטי קדח",
|
||||
"Image": "תמונה",
|
||||
"Inherited from parent view": "בירושה ממבט האב",
|
||||
"Insert Image": "הוספת תמונה",
|
||||
"Leader": "קו הפניה",
|
||||
"Leader Note": "הערה עם קו הפניה",
|
||||
"Manage sheets (not implemented)": "ניהול גיליונות (טרם מומש)",
|
||||
"Manage sheets and docking": "ניהול גיליונות ועיגון",
|
||||
"NOTES": "הערות",
|
||||
"No drawing document": "אין מסמך שרטוט",
|
||||
"No drawing geometry available": "אין גאומטריית שרטוט זמינה",
|
||||
"No drawing objects": "אין עצמים בשרטוט",
|
||||
"No drawing sheet is available yet.": "אין עדיין גיליון שרטוט זמין.",
|
||||
"No drawing view available": "אין מבט שרטוט זמין",
|
||||
"No sheet available": "אין גיליון זמין",
|
||||
"Not available": "לא זמין",
|
||||
"Not configured": "לא מוגדר",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "טרם מומש. האפשרויות האלה מתארות את הממשק המתוכנן; נתוני השרטוט אינם משתנים.",
|
||||
"Note": "הערה",
|
||||
"Paper & layout": "נייר ופריסה",
|
||||
"Paper colour": "צבע הנייר",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "צבע הנייר יהיה זמין כאשר ימומש מנגנון רינדור הגיליון.",
|
||||
"Parent view": "מבט אב",
|
||||
"Parts & Tables": "חלקים וטבלאות",
|
||||
"Parts List": "רשימת חלקים",
|
||||
"Print": "הדפסה",
|
||||
"Print Drawing": "הדפסת שרטוט",
|
||||
"Profile & units": "פרופיל ויחידות",
|
||||
"Projected View": "מבט מוטל",
|
||||
"RESOURCES": "משאבים",
|
||||
"REVISION": "גרסה",
|
||||
"References": "הפניות",
|
||||
"Resources": "משאבים",
|
||||
"Return to Design to use this command.": "יש לחזור לסביבת התכן כדי להשתמש בפקודה זו.",
|
||||
"Review": "סקירה",
|
||||
"Revision Table": "טבלת גרסאות",
|
||||
"Revision Tbl.": "טבלת גרסאות",
|
||||
"SOURCE": "מקור",
|
||||
"SYMBOLS": "סמלים",
|
||||
"Screen appearance": "מראה המסך",
|
||||
"Section View": "מבט חתך",
|
||||
"Sheet Settings": "הגדרות גיליון",
|
||||
"Sheet unavailable": "הגיליון אינו זמין",
|
||||
"Size & orientation": "גודל וכיוון",
|
||||
"Source BOM": "רשימת חלקים של המקור",
|
||||
"Source Flat": "פריסת המקור",
|
||||
"Source design": "תכן המקור",
|
||||
"Source properties": "מאפייני המקור",
|
||||
"Space": "ריווח",
|
||||
"Space Annotations": "ריווח הערות הסבר",
|
||||
"Space Notes": "ריווח הערות",
|
||||
"TABLES": "טבלאות",
|
||||
"Text style": "סגנון טקסט",
|
||||
"Title": "כותרת",
|
||||
"Title block": "טבלת כותרת",
|
||||
"UI preview": "תצוגה מקדימה של הממשק",
|
||||
"Units & precision": "יחידות ודיוק",
|
||||
"Update Drawing": "עדכון שרטוט",
|
||||
"View": "מבט"
|
||||
},
|
||||
"vi": {
|
||||
"0 objects": "0 đối tượng",
|
||||
"ASSEMBLY": "CỤM LẮP RÁP",
|
||||
"Add Drawing Revision": "Thêm lần sửa đổi bản vẽ",
|
||||
"Add Revision": "Thêm lần sửa đổi",
|
||||
"Add sheet (not implemented)": "Thêm tờ (chưa triển khai)",
|
||||
"Align Annotations": "Căn chỉnh chú thích",
|
||||
"Align Notes": "Căn chỉnh ghi chú",
|
||||
"Align Views": "Căn chỉnh hình chiếu",
|
||||
"Annotate": "Thêm chú thích",
|
||||
"Apply (not implemented)": "Áp dụng (chưa triển khai)",
|
||||
"Auto (Theme)": "Tự động (theo giao diện)",
|
||||
"Balloon": "Bóng chú thích",
|
||||
"Base View": "Hình chiếu cơ sở",
|
||||
"Border & template": "Khung viền và mẫu",
|
||||
"Browse Drawing Resources": "Duyệt tài nguyên bản vẽ",
|
||||
"CHANGES": "THAY ĐỔI",
|
||||
"Centre Mark": "Dấu tâm",
|
||||
"Check Drawing": "Kiểm tra bản vẽ",
|
||||
"Close drawing panel": "Đóng bảng bản vẽ",
|
||||
"DRAWING ANNOTATE": "CHÚ THÍCH BẢN VẼ",
|
||||
"DRAWING LAYOUT": "BỐ CỤC BẢN VẼ",
|
||||
"DRAWING PARTS": "CHI TIẾT BẢN VẼ",
|
||||
"DRAWING REVIEW": "RÀ SOÁT BẢN VẼ",
|
||||
"Datum": "Chuẩn",
|
||||
"Detail View": "Hình chiếu chi tiết",
|
||||
"Dock at bottom": "Gắn ở cạnh dưới",
|
||||
"Dock at top": "Gắn ở cạnh trên",
|
||||
"Dock beneath Browser": "Gắn bên dưới cây thư mục",
|
||||
"Dock left": "Gắn bên trái",
|
||||
"Dock right": "Gắn bên phải",
|
||||
"Dock sheet tabs": "Gắn các thẻ tờ bản vẽ",
|
||||
"Drafting defaults": "Thiết lập mặc định khi lập bản vẽ",
|
||||
"Drawing & title block": "Bản vẽ và khung tên",
|
||||
"Drawing Dimension": "Kích thước bản vẽ",
|
||||
"Drawing Line": "Đường bản vẽ",
|
||||
"Drawing Note": "Ghi chú bản vẽ",
|
||||
"Drawing defaults (not available)": "Thiết lập mặc định của bản vẽ (chưa có)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Tệp bản vẽ và bộ kết xuất trang giấy chưa được triển khai. Các công cụ dự kiến hiển thị tùy chọn ở những nơi đã được định nghĩa.",
|
||||
"Drawing workspace": "Không gian làm việc bản vẽ",
|
||||
"Edit Drawing Table": "Sửa bảng trong bản vẽ",
|
||||
"Edit Table": "Sửa bảng",
|
||||
"Export Drawing": "Xuất bản vẽ",
|
||||
"Finish the current operation before entering Drawing": "Hoàn tất thao tác hiện tại trước khi chuyển sang không gian Bản vẽ",
|
||||
"Hole Callout": "Chú dẫn lỗ",
|
||||
"Image": "Hình ảnh",
|
||||
"Inherited from parent view": "Kế thừa từ hình chiếu gốc",
|
||||
"Insert Image": "Chèn hình ảnh",
|
||||
"Leader": "Đường dẫn chú thích",
|
||||
"Leader Note": "Ghi chú có đường dẫn",
|
||||
"Manage sheets (not implemented)": "Quản lý tờ (chưa triển khai)",
|
||||
"Manage sheets and docking": "Quản lý tờ và vị trí gắn",
|
||||
"NOTES": "GHI CHÚ",
|
||||
"No drawing document": "Không có tài liệu bản vẽ",
|
||||
"No drawing geometry available": "Chưa có hình học bản vẽ",
|
||||
"No drawing objects": "Không có đối tượng bản vẽ",
|
||||
"No drawing sheet is available yet.": "Chưa có tờ bản vẽ nào.",
|
||||
"No drawing view available": "Chưa có hình chiếu bản vẽ",
|
||||
"No sheet available": "Chưa có tờ",
|
||||
"Not available": "Chưa có",
|
||||
"Not configured": "Chưa cấu hình",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Chưa triển khai. Các tùy chọn này mô tả giao diện dự kiến; không có dữ liệu bản vẽ nào bị thay đổi.",
|
||||
"Note": "Ghi chú",
|
||||
"Paper & layout": "Giấy và bố cục",
|
||||
"Paper colour": "Màu giấy",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "Có thể chọn màu giấy khi bộ kết xuất tờ bản vẽ được triển khai.",
|
||||
"Parent view": "Hình chiếu gốc",
|
||||
"Parts & Tables": "Chi tiết và bảng",
|
||||
"Parts List": "Danh sách chi tiết",
|
||||
"Print": "In",
|
||||
"Print Drawing": "In bản vẽ",
|
||||
"Profile & units": "Hồ sơ và đơn vị",
|
||||
"Projected View": "Hình chiếu dẫn xuất",
|
||||
"RESOURCES": "TÀI NGUYÊN",
|
||||
"REVISION": "LẦN SỬA ĐỔI",
|
||||
"References": "Tham chiếu",
|
||||
"Resources": "Tài nguyên",
|
||||
"Return to Design to use this command.": "Quay lại không gian Thiết kế để dùng lệnh này.",
|
||||
"Review": "Rà soát",
|
||||
"Revision Table": "Bảng sửa đổi",
|
||||
"Revision Tbl.": "Bảng sửa đổi",
|
||||
"SOURCE": "NGUỒN",
|
||||
"SYMBOLS": "KÝ HIỆU",
|
||||
"Screen appearance": "Diện mạo trên màn hình",
|
||||
"Section View": "Hình cắt",
|
||||
"Sheet Settings": "Cài đặt tờ",
|
||||
"Sheet unavailable": "Tờ không khả dụng",
|
||||
"Size & orientation": "Kích thước và hướng",
|
||||
"Source BOM": "Bảng kê vật tư nguồn",
|
||||
"Source Flat": "Bản trải phẳng nguồn",
|
||||
"Source design": "Thiết kế nguồn",
|
||||
"Source properties": "Thuộc tính nguồn",
|
||||
"Space": "Giãn cách",
|
||||
"Space Annotations": "Giãn cách chú thích",
|
||||
"Space Notes": "Giãn cách ghi chú",
|
||||
"TABLES": "BẢNG",
|
||||
"Text style": "Kiểu chữ",
|
||||
"Title": "Tiêu đề",
|
||||
"Title block": "Khung tên",
|
||||
"UI preview": "Xem trước giao diện",
|
||||
"Units & precision": "Đơn vị và độ chính xác",
|
||||
"Update Drawing": "Cập nhật bản vẽ",
|
||||
"View": "Hình chiếu"
|
||||
},
|
||||
"en": {
|
||||
"0 objects": "0 objects",
|
||||
"ASSEMBLY": "ASSEMBLY",
|
||||
"Add Drawing Revision": "Add Drawing Revision",
|
||||
"Add Revision": "Add Revision",
|
||||
"Add sheet (not implemented)": "Add sheet (not implemented)",
|
||||
"Align Annotations": "Align Annotations",
|
||||
"Align Notes": "Align Notes",
|
||||
"Align Views": "Align Views",
|
||||
"Annotate": "Annotate",
|
||||
"Apply (not implemented)": "Apply (not implemented)",
|
||||
"Auto (Theme)": "Auto (Theme)",
|
||||
"Balloon": "Balloon",
|
||||
"Base View": "Base View",
|
||||
"Border & template": "Border & template",
|
||||
"Browse Drawing Resources": "Browse Drawing Resources",
|
||||
"CHANGES": "CHANGES",
|
||||
"Centre Mark": "Centre Mark",
|
||||
"Check Drawing": "Check Drawing",
|
||||
"Close drawing panel": "Close drawing panel",
|
||||
"DRAWING ANNOTATE": "DRAWING ANNOTATE",
|
||||
"DRAWING LAYOUT": "DRAWING LAYOUT",
|
||||
"DRAWING PARTS": "DRAWING PARTS",
|
||||
"DRAWING REVIEW": "DRAWING REVIEW",
|
||||
"Datum": "Datum",
|
||||
"Detail View": "Detail View",
|
||||
"Dock at bottom": "Dock at bottom",
|
||||
"Dock at top": "Dock at top",
|
||||
"Dock beneath Browser": "Dock beneath Browser",
|
||||
"Dock left": "Dock left",
|
||||
"Dock right": "Dock right",
|
||||
"Dock sheet tabs": "Dock sheet tabs",
|
||||
"Drafting defaults": "Drafting defaults",
|
||||
"Drawing & title block": "Drawing & title block",
|
||||
"Drawing Dimension": "Drawing Dimension",
|
||||
"Drawing Line": "Drawing Line",
|
||||
"Drawing Note": "Drawing Note",
|
||||
"Drawing defaults (not available)": "Drawing defaults (not available)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.",
|
||||
"Drawing workspace": "Drawing workspace",
|
||||
"Edit Drawing Table": "Edit Drawing Table",
|
||||
"Edit Table": "Edit Table",
|
||||
"Export Drawing": "Export Drawing",
|
||||
"Finish the current operation before entering Drawing": "Finish the current operation before entering Drawing",
|
||||
"Hole Callout": "Hole Callout",
|
||||
"Image": "Image",
|
||||
"Inherited from parent view": "Inherited from parent view",
|
||||
"Insert Image": "Insert Image",
|
||||
"Leader": "Leader",
|
||||
"Leader Note": "Leader Note",
|
||||
"Manage sheets (not implemented)": "Manage sheets (not implemented)",
|
||||
"Manage sheets and docking": "Manage sheets and docking",
|
||||
"NOTES": "NOTES",
|
||||
"No drawing document": "No drawing document",
|
||||
"No drawing geometry available": "No drawing geometry available",
|
||||
"No drawing objects": "No drawing objects",
|
||||
"No drawing sheet is available yet.": "No drawing sheet is available yet.",
|
||||
"No drawing view available": "No drawing view available",
|
||||
"No sheet available": "No sheet available",
|
||||
"Not available": "Not available",
|
||||
"Not configured": "Not configured",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Not implemented. These options describe the planned interface; no drawing data is changed.",
|
||||
"Note": "Note",
|
||||
"Paper & layout": "Paper & layout",
|
||||
"Paper colour": "Paper colour",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "Paper colour will be available when the sheet renderer is implemented.",
|
||||
"Parent view": "Parent view",
|
||||
"Parts & Tables": "Parts & Tables",
|
||||
"Parts List": "Parts List",
|
||||
"Print": "Print",
|
||||
"Print Drawing": "Print Drawing",
|
||||
"Profile & units": "Profile & units",
|
||||
"Projected View": "Projected View",
|
||||
"RESOURCES": "RESOURCES",
|
||||
"REVISION": "REVISION",
|
||||
"References": "References",
|
||||
"Resources": "Resources",
|
||||
"Return to Design to use this command.": "Return to Design to use this command.",
|
||||
"Review": "Review",
|
||||
"Revision Table": "Revision Table",
|
||||
"Revision Tbl.": "Revision Tbl.",
|
||||
"SOURCE": "SOURCE",
|
||||
"SYMBOLS": "SYMBOLS",
|
||||
"Screen appearance": "Screen appearance",
|
||||
"Section View": "Section View",
|
||||
"Sheet Settings": "Sheet Settings",
|
||||
"Sheet unavailable": "Sheet unavailable",
|
||||
"Size & orientation": "Size & orientation",
|
||||
"Source BOM": "Source BOM",
|
||||
"Source Flat": "Source Flat",
|
||||
"Source design": "Source design",
|
||||
"Source properties": "Source properties",
|
||||
"Space": "Space",
|
||||
"Space Annotations": "Space Annotations",
|
||||
"Space Notes": "Space Notes",
|
||||
"TABLES": "TABLES",
|
||||
"Text style": "Text style",
|
||||
"Title": "Title",
|
||||
"Title block": "Title block",
|
||||
"UI preview": "UI preview",
|
||||
"Units & precision": "Units & precision",
|
||||
"Update Drawing": "Update Drawing",
|
||||
"View": "View"
|
||||
},
|
||||
"ja": {
|
||||
"0 objects": "0個のオブジェクト",
|
||||
"ASSEMBLY": "アセンブリ",
|
||||
"Add Drawing Revision": "図面の改訂を追加",
|
||||
"Add Revision": "改訂を追加",
|
||||
"Add sheet (not implemented)": "シートを追加(未実装)",
|
||||
"Align Annotations": "注釈を整列",
|
||||
"Align Notes": "注記を整列",
|
||||
"Align Views": "ビューを整列",
|
||||
"Annotate": "注釈",
|
||||
"Apply (not implemented)": "適用(未実装)",
|
||||
"Auto (Theme)": "自動(テーマ)",
|
||||
"Balloon": "バルーン",
|
||||
"Base View": "基準ビュー",
|
||||
"Border & template": "図枠とテンプレート",
|
||||
"Browse Drawing Resources": "図面リソースを参照",
|
||||
"CHANGES": "変更",
|
||||
"Centre Mark": "中心マーク",
|
||||
"Check Drawing": "図面をチェック",
|
||||
"Close drawing panel": "図面パネルを閉じる",
|
||||
"DRAWING ANNOTATE": "図面の注釈",
|
||||
"DRAWING LAYOUT": "図面のレイアウト",
|
||||
"DRAWING PARTS": "図面の部品",
|
||||
"DRAWING REVIEW": "図面の確認",
|
||||
"Datum": "データム",
|
||||
"Detail View": "詳細ビュー",
|
||||
"Dock at bottom": "下部にドッキング",
|
||||
"Dock at top": "上部にドッキング",
|
||||
"Dock beneath Browser": "ブラウザーの下にドッキング",
|
||||
"Dock left": "左にドッキング",
|
||||
"Dock right": "右にドッキング",
|
||||
"Dock sheet tabs": "シートタブをドッキング",
|
||||
"Drafting defaults": "製図の既定値",
|
||||
"Drawing & title block": "図面と表題欄",
|
||||
"Drawing Dimension": "図面寸法",
|
||||
"Drawing Line": "図面の線分",
|
||||
"Drawing Note": "図面の注記",
|
||||
"Drawing defaults (not available)": "図面の既定値(利用不可)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "図面ファイルと用紙レンダラーは未実装です。予定されているツールでは、設定項目が定義済みの場合に表示されます。",
|
||||
"Drawing workspace": "図面ワークスペース",
|
||||
"Edit Drawing Table": "図面の表を編集",
|
||||
"Edit Table": "表を編集",
|
||||
"Export Drawing": "図面をエクスポート",
|
||||
"Finish the current operation before entering Drawing": "現在の操作を完了してから図面ワークスペースに移動してください",
|
||||
"Hole Callout": "穴注記",
|
||||
"Image": "画像",
|
||||
"Inherited from parent view": "親ビューから継承",
|
||||
"Insert Image": "画像を挿入",
|
||||
"Leader": "引出線",
|
||||
"Leader Note": "引出線付き注記",
|
||||
"Manage sheets (not implemented)": "シートを管理(未実装)",
|
||||
"Manage sheets and docking": "シートとドッキングを管理",
|
||||
"NOTES": "注記",
|
||||
"No drawing document": "図面ドキュメントがありません",
|
||||
"No drawing geometry available": "利用可能な図面ジオメトリがありません",
|
||||
"No drawing objects": "図面オブジェクトがありません",
|
||||
"No drawing sheet is available yet.": "図面シートはまだ利用できません。",
|
||||
"No drawing view available": "利用可能な図面ビューがありません",
|
||||
"No sheet available": "利用可能なシートがありません",
|
||||
"Not available": "利用不可",
|
||||
"Not configured": "未設定",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "未実装です。これらの設定項目は予定されているインターフェースを示すもので、図面データは変更されません。",
|
||||
"Note": "注記",
|
||||
"Paper & layout": "用紙とレイアウト",
|
||||
"Paper colour": "用紙の色",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "用紙の色はシートレンダラーの実装後に設定できます。",
|
||||
"Parent view": "親ビュー",
|
||||
"Parts & Tables": "部品と表",
|
||||
"Parts List": "部品表",
|
||||
"Print": "印刷",
|
||||
"Print Drawing": "図面を印刷",
|
||||
"Profile & units": "製図規格と単位",
|
||||
"Projected View": "投影ビュー",
|
||||
"RESOURCES": "リソース",
|
||||
"REVISION": "改訂",
|
||||
"References": "参照",
|
||||
"Resources": "リソース",
|
||||
"Return to Design to use this command.": "このコマンドを使用するには、デザインワークスペースに戻ってください。",
|
||||
"Review": "確認",
|
||||
"Revision Table": "改訂表",
|
||||
"Revision Tbl.": "改訂表",
|
||||
"SOURCE": "参照元",
|
||||
"SYMBOLS": "記号",
|
||||
"Screen appearance": "画面表示",
|
||||
"Section View": "断面ビュー",
|
||||
"Sheet Settings": "シート設定",
|
||||
"Sheet unavailable": "シートを利用できません",
|
||||
"Size & orientation": "サイズと向き",
|
||||
"Source BOM": "参照元の部品表",
|
||||
"Source Flat": "参照元の展開図",
|
||||
"Source design": "参照元のデザイン",
|
||||
"Source properties": "参照元のプロパティ",
|
||||
"Space": "等間隔",
|
||||
"Space Annotations": "注釈を等間隔に",
|
||||
"Space Notes": "注記を等間隔に",
|
||||
"TABLES": "表",
|
||||
"Text style": "文字スタイル",
|
||||
"Title": "タイトル",
|
||||
"Title block": "表題欄",
|
||||
"UI preview": "UIプレビュー",
|
||||
"Units & precision": "単位と精度",
|
||||
"Update Drawing": "図面を更新",
|
||||
"View": "ビュー"
|
||||
},
|
||||
"zh": {
|
||||
"0 objects": "0 个对象",
|
||||
"ASSEMBLY": "装配",
|
||||
"Add Drawing Revision": "添加图纸修订",
|
||||
"Add Revision": "添加修订",
|
||||
"Add sheet (not implemented)": "添加图纸页(未实现)",
|
||||
"Align Annotations": "对齐标注",
|
||||
"Align Notes": "对齐注释",
|
||||
"Align Views": "对齐视图",
|
||||
"Annotate": "标注",
|
||||
"Apply (not implemented)": "应用(未实现)",
|
||||
"Auto (Theme)": "自动(跟随主题)",
|
||||
"Balloon": "零件序号",
|
||||
"Base View": "基本视图",
|
||||
"Border & template": "图框与模板",
|
||||
"Browse Drawing Resources": "浏览制图资源",
|
||||
"CHANGES": "更改",
|
||||
"Centre Mark": "中心标记",
|
||||
"Check Drawing": "检查图纸",
|
||||
"Close drawing panel": "关闭制图面板",
|
||||
"DRAWING ANNOTATE": "图纸标注",
|
||||
"DRAWING LAYOUT": "图纸布局",
|
||||
"DRAWING PARTS": "图纸零件",
|
||||
"DRAWING REVIEW": "图纸审阅",
|
||||
"Datum": "基准",
|
||||
"Detail View": "局部放大视图",
|
||||
"Dock at bottom": "停靠到底部",
|
||||
"Dock at top": "停靠到顶部",
|
||||
"Dock beneath Browser": "停靠到浏览器下方",
|
||||
"Dock left": "停靠到左侧",
|
||||
"Dock right": "停靠到右侧",
|
||||
"Dock sheet tabs": "停靠图纸页选项卡",
|
||||
"Drafting defaults": "制图默认设置",
|
||||
"Drawing & title block": "图纸与标题栏",
|
||||
"Drawing Dimension": "图纸尺寸标注",
|
||||
"Drawing Line": "图纸线段",
|
||||
"Drawing Note": "图纸注释",
|
||||
"Drawing defaults (not available)": "图纸默认设置(不可用)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "图纸文件和纸张渲染器尚未实现。规划中的工具会显示已定义的选项。",
|
||||
"Drawing workspace": "工程图工作区",
|
||||
"Edit Drawing Table": "编辑图纸表格",
|
||||
"Edit Table": "编辑表格",
|
||||
"Export Drawing": "导出图纸",
|
||||
"Finish the current operation before entering Drawing": "请先完成当前操作,再进入工程图工作区",
|
||||
"Hole Callout": "孔标注",
|
||||
"Image": "图像",
|
||||
"Inherited from parent view": "继承父视图",
|
||||
"Insert Image": "插入图像",
|
||||
"Leader": "引线",
|
||||
"Leader Note": "引线注释",
|
||||
"Manage sheets (not implemented)": "管理图纸页(未实现)",
|
||||
"Manage sheets and docking": "管理图纸页与停靠位置",
|
||||
"NOTES": "注释",
|
||||
"No drawing document": "没有图纸文档",
|
||||
"No drawing geometry available": "没有可用的图纸几何",
|
||||
"No drawing objects": "没有图纸对象",
|
||||
"No drawing sheet is available yet.": "目前尚无可用的图纸页。",
|
||||
"No drawing view available": "没有可用的图纸视图",
|
||||
"No sheet available": "没有可用的图纸页",
|
||||
"Not available": "不可用",
|
||||
"Not configured": "未配置",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "尚未实现。这些选项展示规划中的界面,不会更改任何图纸数据。",
|
||||
"Note": "注释",
|
||||
"Paper & layout": "纸张与布局",
|
||||
"Paper colour": "纸张颜色",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "图纸页渲染器实现后即可设置纸张颜色。",
|
||||
"Parent view": "父视图",
|
||||
"Parts & Tables": "零件与表格",
|
||||
"Parts List": "零件明细表",
|
||||
"Print": "打印",
|
||||
"Print Drawing": "打印图纸",
|
||||
"Profile & units": "制图规范与单位",
|
||||
"Projected View": "投影视图",
|
||||
"RESOURCES": "资源",
|
||||
"REVISION": "修订",
|
||||
"References": "引用",
|
||||
"Resources": "资源",
|
||||
"Return to Design to use this command.": "请返回设计工作区使用此命令。",
|
||||
"Review": "审阅",
|
||||
"Revision Table": "修订表",
|
||||
"Revision Tbl.": "修订表",
|
||||
"SOURCE": "源设计",
|
||||
"SYMBOLS": "符号",
|
||||
"Screen appearance": "屏幕外观",
|
||||
"Section View": "剖视图",
|
||||
"Sheet Settings": "图纸页设置",
|
||||
"Sheet unavailable": "图纸页不可用",
|
||||
"Size & orientation": "尺寸与方向",
|
||||
"Source BOM": "源设计 BOM",
|
||||
"Source Flat": "源设计展开图",
|
||||
"Source design": "源设计",
|
||||
"Source properties": "源设计属性",
|
||||
"Space": "均布",
|
||||
"Space Annotations": "等间距分布标注",
|
||||
"Space Notes": "等间距分布注释",
|
||||
"TABLES": "表格",
|
||||
"Text style": "文字样式",
|
||||
"Title": "标题",
|
||||
"Title block": "标题栏",
|
||||
"UI preview": "界面预览",
|
||||
"Units & precision": "单位与精度",
|
||||
"Update Drawing": "更新图纸",
|
||||
"View": "视图"
|
||||
},
|
||||
"de": {
|
||||
"0 objects": "0 Objekte",
|
||||
"ASSEMBLY": "BAUGRUPPE",
|
||||
"Add Drawing Revision": "Zeichnungsrevision hinzufügen",
|
||||
"Add Revision": "Revision hinzufügen",
|
||||
"Add sheet (not implemented)": "Blatt hinzufügen (nicht implementiert)",
|
||||
"Align Annotations": "Beschriftungen ausrichten",
|
||||
"Align Notes": "Hinweise ausrichten",
|
||||
"Align Views": "Ansichten ausrichten",
|
||||
"Annotate": "Beschriften",
|
||||
"Apply (not implemented)": "Anwenden (nicht implementiert)",
|
||||
"Auto (Theme)": "Automatisch (Design)",
|
||||
"Balloon": "Positionssymbol",
|
||||
"Base View": "Basisansicht",
|
||||
"Border & template": "Rahmen & Vorlage",
|
||||
"Browse Drawing Resources": "Zeichnungsressourcen durchsuchen",
|
||||
"CHANGES": "ÄNDERUNGEN",
|
||||
"Centre Mark": "Mittelpunktmarkierung",
|
||||
"Check Drawing": "Zeichnung prüfen",
|
||||
"Close drawing panel": "Zeichnungspanel schließen",
|
||||
"DRAWING ANNOTATE": "ZEICHNUNG BESCHRIFTEN",
|
||||
"DRAWING LAYOUT": "ZEICHNUNGSLAYOUT",
|
||||
"DRAWING PARTS": "ZEICHNUNGSTEILE",
|
||||
"DRAWING REVIEW": "ZEICHNUNGSPRÜFUNG",
|
||||
"Datum": "Bezug",
|
||||
"Detail View": "Detailansicht",
|
||||
"Dock at bottom": "Unten andocken",
|
||||
"Dock at top": "Oben andocken",
|
||||
"Dock beneath Browser": "Unter dem Browser andocken",
|
||||
"Dock left": "Links andocken",
|
||||
"Dock right": "Rechts andocken",
|
||||
"Dock sheet tabs": "Blattregister andocken",
|
||||
"Drafting defaults": "Vorgaben für die Zeichnungserstellung",
|
||||
"Drawing & title block": "Zeichnung & Schriftfeld",
|
||||
"Drawing Dimension": "Zeichnungsbemaßung",
|
||||
"Drawing Line": "Zeichnungslinie",
|
||||
"Drawing Note": "Zeichnungshinweis",
|
||||
"Drawing defaults (not available)": "Zeichnungsvorgaben (nicht verfügbar)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Zeichnungsdateien und die Papierdarstellung sind noch nicht implementiert. Geplante Werkzeuge zeigen ihre Optionen an, sofern diese bereits festgelegt sind.",
|
||||
"Drawing workspace": "Zeichnungsarbeitsbereich",
|
||||
"Edit Drawing Table": "Zeichnungstabelle bearbeiten",
|
||||
"Edit Table": "Tabelle bearbeiten",
|
||||
"Export Drawing": "Zeichnung exportieren",
|
||||
"Finish the current operation before entering Drawing": "Beenden Sie den aktuellen Vorgang, bevor Sie zum Zeichnungsarbeitsbereich wechseln",
|
||||
"Hole Callout": "Bohrungsangabe",
|
||||
"Image": "Bild",
|
||||
"Inherited from parent view": "Von der übergeordneten Ansicht übernommen",
|
||||
"Insert Image": "Bild einfügen",
|
||||
"Leader": "Bezugslinie",
|
||||
"Leader Note": "Hinweis mit Bezugslinie",
|
||||
"Manage sheets (not implemented)": "Blätter verwalten (nicht implementiert)",
|
||||
"Manage sheets and docking": "Blätter und Andockpositionen verwalten",
|
||||
"NOTES": "HINWEISE",
|
||||
"No drawing document": "Kein Zeichnungsdokument",
|
||||
"No drawing geometry available": "Keine Zeichnungsgeometrie verfügbar",
|
||||
"No drawing objects": "Keine Zeichnungsobjekte",
|
||||
"No drawing sheet is available yet.": "Es ist noch kein Zeichnungsblatt verfügbar.",
|
||||
"No drawing view available": "Keine Zeichnungsansicht verfügbar",
|
||||
"No sheet available": "Kein Blatt verfügbar",
|
||||
"Not available": "Nicht verfügbar",
|
||||
"Not configured": "Nicht konfiguriert",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Nicht implementiert. Diese Optionen beschreiben die geplante Oberfläche; es werden keine Zeichnungsdaten geändert.",
|
||||
"Note": "Hinweis",
|
||||
"Paper & layout": "Papier & Layout",
|
||||
"Paper colour": "Papierfarbe",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "Die Papierfarbe wird verfügbar sein, sobald die Blattdarstellung implementiert ist.",
|
||||
"Parent view": "Übergeordnete Ansicht",
|
||||
"Parts & Tables": "Teile & Tabellen",
|
||||
"Parts List": "Teileliste",
|
||||
"Print": "Drucken",
|
||||
"Print Drawing": "Zeichnung drucken",
|
||||
"Profile & units": "Profil & Einheiten",
|
||||
"Projected View": "Projizierte Ansicht",
|
||||
"RESOURCES": "RESSOURCEN",
|
||||
"REVISION": "REVISION",
|
||||
"References": "Referenzen",
|
||||
"Resources": "Ressourcen",
|
||||
"Return to Design to use this command.": "Wechseln Sie zum Design-Arbeitsbereich zurück, um diesen Befehl zu verwenden.",
|
||||
"Review": "Prüfen",
|
||||
"Revision Table": "Revisionstabelle",
|
||||
"Revision Tbl.": "Revisionstbl.",
|
||||
"SOURCE": "QUELLE",
|
||||
"SYMBOLS": "SYMBOLE",
|
||||
"Screen appearance": "Bildschirmdarstellung",
|
||||
"Section View": "Schnittansicht",
|
||||
"Sheet Settings": "Blatteinstellungen",
|
||||
"Sheet unavailable": "Blatt nicht verfügbar",
|
||||
"Size & orientation": "Größe & Ausrichtung",
|
||||
"Source BOM": "Stückliste der Quelle",
|
||||
"Source Flat": "Flachmuster der Quelle",
|
||||
"Source design": "Quelldesign",
|
||||
"Source properties": "Eigenschaften der Quelle",
|
||||
"Space": "Abstände anpassen",
|
||||
"Space Annotations": "Beschriftungsabstände anpassen",
|
||||
"Space Notes": "Hinweisabstände anpassen",
|
||||
"TABLES": "TABELLEN",
|
||||
"Text style": "Textstil",
|
||||
"Title": "Titel",
|
||||
"Title block": "Schriftfeld",
|
||||
"UI preview": "Oberflächenvorschau",
|
||||
"Units & precision": "Einheiten & Genauigkeit",
|
||||
"Update Drawing": "Zeichnung aktualisieren",
|
||||
"View": "Ansicht"
|
||||
},
|
||||
"ko": {
|
||||
"0 objects": "객체 0개",
|
||||
"ASSEMBLY": "조립품",
|
||||
"Add Drawing Revision": "도면 리비전 추가",
|
||||
"Add Revision": "리비전 추가",
|
||||
"Add sheet (not implemented)": "시트 추가 (미구현)",
|
||||
"Align Annotations": "주석 정렬",
|
||||
"Align Notes": "메모 정렬",
|
||||
"Align Views": "뷰 정렬",
|
||||
"Annotate": "주석 달기",
|
||||
"Apply (not implemented)": "적용 (미구현)",
|
||||
"Auto (Theme)": "자동 (테마)",
|
||||
"Balloon": "항목 번호 기호",
|
||||
"Base View": "기본 뷰",
|
||||
"Border & template": "테두리 및 템플릿",
|
||||
"Browse Drawing Resources": "도면 리소스 찾아보기",
|
||||
"CHANGES": "변경 사항",
|
||||
"Centre Mark": "중심 표시",
|
||||
"Check Drawing": "도면 검사",
|
||||
"Close drawing panel": "도면 패널 닫기",
|
||||
"DRAWING ANNOTATE": "도면 주석",
|
||||
"DRAWING LAYOUT": "도면 배치",
|
||||
"DRAWING PARTS": "도면 부품",
|
||||
"DRAWING REVIEW": "도면 검토",
|
||||
"Datum": "기준",
|
||||
"Detail View": "상세 뷰",
|
||||
"Dock at bottom": "아래쪽에 고정",
|
||||
"Dock at top": "위쪽에 고정",
|
||||
"Dock beneath Browser": "브라우저 아래에 고정",
|
||||
"Dock left": "왼쪽에 고정",
|
||||
"Dock right": "오른쪽에 고정",
|
||||
"Dock sheet tabs": "시트 탭 고정",
|
||||
"Drafting defaults": "도면 작성 기본 설정",
|
||||
"Drawing & title block": "도면 및 표제란",
|
||||
"Drawing Dimension": "도면 치수",
|
||||
"Drawing Line": "도면 선",
|
||||
"Drawing Note": "도면 메모",
|
||||
"Drawing defaults (not available)": "도면 기본 설정 (사용 불가)",
|
||||
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "도면 파일과 용지 렌더러는 아직 구현되지 않았습니다. 예정된 도구는 정의된 옵션이 있는 경우 해당 옵션을 표시합니다.",
|
||||
"Drawing workspace": "도면 작업 공간",
|
||||
"Edit Drawing Table": "도면 표 편집",
|
||||
"Edit Table": "표 편집",
|
||||
"Export Drawing": "도면 내보내기",
|
||||
"Finish the current operation before entering Drawing": "도면 작업 공간으로 전환하기 전에 현재 작업을 완료하세요",
|
||||
"Hole Callout": "구멍 주석",
|
||||
"Image": "이미지",
|
||||
"Inherited from parent view": "상위 뷰에서 상속됨",
|
||||
"Insert Image": "이미지 삽입",
|
||||
"Leader": "지시선",
|
||||
"Leader Note": "지시선 메모",
|
||||
"Manage sheets (not implemented)": "시트 관리 (미구현)",
|
||||
"Manage sheets and docking": "시트 및 고정 위치 관리",
|
||||
"NOTES": "메모",
|
||||
"No drawing document": "도면 문서 없음",
|
||||
"No drawing geometry available": "사용 가능한 도면 형상 없음",
|
||||
"No drawing objects": "도면 객체 없음",
|
||||
"No drawing sheet is available yet.": "아직 사용 가능한 도면 시트가 없습니다.",
|
||||
"No drawing view available": "사용 가능한 도면 뷰 없음",
|
||||
"No sheet available": "사용 가능한 시트 없음",
|
||||
"Not available": "사용 불가",
|
||||
"Not configured": "설정되지 않음",
|
||||
"Not implemented. These options describe the planned interface; no drawing data is changed.": "아직 구현되지 않았습니다. 이 옵션은 예정된 인터페이스를 설명하며 도면 데이터를 변경하지 않습니다.",
|
||||
"Note": "메모",
|
||||
"Paper & layout": "용지 및 배치",
|
||||
"Paper colour": "용지 색상",
|
||||
"Paper colour will be available when the sheet renderer is implemented.": "시트 렌더러가 구현되면 용지 색상을 설정할 수 있습니다.",
|
||||
"Parent view": "상위 뷰",
|
||||
"Parts & Tables": "부품 및 표",
|
||||
"Parts List": "부품 목록",
|
||||
"Print": "인쇄",
|
||||
"Print Drawing": "도면 인쇄",
|
||||
"Profile & units": "프로파일 및 단위",
|
||||
"Projected View": "투영 뷰",
|
||||
"RESOURCES": "리소스",
|
||||
"REVISION": "리비전",
|
||||
"References": "참조",
|
||||
"Resources": "리소스",
|
||||
"Return to Design to use this command.": "이 명령을 사용하려면 디자인 작업 공간으로 돌아가세요.",
|
||||
"Review": "검토",
|
||||
"Revision Table": "리비전 표",
|
||||
"Revision Tbl.": "리비전 표",
|
||||
"SOURCE": "원본",
|
||||
"SYMBOLS": "기호",
|
||||
"Screen appearance": "화면 모양",
|
||||
"Section View": "단면 뷰",
|
||||
"Sheet Settings": "시트 설정",
|
||||
"Sheet unavailable": "시트 사용 불가",
|
||||
"Size & orientation": "크기 및 방향",
|
||||
"Source BOM": "원본 자재 명세서",
|
||||
"Source Flat": "원본 전개도",
|
||||
"Source design": "원본 디자인",
|
||||
"Source properties": "원본 속성",
|
||||
"Space": "간격 조정",
|
||||
"Space Annotations": "주석 간격 조정",
|
||||
"Space Notes": "메모 간격 조정",
|
||||
"TABLES": "표",
|
||||
"Text style": "텍스트 스타일",
|
||||
"Title": "제목",
|
||||
"Title block": "표제란",
|
||||
"UI preview": "인터페이스 미리보기",
|
||||
"Units & precision": "단위 및 정밀도",
|
||||
"Update Drawing": "도면 업데이트",
|
||||
"View": "뷰"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.assembly_bom_item_set": "تغيير رقم البند",
|
||||
"cmd.assembly_bom_renumber": "إعادة ترقيم البنود",
|
||||
"cmd.drawing_balloon_add": "إضافة بالون",
|
||||
"cmd.drawing_balloon_edit": "تعديل البالون",
|
||||
"cmd.drawing_balloon_inspect": "فحص البالون",
|
||||
"cmd.drawing_close": "إغلاق اللوحة",
|
||||
"cmd.drawing_create": "إنشاء لوحة",
|
||||
"cmd.drawing_delete": "حذف اللوحة",
|
||||
"cmd.drawing_dimension_add": "إضافة بُعد إلى اللوحة",
|
||||
"cmd.drawing_dimension_edit": "تعديل بُعد اللوحة",
|
||||
"cmd.drawing_dimension_inspect": "فحص بُعد اللوحة",
|
||||
"cmd.drawing_export": "تصدير الورقة",
|
||||
"cmd.drawing_inspect": "فحص اللوحة",
|
||||
"cmd.drawing_list": "سرد اللوحات",
|
||||
"cmd.drawing_open": "فتح اللوحة",
|
||||
"cmd.drawing_properties": "خصائص اللوحة",
|
||||
"cmd.drawing_properties_remove": "إزالة خاصية اللوحة",
|
||||
"cmd.drawing_properties_set": "تغيير خصائص اللوحة",
|
||||
"cmd.drawing_revision_add": "إضافة مراجعة",
|
||||
"cmd.drawing_revision_delete": "حذف المراجعة",
|
||||
"cmd.drawing_revision_edit": "تعديل المراجعة",
|
||||
"cmd.drawing_scene": "رسم الورقة",
|
||||
"cmd.drawing_set": "إعدادات اللوحة",
|
||||
"cmd.drawing_sheet_add": "إضافة ورقة",
|
||||
"cmd.drawing_sheet_delete": "حذف الورقة",
|
||||
"cmd.drawing_sheet_reorder": "إعادة ترتيب الأوراق",
|
||||
"cmd.drawing_sheet_set": "إعدادات الورقة",
|
||||
"cmd.drawing_view_copy_to_sheet": "نسخ المسقط إلى ورقة",
|
||||
"cmd.drawing_view_edit": "تعديل مسقط اللوحة",
|
||||
"cmd.drawing_view_inspect": "فحص مسقط اللوحة",
|
||||
"cmd.drawing_view_move_to_sheet": "نقل المسقط إلى ورقة",
|
||||
"cmd.drawing_view_snapshot": "لقطة مسقط اللوحة",
|
||||
"cmd.occurrence_bom_type": "نوع قائمة المواد",
|
||||
"prefs.drawings": "اللوحات",
|
||||
"ui.first_angle": "الزاوية الأولى",
|
||||
"ui.projection_of_new_drawings": "إسقاط اللوحات الجديدة",
|
||||
"ui.third_angle": "الزاوية الثالثة"
|
||||
},
|
||||
"he": {
|
||||
"cmd.assembly_bom_item_set": "שינוי מספר פריט",
|
||||
"cmd.assembly_bom_renumber": "מספור מחדש של פריטים",
|
||||
"cmd.drawing_balloon_add": "הוספת בלון",
|
||||
"cmd.drawing_balloon_edit": "עריכת בלון",
|
||||
"cmd.drawing_balloon_inspect": "בדיקת בלון",
|
||||
"cmd.drawing_close": "סגירת שרטוט",
|
||||
"cmd.drawing_create": "יצירת שרטוט",
|
||||
"cmd.drawing_delete": "מחיקת שרטוט",
|
||||
"cmd.drawing_dimension_add": "הוספת מידה לשרטוט",
|
||||
"cmd.drawing_dimension_edit": "עריכת מידת שרטוט",
|
||||
"cmd.drawing_dimension_inspect": "בדיקת מידת שרטוט",
|
||||
"cmd.drawing_export": "ייצוא גיליון",
|
||||
"cmd.drawing_inspect": "בדיקת שרטוט",
|
||||
"cmd.drawing_list": "רשימת שרטוטים",
|
||||
"cmd.drawing_open": "פתיחת שרטוט",
|
||||
"cmd.drawing_properties": "מאפייני שרטוט",
|
||||
"cmd.drawing_properties_remove": "הסרת מאפיין שרטוט",
|
||||
"cmd.drawing_properties_set": "שינוי מאפייני שרטוט",
|
||||
"cmd.drawing_revision_add": "הוספת גרסה",
|
||||
"cmd.drawing_revision_delete": "מחיקת גרסה",
|
||||
"cmd.drawing_revision_edit": "עריכת גרסה",
|
||||
"cmd.drawing_scene": "ציור גיליון",
|
||||
"cmd.drawing_set": "הגדרות שרטוט",
|
||||
"cmd.drawing_sheet_add": "הוספת גיליון",
|
||||
"cmd.drawing_sheet_delete": "מחיקת גיליון",
|
||||
"cmd.drawing_sheet_reorder": "סידור גיליונות מחדש",
|
||||
"cmd.drawing_sheet_set": "הגדרות גיליון",
|
||||
"cmd.drawing_view_copy_to_sheet": "העתקת מבט לגיליון",
|
||||
"cmd.drawing_view_edit": "עריכת מבט שרטוט",
|
||||
"cmd.drawing_view_inspect": "בדיקת מבט שרטוט",
|
||||
"cmd.drawing_view_move_to_sheet": "העברת מבט לגיליון",
|
||||
"cmd.drawing_view_snapshot": "תמונת מבט שרטוט",
|
||||
"cmd.occurrence_bom_type": "סוג ברשימת החלקים",
|
||||
"prefs.drawings": "שרטוטים",
|
||||
"ui.first_angle": "זווית ראשונה",
|
||||
"ui.projection_of_new_drawings": "היטל של שרטוטים חדשים",
|
||||
"ui.third_angle": "זווית שלישית"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.assembly_bom_item_set": "Đổi số hạng mục",
|
||||
"cmd.assembly_bom_renumber": "Đánh số lại hạng mục",
|
||||
"cmd.drawing_balloon_add": "Thêm bóng chú thích",
|
||||
"cmd.drawing_balloon_edit": "Sửa bóng chú thích",
|
||||
"cmd.drawing_balloon_inspect": "Kiểm tra bóng chú thích",
|
||||
"cmd.drawing_close": "Đóng bản vẽ",
|
||||
"cmd.drawing_create": "Tạo bản vẽ",
|
||||
"cmd.drawing_delete": "Xóa bản vẽ",
|
||||
"cmd.drawing_dimension_add": "Thêm kích thước bản vẽ",
|
||||
"cmd.drawing_dimension_edit": "Sửa kích thước bản vẽ",
|
||||
"cmd.drawing_dimension_inspect": "Kiểm tra kích thước bản vẽ",
|
||||
"cmd.drawing_export": "Xuất tờ",
|
||||
"cmd.drawing_inspect": "Kiểm tra bản vẽ",
|
||||
"cmd.drawing_list": "Liệt kê bản vẽ",
|
||||
"cmd.drawing_open": "Mở bản vẽ",
|
||||
"cmd.drawing_properties": "Thuộc tính bản vẽ",
|
||||
"cmd.drawing_properties_remove": "Xóa thuộc tính bản vẽ",
|
||||
"cmd.drawing_properties_set": "Thay đổi thuộc tính bản vẽ",
|
||||
"cmd.drawing_revision_add": "Thêm lần sửa đổi",
|
||||
"cmd.drawing_revision_delete": "Xóa lần sửa đổi",
|
||||
"cmd.drawing_revision_edit": "Sửa lần sửa đổi",
|
||||
"cmd.drawing_scene": "Vẽ tờ",
|
||||
"cmd.drawing_set": "Cài đặt bản vẽ",
|
||||
"cmd.drawing_sheet_add": "Thêm tờ",
|
||||
"cmd.drawing_sheet_delete": "Xóa tờ",
|
||||
"cmd.drawing_sheet_reorder": "Sắp xếp lại các tờ",
|
||||
"cmd.drawing_sheet_set": "Cài đặt tờ",
|
||||
"cmd.drawing_view_copy_to_sheet": "Sao chép hình chiếu sang tờ",
|
||||
"cmd.drawing_view_edit": "Sửa hình chiếu bản vẽ",
|
||||
"cmd.drawing_view_inspect": "Kiểm tra hình chiếu bản vẽ",
|
||||
"cmd.drawing_view_move_to_sheet": "Chuyển hình chiếu sang tờ",
|
||||
"cmd.drawing_view_snapshot": "Ảnh chụp hình chiếu bản vẽ",
|
||||
"cmd.occurrence_bom_type": "Loại trong bảng kê vật tư",
|
||||
"prefs.drawings": "Bản vẽ",
|
||||
"ui.first_angle": "Góc chiếu thứ nhất",
|
||||
"ui.projection_of_new_drawings": "Phép chiếu của bản vẽ mới",
|
||||
"ui.third_angle": "Góc chiếu thứ ba"
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,8 @@
|
||||
"cmd.appearance_pick_up": "拾取外观",
|
||||
"cmd.appearance_replace": "替换外观",
|
||||
"cmd.assembly_bom": "物料清单",
|
||||
"cmd.assembly_bom_item_set": "更改项目编号",
|
||||
"cmd.assembly_bom_renumber": "重新编号项目",
|
||||
"cmd.body_rename": "重命名实体",
|
||||
"cmd.browser_collapse_all": "全部折叠",
|
||||
"cmd.browser_expand_all": "全部展开",
|
||||
@@ -909,6 +911,36 @@
|
||||
"cmd.document_metadata_set": "更改设计属性",
|
||||
"cmd.document_revision": "设计版本号",
|
||||
"cmd.document_units": "更改设计单位",
|
||||
"cmd.drawing_balloon_add": "添加零件序号",
|
||||
"cmd.drawing_balloon_edit": "编辑零件序号",
|
||||
"cmd.drawing_balloon_inspect": "检查零件序号",
|
||||
"cmd.drawing_close": "关闭绘图",
|
||||
"cmd.drawing_create": "创建绘图",
|
||||
"cmd.drawing_delete": "删除绘图",
|
||||
"cmd.drawing_dimension_add": "添加绘图尺寸",
|
||||
"cmd.drawing_dimension_edit": "编辑绘图尺寸",
|
||||
"cmd.drawing_dimension_inspect": "检查绘图尺寸",
|
||||
"cmd.drawing_export": "导出图纸",
|
||||
"cmd.drawing_inspect": "检查绘图",
|
||||
"cmd.drawing_list": "列出绘图",
|
||||
"cmd.drawing_open": "打开绘图",
|
||||
"cmd.drawing_properties": "绘图属性",
|
||||
"cmd.drawing_properties_remove": "删除绘图属性",
|
||||
"cmd.drawing_properties_set": "更改绘图属性",
|
||||
"cmd.drawing_revision_add": "添加版本",
|
||||
"cmd.drawing_revision_delete": "删除版本",
|
||||
"cmd.drawing_revision_edit": "编辑版本",
|
||||
"cmd.drawing_scene": "绘制图纸",
|
||||
"cmd.drawing_set": "绘图设置",
|
||||
"cmd.drawing_sheet_add": "添加图纸",
|
||||
"cmd.drawing_sheet_delete": "删除图纸",
|
||||
"cmd.drawing_sheet_reorder": "重新排列图纸",
|
||||
"cmd.drawing_sheet_set": "图纸设置",
|
||||
"cmd.drawing_view_copy_to_sheet": "将视图复制到图纸",
|
||||
"cmd.drawing_view_edit": "编辑绘图视图",
|
||||
"cmd.drawing_view_inspect": "检查绘图视图",
|
||||
"cmd.drawing_view_move_to_sheet": "将视图移至图纸",
|
||||
"cmd.drawing_view_snapshot": "绘图视图快照",
|
||||
"cmd.edit_checkpoint": "检查点",
|
||||
"cmd.edit_repeat": "重复上一个命令",
|
||||
"cmd.edit_restore_checkpoint": "恢复检查点",
|
||||
@@ -993,6 +1025,7 @@
|
||||
"cmd.motion_trace_edit": "编辑运动轨迹",
|
||||
"cmd.motion_trace_keep": "保留运动轨迹",
|
||||
"cmd.occurrence_align": "对齐实例",
|
||||
"cmd.occurrence_bom_type": "物料清单类型",
|
||||
"cmd.occurrence_copy": "粘贴(实例)",
|
||||
"cmd.occurrence_drag": "拖动实例",
|
||||
"cmd.occurrence_ground": "固定",
|
||||
@@ -1870,6 +1903,7 @@
|
||||
"prefs.dialogs_left_column": "左侧栏",
|
||||
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
|
||||
"prefs.display": "显示",
|
||||
"prefs.drawings": "绘图",
|
||||
"prefs.edges": "边缘与着色",
|
||||
"prefs.extend": "扩展",
|
||||
"prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。",
|
||||
@@ -4140,6 +4174,7 @@
|
||||
"ui.fine": "精细",
|
||||
"ui.finest": "最精细",
|
||||
"ui.finish_sketch": "完成草图",
|
||||
"ui.first_angle": "第一角投影",
|
||||
"ui.fit": "适合",
|
||||
"ui.fix_unfix": "固定/取消固定",
|
||||
"ui.flat": "平面",
|
||||
@@ -4424,6 +4459,7 @@
|
||||
"ui.profiles": "轮廓",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "将面的边投影到新草图",
|
||||
"ui.projection_of_new_drawings": "新绘图的投影法",
|
||||
"ui.properties": "属性",
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
@@ -4599,6 +4635,7 @@
|
||||
"ui.text": "文本",
|
||||
"ui.theme": "主题",
|
||||
"ui.thickness": "厚度",
|
||||
"ui.third_angle": "第三角投影",
|
||||
"ui.thread": "螺纹",
|
||||
"ui.tie_ends": "约束",
|
||||
"ui.tie_ends_tip": "约束全部未约束端点",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
| assets/icons/ui/design/svg/model/drawing_base_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_projected_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_section_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_detail_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_align_views.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_image.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_dimension.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_hole_callout.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_leader.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_datum.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_centre_mark.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_align_annotations.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_space_annotations.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_parts_list.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_balloon.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_edit_table.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_revision_table.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_update.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_check.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_revision.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_print.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drawing_resources.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
@@ -28,6 +28,18 @@ pub fn mat_mul(a: &Mat, b: &Mat) -> Mat {
|
||||
r
|
||||
}
|
||||
|
||||
/// How much `m` scales a volume: the size of the determinant of its linear part (1 for a rigid
|
||||
/// placement).
|
||||
pub fn mat_volume_scale(m: &Mat) -> f64 {
|
||||
let c = |i: usize| m.get(i).map_or(Vec3::ZERO, |col| Vec3::new(col[0], col[1], col[2]));
|
||||
c(0).dot(c(1).cross(c(2))).abs()
|
||||
}
|
||||
|
||||
/// Why an assembly too large to walk whole is refused.
|
||||
pub(crate) fn too_many_placements() -> crate::DocError {
|
||||
crate::DocError::Invalid(format!("the assembly is deeper than {MAX_PLACEMENT_DEPTH} levels or has more than {MAX_PLACEMENTS} placements"))
|
||||
}
|
||||
|
||||
pub fn apply_point(m: &Mat, p: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
|
||||
@@ -44,6 +56,11 @@ pub fn apply_vector(m: &Mat, v: Vec3) -> Vec3 {
|
||||
)
|
||||
}
|
||||
|
||||
/// `p` carried by `m`: `None` when `m` flattens it.
|
||||
pub fn map_plane(m: &Mat, p: &Plane) -> Option<Plane> {
|
||||
Plane::new(apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y))
|
||||
}
|
||||
|
||||
pub fn is_identity(m: &Mat) -> bool {
|
||||
m.iter().flatten().zip(IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-12)
|
||||
}
|
||||
@@ -118,6 +135,10 @@ pub struct Occurrence {
|
||||
/// Grounded occurrences don't move.
|
||||
#[serde(default)]
|
||||
pub grounded: bool,
|
||||
/// How this occurrence counts in the bill of materials, in place of its component's type
|
||||
/// (`None`: as its component).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bom_type: Option<BomType>,
|
||||
}
|
||||
|
||||
fn identity() -> Mat {
|
||||
@@ -126,7 +147,8 @@ fn identity() -> Mat {
|
||||
|
||||
/// How a component counts in the bill of materials of an assembly it is placed in. It is a part
|
||||
/// property (the `bom_type` key of the component's [`Metadata`](crate::Metadata)); a component
|
||||
/// without one is [`BomType::Normal`].
|
||||
/// without one is [`BomType::Normal`]. One occurrence can count as another type
|
||||
/// ([`Occurrence::bom_type`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BomType {
|
||||
@@ -170,21 +192,16 @@ impl BomType {
|
||||
pub const MAX_PLACEMENT_DEPTH: usize = 64;
|
||||
pub const MAX_PLACEMENTS: usize = 100_000;
|
||||
|
||||
fn map_plane(m: &Mat, p: &Plane) -> Plane {
|
||||
let (o, x, y) = (apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y));
|
||||
Plane::new(o, x, y).unwrap_or(*p)
|
||||
}
|
||||
|
||||
fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Custom { plane } => *plane = map_plane(m, plane),
|
||||
PlaneRef::Custom { plane } => *plane = map_plane(m, plane).unwrap_or(*plane),
|
||||
PlaneRef::Placed { origin, x_dir, y_dir } => {
|
||||
map_point_expr(m, origin);
|
||||
*x_dir = apply_vector(m, *x_dir);
|
||||
*y_dir = apply_vector(m, *y_dir);
|
||||
}
|
||||
PlaneRef::Face { plane, at, .. } => {
|
||||
*plane = map_plane(m, plane);
|
||||
*plane = map_plane(m, plane).unwrap_or(*plane);
|
||||
*at = apply_point(m, *at);
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
|
||||
@@ -302,6 +319,22 @@ impl Document {
|
||||
self.occurrences.iter().find(|o| o.component == component)
|
||||
}
|
||||
|
||||
/// The bill of materials type each occurrence counts as, in the order of `occurrences`: its
|
||||
/// own ([`Occurrence::bom_type`]), else its component's. Components are looked up once.
|
||||
pub fn occurrence_bom_types(&self) -> Vec<BomType> {
|
||||
let types: std::collections::HashMap<u64, BomType> =
|
||||
self.components.iter().map(|c| (c.id, c.metadata.bom_type())).filter(|(_, t)| *t != BomType::Normal).collect();
|
||||
self.occurrences.iter().map(|o| o.bom_type.or_else(|| types.get(&o.component).copied()).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// The occurrences that count as reference ([`Document::occurrence_bom_types`]). A body placed
|
||||
/// through any of them is a reference part: the bill of materials counts nothing inside a
|
||||
/// reference, and a drawing draws all of it as reference.
|
||||
pub fn reference_occurrences(&self) -> std::collections::HashSet<u64> {
|
||||
let types = self.occurrences.iter().zip(self.occurrence_bom_types());
|
||||
types.filter(|(_, bom_type)| *bom_type == BomType::Reference).map(|(o, _)| o.id).collect()
|
||||
}
|
||||
|
||||
/// World transform of a component's frame (through its first occurrence and its parents').
|
||||
pub fn component_transform(&self, component: u64) -> Mat {
|
||||
transform_through(component, |c| self.occurrence_of(c))
|
||||
@@ -352,6 +385,45 @@ impl Document {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether [`Document::placements`] reaches every placement. Its walk stops at
|
||||
/// [`MAX_PLACEMENTS`], and a branch past [`MAX_PLACEMENT_DEPTH`] levels, so a placement it
|
||||
/// holds may have occurrences it never walked; the walk is whole when none does.
|
||||
pub fn placements_complete(&self) -> bool {
|
||||
let mut children = std::collections::HashMap::<u64, usize>::new();
|
||||
for o in &self.occurrences {
|
||||
*children.entry(o.parent).or_default() += 1;
|
||||
}
|
||||
let walk = self.placements();
|
||||
let reached = walk.iter().fold(0usize, |n, (c, _, _)| n.saturating_add(children.get(c).copied().unwrap_or(0)));
|
||||
walk.len() == reached.saturating_add(1)
|
||||
}
|
||||
|
||||
/// The placement `path` of `component` at `world`, and every placement below it: (component,
|
||||
/// occurrence path ids, world transform), the start first. Unlike [`Self::placements`], which
|
||||
/// stops a branch where the display does, a larger assembly is refused, never walked in part:
|
||||
/// for totals that must cover all of it (physical properties).
|
||||
pub fn placements_below(&self, component: u64, path: &[u64], world: Mat) -> crate::Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut children: std::collections::HashMap<u64, Vec<&Occurrence>> = std::collections::HashMap::new();
|
||||
for o in &self.occurrences {
|
||||
children.entry(o.parent).or_default().push(o);
|
||||
}
|
||||
let mut out = vec![(component, path.to_vec(), world)];
|
||||
let mut stack = vec![(component, path.to_vec(), world)];
|
||||
while let Some((parent, path, world)) = stack.pop() {
|
||||
for o in children.get(&parent).into_iter().flatten() {
|
||||
if path.len() >= MAX_PLACEMENT_DEPTH || out.len() > MAX_PLACEMENTS {
|
||||
return Err(too_many_placements());
|
||||
}
|
||||
let mut child = path.clone();
|
||||
child.push(o.id);
|
||||
let w = mat_mul(&world, &o.transform);
|
||||
out.push((o.component, child.clone(), w));
|
||||
stack.push((o.component, child, w));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
|
||||
/// further instance), its component, the occurrence path, and the transform from the frame
|
||||
/// its geometry is in to the world.
|
||||
@@ -418,6 +490,21 @@ impl Document {
|
||||
false
|
||||
}
|
||||
|
||||
/// The virtual components that hold something, by id: a feature (a sketch, a body's, a moved
|
||||
/// body's) or a placed component. A virtual component holds nothing ([`crate::metadata::VIRTUAL`]):
|
||||
/// the engine refuses any change that puts something in one.
|
||||
pub fn virtual_holding(&self) -> Vec<u64> {
|
||||
let virtuals: std::collections::HashSet<u64> = self.components.iter().filter(|c| c.metadata.is_virtual()).map(|c| c.id).collect();
|
||||
if virtuals.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut held: Vec<u64> =
|
||||
self.features.iter().map(|f| f.component).chain(self.occurrences.iter().map(|o| o.parent)).filter(|id| virtuals.contains(id)).collect();
|
||||
held.sort_unstable();
|
||||
held.dedup();
|
||||
held
|
||||
}
|
||||
|
||||
/// Would putting a placement of `component` into `parent` put it inside itself: is `parent`
|
||||
/// the component, or inside it, created in it or placed in it at any depth? Placements can sit
|
||||
/// apart from where their component was created ([`Self::component_within`] follows only
|
||||
@@ -457,7 +544,7 @@ impl Document {
|
||||
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
|
||||
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
|
||||
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
|
||||
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false, bom_type: None });
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,937 @@
|
||||
//! The bill of materials of an assembly (the design, or one component's definition): the rows a
|
||||
//! bill of materials view lists, typed. `assembly.bom`'s JSON and CSV and the Bill of Materials
|
||||
//! window present these rows; parts lists, item numbers and balloons are to read them too.
|
||||
//!
|
||||
//! [`list`] walks the placements below the assembly once. One rule (`classify`) decides for each
|
||||
//! placement whether it is counted in a row, how what it holds is walked and what its row stands
|
||||
//! for when weighed, from the bill of materials type it counts as (its occurrence's, else its
|
||||
//! component's), the view and the component's shape. One budget of [`MAX_PLACEMENTS`] placements
|
||||
//! and [`MAX_PLACEMENT_DEPTH`] levels covers the whole list, folded parts' contents included: a
|
||||
//! larger assembly is refused, never listed in part.
|
||||
//!
|
||||
//! A row is named by its `RowKey`: the rows it is listed under and its lowest member (a
|
||||
//! component and the type it counts as there), however its part number or contents read. Its
|
||||
//! mass is what one of its parts weighs as the row stands for it (`Holds`), so the rows of any
|
||||
//! list add up to the assembly once. The keys, what rows stand for, their members and `row_of`
|
||||
//! stay inside this crate until balloons read them; item numbers are saved by key ([`numbers`]). Placements are rigid, so
|
||||
//! each definition is weighed once however often it is placed, and kept in the [`Tree`] for every list made from it. The
|
||||
//! assembly's own mass is weighed the same way, never added up from rows: a row whose definitions
|
||||
//! weigh differently has none.
|
||||
//!
|
||||
//! Materials are bodies' (`material.assign`): a row names the materials of the bodies it stands for,
|
||||
//! and a row that stands for an assembly names one only when everything in it shares it. A static
|
||||
//! material typed in for a component ([`metadata::MATERIAL`](crate::metadata::MATERIAL)) is what
|
||||
//! its rows say instead, in every view and at every level ([`Row::statics`]), and what it stands
|
||||
//! for in the one material an assembly around it shares: the bill of materials says one thing
|
||||
//! about the part everywhere. It only names: mass still comes from the bodies.
|
||||
//!
|
||||
//! A row's [`Quantity`] adds up what each of its placements means ([`quantity`]): each definition's
|
||||
//! quantity is evaluated once for every list made from the [`Tree`].
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BomType, Component, DocError, Document, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, ModelBody, ModelState, Result};
|
||||
|
||||
mod numbers;
|
||||
mod quantity;
|
||||
|
||||
pub use numbers::{ItemNumber, ItemNumbers, MAX_ITEM, Numbering, Order, number, record};
|
||||
pub use quantity::{Amount, Quantity, Uncounted, amount};
|
||||
|
||||
/// A bill of materials view, saved by the word commands use for it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum View {
|
||||
/// Parts only: every part at any depth, counted through its subassemblies.
|
||||
#[default]
|
||||
#[serde(rename = "flat")]
|
||||
PartsOnly,
|
||||
/// Structured, first level: the assembly's direct contents, a subassembly as one row.
|
||||
#[serde(rename = "top")]
|
||||
FirstLevel,
|
||||
/// Structured, all levels: the assembly's direct contents, each subassembly a row with its own
|
||||
/// contents listed under it, to any depth (1, 1.1, 1.1.1).
|
||||
#[serde(rename = "all")]
|
||||
AllLevels,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// Every view, in the order commands and documentation list them.
|
||||
pub const ALL: [View; 3] = [View::PartsOnly, View::FirstLevel, View::AllLevels];
|
||||
|
||||
/// The word commands use (`assembly.bom`'s `levels`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
View::PartsOnly => "flat",
|
||||
View::FirstLevel => "top",
|
||||
View::AllLevels => "all",
|
||||
}
|
||||
}
|
||||
|
||||
/// The view a word names, if it names one.
|
||||
pub fn parse(word: &str) -> Option<View> {
|
||||
View::ALL.into_iter().find(|v| v.as_str() == word)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one row gathers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Group {
|
||||
/// The definitions that share a part number; a definition without one is a row of its own.
|
||||
#[default]
|
||||
PartNumber,
|
||||
/// One definition.
|
||||
Definition,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
/// Every grouping, in the order commands and documentation list them.
|
||||
pub const ALL: [Group; 2] = [Group::PartNumber, Group::Definition];
|
||||
|
||||
/// The word commands use (`assembly.bom`'s `group_by`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Group::PartNumber => "part_number",
|
||||
Group::Definition => "definition",
|
||||
}
|
||||
}
|
||||
|
||||
/// The grouping a word names, if it names one.
|
||||
pub fn parse(word: &str) -> Option<Group> {
|
||||
Group::ALL.into_iter().find(|g| g.as_str() == word)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a bill of materials lists. The default is the design's parts only, by part number.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
/// The assembly listed: a component, or 0 for the design.
|
||||
pub source: u64,
|
||||
pub view: View,
|
||||
pub group: Group,
|
||||
/// A normal part with a part number is one row, and what it holds is listed under it as its
|
||||
/// children (as parts only lists them) instead of as rows of their own.
|
||||
pub fold: bool,
|
||||
/// Weigh each row and the assembly: every part is measured, so it is asked for.
|
||||
pub mass: bool,
|
||||
/// In all levels, what a purchased part holds is listed under it too (it is still one row,
|
||||
/// weighed whole).
|
||||
pub purchased_contents: bool,
|
||||
}
|
||||
|
||||
/// A component as one placement counts: its id and the bill of materials type it counts as there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct Member {
|
||||
pub component: u64,
|
||||
pub bom_type: BomType,
|
||||
}
|
||||
|
||||
/// A row's identity: the key members of the rows it is listed under (outermost first; none at the
|
||||
/// top), and its own lowest member. It doesn't change with the row's part number or contents, so
|
||||
/// item numbers, overrides and balloons can follow the row through those edits.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub(crate) struct RowKey {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub(crate) parents: Vec<Member>,
|
||||
pub(crate) member: Member,
|
||||
}
|
||||
|
||||
/// What one of a row's parts stands for when it is weighed, so that the rows of any list add up
|
||||
/// to the assembly once.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub(crate) enum Holds {
|
||||
/// Its own bodies and everything placed inside it.
|
||||
All,
|
||||
/// Its own bodies alone: what it holds is listed beside it.
|
||||
Own,
|
||||
/// Everything but its purchased parts, which are listed beside it (an inseparable part in
|
||||
/// parts only).
|
||||
AllButPurchased,
|
||||
}
|
||||
|
||||
/// What one of a row's parts weighs, or the assembly.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum Mass {
|
||||
/// Not asked for ([`Request::mass`]).
|
||||
#[default]
|
||||
NotAsked,
|
||||
Grams(f64),
|
||||
Unknown(Unknown),
|
||||
}
|
||||
|
||||
/// Why a mass isn't known.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unknown {
|
||||
/// Nothing in it has a body.
|
||||
NothingToWeigh,
|
||||
/// A body in it can't be measured, or measures inside out (a negative volume).
|
||||
Unmeasurable,
|
||||
/// The definitions the row gathers (one part number) weigh differently.
|
||||
Differs,
|
||||
}
|
||||
|
||||
/// A value of a row typed in by hand for its component, in place of the one worked out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Static {
|
||||
/// [`Row::materials`], from [`metadata::MATERIAL`](crate::metadata::MATERIAL).
|
||||
Material,
|
||||
}
|
||||
|
||||
impl Static {
|
||||
/// The part property it is kept under, and its name in `assembly.bom`'s rows.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Static::Material => crate::metadata::MATERIAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a bill of materials view.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Row {
|
||||
pub(crate) key: RowKey,
|
||||
/// Its item number among the rows it is listed with: the one saved for it, else its position
|
||||
/// from 1 (the view not numbered yet) or the next number after the saved ones ([`numbers`]).
|
||||
pub item: u32,
|
||||
/// Its item number isn't saved yet.
|
||||
pub provisional: bool,
|
||||
/// Its item number is locked: a renumber keeps it.
|
||||
pub locked: bool,
|
||||
/// Another row listed with it has the same item number.
|
||||
pub duplicate: bool,
|
||||
pub bom_type: BomType,
|
||||
/// What one of its parts stands for when weighed.
|
||||
pub(crate) stands_for: Holds,
|
||||
/// The definitions it gathers, by id (ascending), and their names, each once.
|
||||
pub components: Vec<u64>,
|
||||
pub names: Vec<String>,
|
||||
/// Its part properties: its first definition's part number (the one it gathers by), and the
|
||||
/// first description and revision its definitions give.
|
||||
pub part_number: String,
|
||||
pub description: String,
|
||||
pub revision: String,
|
||||
/// The definitions' free properties; where several give one name, the first's.
|
||||
pub properties: BTreeMap<String, String>,
|
||||
/// The physical materials of what one of its parts stands for, each once, in body order: a
|
||||
/// part's own bodies'. A row that stands for an assembly (something placed inside it is weighed
|
||||
/// with it) shows the one material all of that shares, else none. A static material replaces
|
||||
/// them.
|
||||
pub materials: Vec<String>,
|
||||
/// Its values typed in by hand ([`Static`]). Where its definitions give different ones, the
|
||||
/// first's that gives one wins, as with its description.
|
||||
pub statics: Vec<Static>,
|
||||
/// How many are listed: in all, and in one of the row it is listed under (the assembly at the
|
||||
/// top).
|
||||
pub count: u64,
|
||||
pub each: u64,
|
||||
/// What its placements add up to, when its definitions have quantities.
|
||||
pub quantity: Quantity,
|
||||
/// The placements it counts, by occurrence path from the assembly listed, in path order;
|
||||
/// empty unless the list was made with them (`list_with`).
|
||||
pub(crate) members: Vec<Vec<u64>>,
|
||||
pub mass: Mass,
|
||||
/// What one of it holds, listed under it: a folded part's contents as parts only lists them,
|
||||
/// or in all levels a subassembly's own rows.
|
||||
pub children: Vec<Row>,
|
||||
}
|
||||
|
||||
/// The assembly as a whole.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Total {
|
||||
/// Every placement listed, children included.
|
||||
pub occurrences: u64,
|
||||
/// Its own bodies and all it holds but references, weighed from the design (with the rows'
|
||||
/// measurements), not added up from rows.
|
||||
pub mass: Mass,
|
||||
}
|
||||
|
||||
/// The rows of one bill of materials view.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Bom {
|
||||
/// The assembly listed: a component, or 0 for the design.
|
||||
pub source: u64,
|
||||
/// By part number, rows without one after them by name, one part's rows by type (`list_order`);
|
||||
/// each row's children likewise. In a numbered view, by item number.
|
||||
pub rows: Vec<Row>,
|
||||
pub total: Total,
|
||||
/// Its item numbers are saved: it has been numbered ([`ItemNumbers`]).
|
||||
pub numbered: bool,
|
||||
/// Each member path, and where its row is (its position among the rows, then among each one's
|
||||
/// children): `row_of` reads it. Empty unless the list was made with its members.
|
||||
index: HashMap<Vec<u64>, Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Why a placement is counted in no row of a list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "item numbers and balloons follow rows by them (#3874)"))]
|
||||
pub(crate) enum Unlisted {
|
||||
/// No placement below the assembly listed has that path.
|
||||
NotFound,
|
||||
/// It counts as a reference, or is inside one: no bill of materials counts it.
|
||||
Reference,
|
||||
/// It has no row of its own and isn't inside one: a phantom without bodies, or in parts only
|
||||
/// a subassembly listed through its parts (the assembly listed, an empty path, is none either).
|
||||
NoRow,
|
||||
}
|
||||
|
||||
impl Bom {
|
||||
/// Every row, each followed by the rows listed under it (1, 1.1, 1.2, 2…), with its item
|
||||
/// numbers from the top (`[1, 2]` reads 1.2).
|
||||
pub fn rows_deep(&self) -> Vec<(Vec<u32>, &Row)> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<(Vec<u32>, &Row)> = self.rows.iter().rev().map(|r| (vec![r.item], r)).collect();
|
||||
while let Some((item, row)) = stack.pop() {
|
||||
for child in row.children.iter().rev() {
|
||||
let mut under = item.clone();
|
||||
under.push(child.item);
|
||||
stack.push((under, child));
|
||||
}
|
||||
out.push((item, row));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The row a placement is counted in, by its occurrence path from the assembly listed: the row
|
||||
/// that lists it, else the row of the part it is inside (a nut inside a purchased bolt is
|
||||
/// counted in the bolt's). `tree` is the one the list was made from, with its members
|
||||
/// (`list_with`).
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "item numbers and balloons follow rows by them (#3874)"))]
|
||||
pub(crate) fn row_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<&Row, Unlisted> {
|
||||
if path.len() > MAX_PLACEMENT_DEPTH {
|
||||
return Err(Unlisted::NotFound);
|
||||
}
|
||||
let mut component = self.source;
|
||||
for id in path {
|
||||
let placed = tree.contents(component).iter().find(|p| p.occurrence == *id).ok_or(Unlisted::NotFound)?;
|
||||
if placed.member.bom_type == BomType::Reference {
|
||||
return Err(Unlisted::Reference);
|
||||
}
|
||||
component = placed.member.component;
|
||||
}
|
||||
// Each placement is in one row or none, so the longest member path it starts with is its row's.
|
||||
let at = (1..=path.len()).rev().find_map(|n| path.get(..n).and_then(|start| self.index.get(start)));
|
||||
at.and_then(|at| self.row_at(at)).ok_or(Unlisted::NoRow)
|
||||
}
|
||||
|
||||
/// The row at `at`: its position among the rows, then among each one's children.
|
||||
fn row_at(&self, at: &[usize]) -> Option<&Row> {
|
||||
let (first, rest) = at.split_first()?;
|
||||
let mut row = self.rows.get(*first)?;
|
||||
for &i in rest {
|
||||
row = row.children.get(i)?;
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
|
||||
/// Where each member's row is, for `row_of`.
|
||||
fn index_members(&mut self) {
|
||||
let mut index = HashMap::new();
|
||||
let mut stack: Vec<(Vec<usize>, &Row)> = self.rows.iter().enumerate().map(|(i, r)| (vec![i], r)).collect();
|
||||
while let Some((at, row)) = stack.pop() {
|
||||
for member in &row.members {
|
||||
index.insert(member.clone(), at.clone());
|
||||
}
|
||||
for (i, child) in row.children.iter().enumerate() {
|
||||
let mut under = at.clone();
|
||||
under.push(i);
|
||||
stack.push((under, child));
|
||||
}
|
||||
}
|
||||
self.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Item numbers as one reads them: `[1, 2]` is "1.2".
|
||||
pub fn item_text(item: &[u32]) -> String {
|
||||
item.iter().map(u32::to_string).collect::<Vec<_>>().join(".")
|
||||
}
|
||||
|
||||
/// The design's structure as bills of materials read it, looked up once, and what each
|
||||
/// definition weighs, measured once for every list made from it. It reads one state of the
|
||||
/// design: make another after a change.
|
||||
pub struct Tree<'a> {
|
||||
doc: &'a Document,
|
||||
/// What each component places (by its id; 0 is the root), in occurrence order.
|
||||
children: HashMap<u64, Vec<Placed>>,
|
||||
components: HashMap<u64, &'a Component>,
|
||||
/// Each component's own bodies, in body order; a component that owns none isn't here.
|
||||
bodies: HashMap<u64, Vec<&'a ModelBody>>,
|
||||
/// What one of each definition weighs, by what it stands for.
|
||||
weighed: BTreeMap<(u64, Holds), Weighed>,
|
||||
/// The materials of what one of each definition stands for, each once, in body order.
|
||||
made_of: BTreeMap<(u64, Holds), Vec<String>>,
|
||||
/// What one placement of each definition with a quantity counts: `None` when it doesn't
|
||||
/// evaluate.
|
||||
amounts: HashMap<u64, Option<Amount>>,
|
||||
}
|
||||
|
||||
/// One occurrence: its id, and the component it places with the type it counts as there.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Placed {
|
||||
occurrence: u64,
|
||||
member: Member,
|
||||
}
|
||||
|
||||
/// What one of a component weighs: grams, and how many bodies that is (none: nothing to weigh).
|
||||
/// `None` when a body in it can't be measured, or measures inside out.
|
||||
type Weighed = Option<(f64, usize)>;
|
||||
|
||||
impl<'a> Tree<'a> {
|
||||
/// The structure of `doc` with the bodies of `state`, its evaluated model.
|
||||
pub fn new(doc: &'a Document, state: &'a ModelState) -> Tree<'a> {
|
||||
let mut children: HashMap<u64, Vec<Placed>> = HashMap::new();
|
||||
for (o, bom_type) in doc.occurrences.iter().zip(doc.occurrence_bom_types()) {
|
||||
children.entry(o.parent).or_default().push(Placed { occurrence: o.id, member: Member { component: o.component, bom_type } });
|
||||
}
|
||||
let mut components: HashMap<u64, &Component> = HashMap::new();
|
||||
for c in &doc.components {
|
||||
components.entry(c.id).or_insert(c);
|
||||
}
|
||||
let mut bodies: HashMap<u64, Vec<&ModelBody>> = HashMap::new();
|
||||
for (body, owner) in state.bodies.iter().zip(doc.model_body_components(state)) {
|
||||
bodies.entry(owner).or_default().push(body);
|
||||
}
|
||||
let values = std::cell::OnceCell::new();
|
||||
let amounts = components.values().filter_map(|c| Some((c.id, quantity::amount_in(doc, &values, c.metadata.bom_quantity()?).ok()))).collect();
|
||||
Tree { doc, children, components, bodies, weighed: BTreeMap::new(), made_of: BTreeMap::new(), amounts }
|
||||
}
|
||||
|
||||
/// What a component places.
|
||||
fn contents(&self, id: u64) -> &[Placed] {
|
||||
self.children.get(&id).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The components placed in `id` whose parts are part of what `id` stands for as `holds` says
|
||||
/// (see `adds`), one for each placement, with the type each counts as there.
|
||||
fn adding(&self, id: u64, holds: Holds) -> Vec<Member> {
|
||||
self.contents(id).iter().filter(|p| adds(holds, p.member.bom_type)).map(|p| p.member).collect()
|
||||
}
|
||||
|
||||
/// The static material typed in for a component, if any.
|
||||
fn static_material(&self, id: u64) -> Option<&'a str> {
|
||||
self.components.get(&id).and_then(|c| c.metadata.material())
|
||||
}
|
||||
|
||||
fn own_bodies(&self, id: u64) -> &[&'a ModelBody] {
|
||||
self.bodies.get(&id).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// What a row of `member` gathers by: its part number when `group` asks for that and it has
|
||||
/// one, else its definition. `None` for a damaged file's occurrence of a component that isn't
|
||||
/// there, which has no row.
|
||||
fn part(&self, member: Member, group: Group) -> Option<Part<'a>> {
|
||||
let c: &'a Component = self.components.get(&member.component)?;
|
||||
Some(match c.metadata.part_number() {
|
||||
Some(pn) if group == Group::PartNumber => Part::Number(pn),
|
||||
_ => Part::Definition(member.component),
|
||||
})
|
||||
}
|
||||
|
||||
/// The rows of one level: the placements `at` (positions in `entries`), gathered as `group`
|
||||
/// says, listed under the rows `parents` names, `per` of them in all (1 for the assembly).
|
||||
/// The rows under these are gathered as `under` says.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn level(&self, listing: &Listing, paths: &mut [Vec<u64>], at: &[usize], group: Group, under: Group, parents: &[Member], per: u64) -> Vec<Row> {
|
||||
let mut grouped: BTreeMap<GroupKey<'a>, Vec<usize>> = BTreeMap::new();
|
||||
for &i in at {
|
||||
let Some(entry) = listing.entries.get(i) else { continue };
|
||||
let Some(part) = self.part(entry.member, group) else { continue };
|
||||
let kit = listing.kits.get(i).copied().unwrap_or_default();
|
||||
grouped.entry(GroupKey { part, bom_type: entry.member.bom_type, kit }).or_default().push(i);
|
||||
}
|
||||
let mut rows: Vec<Row> = grouped.values().filter_map(|members| self.row(listing, paths, members, under, parents, per)).collect();
|
||||
rows.sort_by(list_order);
|
||||
for (item, row) in (1..).zip(&mut rows) {
|
||||
row.item = item;
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Each entry's kit, as a number: what it holds as its row's children read it, to any depth.
|
||||
/// Two entries have the same kit when what they hold is the same parts (by part number, else
|
||||
/// definition), counted as the same types, each holding the same again all the way down. One
|
||||
/// pass from the last entry back: an entry's children come after it, so theirs are known first.
|
||||
fn kits(&self, entries: &[Entry], kids: &[Vec<usize>]) -> Vec<u32> {
|
||||
let mut kit_ids: HashMap<Vec<u32>, u32> = HashMap::new();
|
||||
let mut placed_ids: HashMap<(Option<Part<'a>>, BomType, u32), u32> = HashMap::new();
|
||||
// Each entry as a child reads: its part, its type and its own kit.
|
||||
let mut placed = vec![0u32; entries.len()];
|
||||
let mut kits = vec![0u32; entries.len()];
|
||||
for (i, entry) in entries.iter().enumerate().rev() {
|
||||
let mut held: Vec<u32> = kids.get(i).into_iter().flatten().filter_map(|&k| placed.get(k).copied()).collect();
|
||||
held.sort_unstable();
|
||||
let next = kit_ids.len() as u32;
|
||||
let kit = *kit_ids.entry(held).or_insert(next);
|
||||
let next = placed_ids.len() as u32;
|
||||
let key = (self.part(entry.member, Group::PartNumber), entry.member.bom_type, kit);
|
||||
if let (Some(k), Some(p)) = (kits.get_mut(i), placed.get_mut(i)) {
|
||||
*k = kit;
|
||||
*p = *placed_ids.entry(key).or_insert(next);
|
||||
}
|
||||
}
|
||||
kits
|
||||
}
|
||||
|
||||
/// One row: the placements `members` (positions in `entries`) of one part, type and kit, with
|
||||
/// the rows under it gathered as `under` says. Their `paths` (when the list keeps them) move into
|
||||
/// the row: each placement is in one row.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row(&self, listing: &Listing, paths: &mut [Vec<u64>], members: &[usize], under: Group, parents: &[Member], per: u64) -> Option<Row> {
|
||||
let listed: Vec<&Entry> = members.iter().filter_map(|&i| listing.entries.get(i)).collect();
|
||||
let first = listed.first()?;
|
||||
let mut components: Vec<u64> = listed.iter().map(|e| e.member.component).collect();
|
||||
components.sort_unstable();
|
||||
components.dedup();
|
||||
let member = Member { component: *components.first()?, bom_type: first.member.bom_type };
|
||||
let count = listed.len() as u64;
|
||||
let mut taken: Vec<Vec<u64>> = members.iter().filter_map(|&i| paths.get_mut(i).map(std::mem::take)).filter(|p| !p.is_empty()).collect();
|
||||
taken.sort_unstable();
|
||||
// What one of it holds is listed under it.
|
||||
let inside: Vec<usize> = members.iter().flat_map(|&i| listing.kids.get(i).into_iter().flatten().copied()).collect();
|
||||
let mut chain = parents.to_vec();
|
||||
chain.push(member);
|
||||
let mut row = Row {
|
||||
key: RowKey { parents: parents.to_vec(), member },
|
||||
item: 0,
|
||||
provisional: true,
|
||||
locked: false,
|
||||
duplicate: false,
|
||||
bom_type: member.bom_type,
|
||||
stands_for: first.holds,
|
||||
// Filled in by `name_materials`, which reads each definition once.
|
||||
materials: Vec::new(),
|
||||
statics: Vec::new(),
|
||||
components,
|
||||
names: Vec::new(),
|
||||
part_number: String::new(),
|
||||
description: String::new(),
|
||||
revision: String::new(),
|
||||
properties: BTreeMap::new(),
|
||||
count,
|
||||
// Rows with different kits are different rows, to any depth, so every part of the parent
|
||||
// row holds as many.
|
||||
each: count / per.max(1),
|
||||
quantity: quantity::add_up(listed.iter().map(|e| self.amounts.get(&e.member.component))),
|
||||
members: taken,
|
||||
mass: Mass::NotAsked,
|
||||
children: self.level(listing, paths, &inside, under, under, &chain, count),
|
||||
};
|
||||
for (i, c) in row.components.iter().filter_map(|id| self.components.get(id)).enumerate() {
|
||||
let m = &c.metadata;
|
||||
if i == 0 {
|
||||
row.part_number = m.part_number().unwrap_or_default().to_string();
|
||||
}
|
||||
if !row.names.contains(&c.name) {
|
||||
row.names.push(c.name.clone());
|
||||
}
|
||||
if row.description.is_empty() {
|
||||
row.description = m.description().unwrap_or_default().to_string();
|
||||
}
|
||||
if row.revision.is_empty() {
|
||||
row.revision = m.revision().unwrap_or_default().to_string();
|
||||
}
|
||||
for (k, v) in &c.properties {
|
||||
row.properties.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
|
||||
/// Each row's materials, and its children's (Cameron, 2026-10-23): a part lists its own bodies';
|
||||
/// a row that stands for an assembly shows a material only when everything it stands for
|
||||
/// shares it, so a frame of steel and aluminium parts reads blank and a bought bearing of steel
|
||||
/// parts reads steel. A static material, the first its definitions give, replaces that.
|
||||
fn name_materials(&mut self, rows: &mut [Row]) -> Result<()> {
|
||||
for row in rows {
|
||||
self.name_materials(&mut row.children)?;
|
||||
let typed_in = row.components.iter().find_map(|&id| self.static_material(id));
|
||||
if let Some(material) = typed_in {
|
||||
row.materials = vec![material.to_string()];
|
||||
row.statics.push(Static::Material);
|
||||
continue;
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut assembly = false;
|
||||
for &c in &row.components {
|
||||
assembly |= row.stands_for != Holds::Own && !self.adding(c, row.stands_for).is_empty();
|
||||
for name in self.made_of(c, row.stands_for, 0)? {
|
||||
push_once(&mut names, name);
|
||||
}
|
||||
}
|
||||
row.materials = if assembly && names.len() > 1 { Vec::new() } else { names };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The materials of what one of `id` stands for as `holds` says, each once, in body order: the
|
||||
/// bodies `weigh` weighs. A static material stands for all of a part placed inside it, and for
|
||||
/// a phantom's own bodies only (a phantom's row is its own bodies, so what it holds keeps its
|
||||
/// own). Each definition is read once for each kind of row; `depth` bounds the walk below a
|
||||
/// definition not read yet.
|
||||
fn made_of(&mut self, id: u64, holds: Holds, depth: usize) -> Result<Vec<String>> {
|
||||
if let Some(names) = self.made_of.get(&(id, holds)) {
|
||||
return Ok(names.clone());
|
||||
}
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return Err(too_big());
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
match self.static_material(id) {
|
||||
Some(material) if !self.own_bodies(id).is_empty() => push_once(&mut names, material.to_string()),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
for body in self.own_bodies(id) {
|
||||
push_once(&mut names, self.doc.body_material(&body.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
if holds != Holds::Own {
|
||||
for child in self.adding(id, holds) {
|
||||
match self.static_material(child.component) {
|
||||
Some(material) if child.bom_type != BomType::Phantom => push_once(&mut names, material.to_string()),
|
||||
_ => {
|
||||
for name in self.made_of(child.component, holds, depth + 1)? {
|
||||
push_once(&mut names, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.made_of.insert((id, holds), names.clone());
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Each row's mass, and its children's.
|
||||
fn weigh_rows(&mut self, rows: &mut [Row]) -> Result<()> {
|
||||
for row in rows {
|
||||
let weighed = row.components.iter().map(|&c| self.weigh(c, row.stands_for, 0)).collect::<Result<Vec<Weighed>>>()?;
|
||||
row.mass = agree(&weighed);
|
||||
self.weigh_rows(&mut row.children)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One of `id` as `holds` says: its own bodies, and of what is placed inside it everything that
|
||||
/// adds to it (see `adds`). Where a part is placed doesn't change what it weighs, so each
|
||||
/// definition is weighed once for each kind of row and each body measured once. `depth` bounds
|
||||
/// the walk below a definition not weighed yet.
|
||||
fn weigh(&mut self, id: u64, holds: Holds, depth: usize) -> Result<Weighed> {
|
||||
if let Some(&weighed) = self.weighed.get(&(id, holds)) {
|
||||
return Ok(weighed);
|
||||
}
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return Err(too_big());
|
||||
}
|
||||
let mut total: Weighed = Some((0.0, 0));
|
||||
if holds == Holds::Own {
|
||||
for body in self.own_bodies(id) {
|
||||
add(&mut total, self.measure(body));
|
||||
}
|
||||
} else {
|
||||
add(&mut total, self.weigh(id, Holds::Own, depth)?);
|
||||
for child in self.adding(id, holds) {
|
||||
add(&mut total, self.weigh(child.component, holds, depth + 1)?);
|
||||
}
|
||||
}
|
||||
let weighed = total.filter(|(grams, _)| grams.is_finite());
|
||||
self.weighed.insert((id, holds), weighed);
|
||||
Ok(weighed)
|
||||
}
|
||||
|
||||
/// What one body weighs, in grams: its volume, measured once for the model
|
||||
/// ([`ModelBody::volume`]), scaled by a body offset as the offset scales what is drawn (a
|
||||
/// damaged file's scaled offset weighs what is drawn).
|
||||
fn measure(&self, body: &ModelBody) -> Weighed {
|
||||
let scale = self.doc.model_body_offset(body).map_or(1.0, crate::mat_volume_scale);
|
||||
// A negative volume (a body inside out) is unknown, never a negative mass.
|
||||
body.volume().map(|v| v * scale).filter(|v| *v >= 0.0).map(|v| (v * self.doc.density(&body.name) / 1000.0, 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// The order a level lists its rows in until it is numbered: by part number; rows without one
|
||||
/// after them, by name; one part's rows by type.
|
||||
fn list_order(a: &Row, b: &Row) -> Ordering {
|
||||
(a.part_number.is_empty(), &a.part_number, &a.names, &a.components, a.bom_type).cmp(&(
|
||||
b.part_number.is_empty(),
|
||||
&b.part_number,
|
||||
&b.names,
|
||||
&b.components,
|
||||
b.bom_type,
|
||||
))
|
||||
}
|
||||
|
||||
/// What a row gathers by: a part number, borrowed from its definition, or the definition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
enum Part<'a> {
|
||||
Number(&'a str),
|
||||
Definition(u64),
|
||||
}
|
||||
|
||||
/// How one row is told from another of its level: what it gathers by, the type its parts count
|
||||
/// as, and its kit: what one of them holds as the row's children read it, to any depth (`Tree::kits`;
|
||||
/// the same for all that hold nothing listed).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct GroupKey<'a> {
|
||||
part: Part<'a>,
|
||||
bom_type: BomType,
|
||||
kit: u32,
|
||||
}
|
||||
|
||||
/// The placements a list counts, what each folded or all-levels row holds, and each one's kit.
|
||||
struct Listing {
|
||||
entries: Vec<Entry>,
|
||||
/// What each entry holds: the entries listed as its children.
|
||||
kids: Vec<Vec<usize>>,
|
||||
kits: Vec<u32>,
|
||||
}
|
||||
|
||||
/// How a walk lists what it passes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Walk {
|
||||
/// What the view lists.
|
||||
Listed,
|
||||
/// Inside an inseparable part in parts only: just its purchased parts, which are listed
|
||||
/// beside it, at any depth.
|
||||
PurchasedOnly,
|
||||
}
|
||||
|
||||
/// What a placed component is like, as far as listing it goes.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Shape {
|
||||
/// It places components of its own.
|
||||
contents: bool,
|
||||
/// It has bodies of its own.
|
||||
bodies: bool,
|
||||
/// It is a part with a part number in a list that folds.
|
||||
folded: bool,
|
||||
/// What it holds is listed under it if it is purchased ([`Request::purchased_contents`], all
|
||||
/// levels).
|
||||
open_purchased: bool,
|
||||
}
|
||||
|
||||
/// How one placement counts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Step {
|
||||
/// It is counted in a row here.
|
||||
row: bool,
|
||||
/// How what it holds is walked (`None`: not at all).
|
||||
inside: Option<Walk>,
|
||||
/// What it holds is listed as its row's children.
|
||||
kit: bool,
|
||||
/// What its row stands for when weighed.
|
||||
holds: Holds,
|
||||
}
|
||||
|
||||
/// How a placement counts in `view`: the one rule every list and every mass read. A reference
|
||||
/// counts nowhere and nothing inside it does; a phantom's own bodies, when it has any, are a row of
|
||||
/// their own (as a subassembly's are in parts only) and what it holds counts where it is placed; a purchased part is one row with nothing inside it listed (in all levels, its
|
||||
/// contents under it when asked); an inseparable part is one row, and in parts only its purchased
|
||||
/// parts are listed beside it; a normal part is a row in the structured views, in all levels with
|
||||
/// what it holds under it, and in parts only a subassembly is listed through its parts unless it
|
||||
/// has bodies of its own. A folded part is one row holding all it holds.
|
||||
fn classify(view: View, walk: Walk, bom_type: BomType, shape: Shape) -> Step {
|
||||
let nothing = Step { row: false, inside: None, kit: false, holds: Holds::All };
|
||||
let row = |holds| Step { row: true, holds, ..nothing };
|
||||
let contents = |walk| shape.contents.then_some(walk);
|
||||
match (bom_type, walk) {
|
||||
(BomType::Reference, _) => nothing,
|
||||
(BomType::Phantom, Walk::Listed) => Step { row: shape.bodies, inside: Some(Walk::Listed), kit: false, holds: Holds::Own },
|
||||
(BomType::Phantom, Walk::PurchasedOnly) => Step { inside: Some(walk), ..nothing },
|
||||
(BomType::Purchased, Walk::Listed) if view == View::AllLevels && shape.open_purchased => {
|
||||
Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) }
|
||||
}
|
||||
(BomType::Purchased, _) => row(Holds::All),
|
||||
(_, Walk::PurchasedOnly) => Step { inside: contents(Walk::PurchasedOnly), ..nothing },
|
||||
(BomType::Inseparable, Walk::Listed) => match view {
|
||||
View::PartsOnly => Step { inside: contents(Walk::PurchasedOnly), ..row(Holds::AllButPurchased) },
|
||||
View::FirstLevel | View::AllLevels => row(Holds::All),
|
||||
},
|
||||
(BomType::Normal, Walk::Listed) if shape.folded => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
|
||||
(BomType::Normal, Walk::Listed) => match view {
|
||||
View::PartsOnly => Step { row: !shape.contents || shape.bodies, inside: contents(Walk::Listed), kit: false, holds: Holds::Own },
|
||||
View::FirstLevel => row(Holds::All),
|
||||
View::AllLevels => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a placement of `bom_type` inside a part adds to what the part's row stands for: a
|
||||
/// reference adds nothing, and what the list shows beside the row (what `classify` lists inside
|
||||
/// an inseparable part) isn't weighed with it.
|
||||
fn adds(holds: Holds, bom_type: BomType) -> bool {
|
||||
match holds {
|
||||
Holds::Own => false,
|
||||
Holds::All => bom_type != BomType::Reference,
|
||||
Holds::AllButPurchased => bom_type != BomType::Reference && !classify(View::PartsOnly, Walk::PurchasedOnly, bom_type, Shape::default()).row,
|
||||
}
|
||||
}
|
||||
|
||||
/// One placement a list counts in a row.
|
||||
struct Entry {
|
||||
member: Member,
|
||||
holds: Holds,
|
||||
/// The folded placement (a position in the entries) whose children it is among.
|
||||
kit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Every placement below `request.source` that `request` counts in a row, in one walk under one
|
||||
/// budget, and with `members` each one's occurrence path (else an empty path each).
|
||||
fn walk(tree: &Tree, request: &Request, members: bool) -> Result<(Vec<Entry>, Vec<Vec<u64>>)> {
|
||||
struct Frame {
|
||||
component: u64,
|
||||
depth: usize,
|
||||
/// Kept only with `members`.
|
||||
path: Vec<u64>,
|
||||
walk: Walk,
|
||||
kit: Option<usize>,
|
||||
}
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
let mut paths: Vec<Vec<u64>> = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
let mut stack = vec![Frame { component: request.source, depth: 0, path: Vec::new(), walk: Walk::Listed, kit: None }];
|
||||
while let Some(frame) = stack.pop() {
|
||||
for placed in tree.contents(frame.component) {
|
||||
visited += 1;
|
||||
if frame.depth >= MAX_PLACEMENT_DEPTH || visited > MAX_PLACEMENTS {
|
||||
return Err(too_big());
|
||||
}
|
||||
let component = placed.member.component;
|
||||
// What a folded part holds is listed as parts only lists it, and isn't folded again; in
|
||||
// all levels what any row holds is listed in all levels again.
|
||||
let (view, folds) = match frame.kit {
|
||||
Some(_) if request.view == View::AllLevels => (View::AllLevels, false),
|
||||
Some(_) => (View::PartsOnly, false),
|
||||
None => (request.view, request.fold),
|
||||
};
|
||||
let numbered = tree.components.get(&component).is_some_and(|c| c.metadata.part_number().is_some());
|
||||
let shape = Shape {
|
||||
contents: tree.children.contains_key(&component),
|
||||
bodies: tree.bodies.contains_key(&component),
|
||||
folded: folds && numbered,
|
||||
open_purchased: request.purchased_contents,
|
||||
};
|
||||
let step = classify(view, frame.walk, placed.member.bom_type, shape);
|
||||
let path = if members { [frame.path.as_slice(), &[placed.occurrence]].concat() } else { Vec::new() };
|
||||
let here = entries.len();
|
||||
if step.row {
|
||||
entries.push(Entry { member: placed.member, holds: step.holds, kit: frame.kit });
|
||||
paths.push(path.clone());
|
||||
}
|
||||
if let Some(walk) = step.inside {
|
||||
let kit = if step.kit { Some(here) } else { frame.kit };
|
||||
stack.push(Frame { component, depth: frame.depth + 1, path, walk, kit });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((entries, paths))
|
||||
}
|
||||
|
||||
impl Tree<'_> {
|
||||
/// Component `id` (0: the design) as a title block shows it (#3875): what one of it weighs, as
|
||||
/// a list weighs the assembly ([`Total::mass`]: its own bodies and all it holds but references),
|
||||
/// and what that is made of, as its row would say. Each is worked out once for the tree.
|
||||
pub fn component(&mut self, id: u64) -> Result<(Mass, Vec<String>)> {
|
||||
if id != 0 && !self.components.contains_key(&id) {
|
||||
return Err(DocError::Unknown(format!("component {id}")));
|
||||
}
|
||||
let mass = agree(&[self.weigh(id, Holds::All, 0)?]);
|
||||
Ok((mass, self.made_of(id, Holds::All, 0)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows `request` lists, with the assembly's total; with `request.mass`, each row and the
|
||||
/// assembly weighed.
|
||||
pub fn list(tree: &mut Tree, request: &Request) -> Result<Bom> {
|
||||
list_with(tree, request, false)
|
||||
}
|
||||
|
||||
/// [`list`], with each row's members when asked (what item numbers and balloons will follow rows
|
||||
/// by, and what `row_of` reads), so a list made for its rows alone copies no paths.
|
||||
pub(crate) fn list_with(tree: &mut Tree, request: &Request, members: bool) -> Result<Bom> {
|
||||
if request.source != 0 && !tree.components.contains_key(&request.source) {
|
||||
return Err(DocError::Unknown(format!("component {}", request.source)));
|
||||
}
|
||||
let (entries, mut paths) = walk(tree, request, members)?;
|
||||
// What each folded placement holds: the placements listed as its children.
|
||||
let mut kids: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
if let Some(of) = entry.kit.and_then(|k| kids.get_mut(k)) {
|
||||
of.push(i);
|
||||
}
|
||||
}
|
||||
let kits = tree.kits(&entries, &kids);
|
||||
let listing = Listing { entries, kids, kits };
|
||||
let top: Vec<usize> = listing.entries.iter().enumerate().filter(|(_, e)| e.kit.is_none()).map(|(i, _)| i).collect();
|
||||
// What a folded part holds is gathered by part number in any list; all levels gathers every
|
||||
// level as it gathers the first.
|
||||
let under = if request.view == View::AllLevels { request.group } else { Group::PartNumber };
|
||||
let mut rows = tree.level(&listing, &mut paths, &top, request.group, under, &[], 1);
|
||||
let numbered = numbers::apply(&mut rows, numbers::saved(tree.doc, request.source, request.view));
|
||||
let mut bom = Bom { source: request.source, rows, total: Total::default(), numbered, index: HashMap::new() };
|
||||
if members {
|
||||
bom.index_members();
|
||||
}
|
||||
tree.name_materials(&mut bom.rows)?;
|
||||
if request.mass {
|
||||
tree.weigh_rows(&mut bom.rows)?;
|
||||
// After the rows, so what their definitions weigh is kept: only what no row holds (the
|
||||
// assembly's own bodies) is measured again.
|
||||
bom.total.mass = agree(&[tree.weigh(request.source, Holds::All, 0)?]);
|
||||
}
|
||||
let occurrences: u64 = bom.rows_deep().iter().map(|(_, row)| row.count).sum();
|
||||
bom.total.occurrences = occurrences;
|
||||
Ok(bom)
|
||||
}
|
||||
|
||||
/// One of a row's parts, from what each of its definitions weighs: known when every one can be
|
||||
/// measured, has something to weigh, and they agree. Some with something to weigh and some with
|
||||
/// nothing differ.
|
||||
fn agree(weighed: &[Weighed]) -> Mass {
|
||||
let Some(known) = weighed.iter().copied().collect::<Option<Vec<(f64, usize)>>>() else {
|
||||
return Mass::Unknown(Unknown::Unmeasurable);
|
||||
};
|
||||
if known.iter().all(|&(_, bodies)| bodies == 0) {
|
||||
return Mass::Unknown(Unknown::NothingToWeigh);
|
||||
}
|
||||
match known.first() {
|
||||
Some(&(first, _)) if known.iter().any(|&(grams, bodies)| bodies == 0 || !same_mass(grams, first)) => Mass::Unknown(Unknown::Differs),
|
||||
Some(&(first, _)) => Mass::Grams(first),
|
||||
None => Mass::Unknown(Unknown::NothingToWeigh),
|
||||
}
|
||||
}
|
||||
|
||||
/// How closely the kernel's `measure` gives one solid's volume however it was modelled, relative to
|
||||
/// it (about 1e-4): a pin made as a cylinder and as a revolved rectangle measured 1.05e-5 apart
|
||||
/// (2026-10-26), and this is ten times that.
|
||||
pub const MEASURE_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// Two masses of one part, measured from definitions modelled apart: one when they are within the
|
||||
/// kernel's measurement accuracy of each other.
|
||||
fn same_mass(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= MEASURE_ACCURACY * a.abs().max(b.abs())
|
||||
}
|
||||
|
||||
/// `more` added to `total`; unknown when either is. The body count only says whether there is
|
||||
/// any, so a compact assembly with more placed bodies than a count holds saturates it.
|
||||
fn add(total: &mut Weighed, more: Weighed) {
|
||||
*total = total.zip(more).map(|((grams, bodies), (g, n))| (grams + g, bodies.saturating_add(n)));
|
||||
}
|
||||
|
||||
/// `name` added to `names` unless it is there already, so each is listed once, in first order.
|
||||
fn push_once(names: &mut Vec<String>, name: String) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn too_big() -> DocError {
|
||||
crate::assembly::too_many_placements()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bom_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Item numbers (#3874). A view of an assembly is numbered once someone numbers it
|
||||
//! (`assembly.bom.renumber`, `assembly.bom.item.set`): from then on its rows' numbers are saved in
|
||||
//! the design ([`ItemNumbers`], `assembly.item_numbers`), each under its row's key, so a row keeps
|
||||
//! its number through part-number edits. Until then a view's numbers are its rows' positions,
|
||||
//! provisional, and listing it saves nothing.
|
||||
//!
|
||||
//! A row a numbered view has no number for (a part added since) shows the highest number saved at
|
||||
//! its level plus one, provisional, and [`record`] saves it: the engine runs it after each change,
|
||||
//! in the change's undo step. A row that goes keeps its number until a renumber, so a number on a
|
||||
//! printed drawing never comes to mean another part, and a part put back gets its number again.
|
||||
//! A renumber keeps locked numbers and skips them.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Group, Member, Request, Row, RowKey, Tree, View, item_text, list, list_order};
|
||||
use crate::{DocError, Document, MAX_PLACEMENTS, Result};
|
||||
|
||||
/// The highest item number.
|
||||
pub const MAX_ITEM: u32 = 999_999;
|
||||
|
||||
/// The item numbers saved for one view of one assembly, with the list options it was numbered
|
||||
/// with: the rows it numbers are listed by them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ItemNumbers {
|
||||
/// The assembly: a component, or 0 for the design.
|
||||
pub source: u64,
|
||||
#[serde(rename = "levels")]
|
||||
pub view: View,
|
||||
#[serde(default, rename = "group_by")]
|
||||
pub group: Group,
|
||||
#[serde(default, rename = "group_children", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub fold: bool,
|
||||
#[serde(default, rename = "show_purchased_contents", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub purchased_contents: bool,
|
||||
/// One for each row numbered, rows gone since included, by key.
|
||||
#[serde(default)]
|
||||
pub items: Vec<ItemNumber>,
|
||||
}
|
||||
|
||||
/// One row's saved item number.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ItemNumber {
|
||||
#[serde(flatten)]
|
||||
pub(crate) key: RowKey,
|
||||
pub number: u32,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl ItemNumbers {
|
||||
/// The list it numbers.
|
||||
pub fn request(&self) -> Request {
|
||||
Request { source: self.source, view: self.view, group: self.group, fold: self.fold, mass: false, purchased_contents: self.purchased_contents }
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`number`] numbers a view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Numbering {
|
||||
/// Each row without a saved number keeps the one it shows: the next at its level. Only a
|
||||
/// numbered view has any to save.
|
||||
NewRows,
|
||||
/// Every row from `start` by `step`, level by level, in `order`. Locked rows keep their
|
||||
/// numbers, which are skipped, and rows gone are forgotten.
|
||||
Renumber { start: u32, step: u32, order: Order },
|
||||
/// One row, by its item as the view shows it now (`[1, 2]` is 1.2): its number and its lock.
|
||||
/// Every other row keeps the number it shows.
|
||||
Set { row: Vec<u32>, item: Option<u32>, locked: Option<bool> },
|
||||
}
|
||||
|
||||
/// The order a renumber goes in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Order {
|
||||
/// The list's own, by part number (`list_order`).
|
||||
#[default]
|
||||
List,
|
||||
/// The item numbers', closing the gaps.
|
||||
Item,
|
||||
}
|
||||
|
||||
/// The numbering saved for a view of an assembly, if it has been numbered.
|
||||
pub(super) fn saved(doc: &Document, source: u64, view: View) -> Option<&ItemNumbers> {
|
||||
doc.assembly.item_numbers.iter().find(|n| n.source == source && n.view == view)
|
||||
}
|
||||
|
||||
/// `rows`, as their view lists them, numbered as `saved` says: each its saved number, else the next
|
||||
/// at its level (provisional); then each level by item number, with duplicates marked. With no
|
||||
/// numbering they stay as listed, every number provisional. Whether the view is numbered.
|
||||
pub(super) fn apply(rows: &mut [Row], saved: Option<&ItemNumbers>) -> bool {
|
||||
let Some(saved) = saved else { return false };
|
||||
// A damaged file's number out of range is no number, and of a key saved twice the first holds.
|
||||
let mut numbers: HashMap<&RowKey, &ItemNumber> = HashMap::new();
|
||||
let mut highest: HashMap<Vec<Member>, u32> = HashMap::new();
|
||||
for item in saved.items.iter().filter(|i| (1..=MAX_ITEM).contains(&i.number)) {
|
||||
if numbers.contains_key(&item.key) {
|
||||
continue;
|
||||
}
|
||||
numbers.insert(&item.key, item);
|
||||
let h = highest.entry(item.key.parents.clone()).or_default();
|
||||
*h = (*h).max(item.number);
|
||||
}
|
||||
number_level(rows, &numbers, &mut highest);
|
||||
true
|
||||
}
|
||||
|
||||
/// One level of [`apply`], and the levels under it. `highest` is the highest number at each level
|
||||
/// (by its parents), saved or shown.
|
||||
fn number_level(rows: &mut [Row], numbers: &HashMap<&RowKey, &ItemNumber>, highest: &mut HashMap<Vec<Member>, u32>) {
|
||||
for row in rows.iter_mut() {
|
||||
if let Some(saved) = numbers.get(&row.key) {
|
||||
row.item = saved.number;
|
||||
row.locked = saved.locked;
|
||||
row.provisional = false;
|
||||
} else {
|
||||
let h = highest.entry(row.key.parents.clone()).or_default();
|
||||
*h = h.saturating_add(1);
|
||||
row.item = *h;
|
||||
}
|
||||
number_level(&mut row.children, numbers, highest);
|
||||
}
|
||||
rows.sort_by_key(|r| r.item);
|
||||
let mut uses: HashMap<u32, usize> = HashMap::new();
|
||||
for row in rows.iter() {
|
||||
*uses.entry(row.item).or_default() += 1;
|
||||
}
|
||||
for row in rows.iter_mut() {
|
||||
row.duplicate = uses.get(&row.item).is_some_and(|&n| n > 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The view `request` asks for, numbered as `how` says: the numbering to save, or `None` when that
|
||||
/// changes nothing.
|
||||
pub fn number(tree: &mut Tree, request: &Request, how: Numbering) -> Result<Option<ItemNumbers>> {
|
||||
let request = Request { mass: false, ..*request };
|
||||
// Borrowed from the design, not the tree: listing needs the tree, and nothing is copied until
|
||||
// something changes.
|
||||
let doc = tree.doc;
|
||||
let old = saved(doc, request.source, request.view);
|
||||
let mut bom = list(tree, &request)?;
|
||||
let mut items: Vec<ItemNumber> = Vec::new();
|
||||
match how {
|
||||
Numbering::NewRows => {
|
||||
let Some(old) = old else { return Ok(None) };
|
||||
let room = MAX_PLACEMENTS.saturating_sub(old.items.len());
|
||||
let shown = bom.rows_deep();
|
||||
let new: Vec<ItemNumber> = shown
|
||||
.iter()
|
||||
.filter(|(_, r)| r.provisional && r.item <= MAX_ITEM)
|
||||
.take(room)
|
||||
.map(|(_, r)| ItemNumber { key: r.key.clone(), number: r.item, locked: false })
|
||||
.collect();
|
||||
if new.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
items = [old.items.as_slice(), &new].concat();
|
||||
}
|
||||
Numbering::Set { row, item, locked } => {
|
||||
if let Some(n) = item.filter(|n| !(1..=MAX_ITEM).contains(n)) {
|
||||
return Err(DocError::Invalid(format!("an item number is 1 to {MAX_ITEM}, not {n}")));
|
||||
}
|
||||
let shown = bom.rows_deep();
|
||||
let target = shown
|
||||
.iter()
|
||||
.find(|(i, _)| *i == row)
|
||||
.map(|(_, r)| r.key.clone())
|
||||
.ok_or_else(|| DocError::Unknown(format!("item {}", item_text(&row))))?;
|
||||
// Rows not listed now keep what was saved for them; listed ones the number they show.
|
||||
let listed: HashSet<&RowKey> = shown.iter().map(|(_, r)| &r.key).collect();
|
||||
items.extend(old.iter().flat_map(|o| &o.items).filter(|i| !listed.contains(&i.key)).cloned());
|
||||
items.extend(shown.iter().map(|(_, r)| ItemNumber { key: r.key.clone(), number: r.item, locked: r.locked }));
|
||||
if let Some(it) = items.iter_mut().find(|i| i.key == target) {
|
||||
it.number = item.unwrap_or(it.number);
|
||||
it.locked = locked.unwrap_or(it.locked);
|
||||
}
|
||||
}
|
||||
Numbering::Renumber { start, step, order } => {
|
||||
for (name, n) in [("start", start), ("step", step)] {
|
||||
if !(1..=MAX_ITEM).contains(&n) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is 1 to {MAX_ITEM}, not {n}")));
|
||||
}
|
||||
}
|
||||
renumber(&mut bom.rows, start, step, order, &mut items)?;
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
let new = ItemNumbers {
|
||||
source: request.source,
|
||||
view: request.view,
|
||||
group: request.group,
|
||||
fold: request.fold,
|
||||
purchased_contents: request.purchased_contents,
|
||||
items,
|
||||
};
|
||||
Ok((old != Some(&new)).then_some(new))
|
||||
}
|
||||
|
||||
/// `rows` and the levels under them numbered from `start` by `step` in `order`, each level on its
|
||||
/// own, into `out`. Locked rows keep their numbers and the others skip them, so no two rows get one
|
||||
/// number.
|
||||
fn renumber(rows: &mut [Row], start: u32, step: u32, order: Order, out: &mut Vec<ItemNumber>) -> Result<()> {
|
||||
if order == Order::List {
|
||||
rows.sort_by(list_order);
|
||||
}
|
||||
let locked: HashSet<u32> = rows.iter().filter(|r| r.locked).map(|r| r.item).collect();
|
||||
let mut next = start;
|
||||
for row in rows.iter_mut() {
|
||||
if !row.locked {
|
||||
while locked.contains(&next) {
|
||||
next = next.saturating_add(step);
|
||||
}
|
||||
if next > MAX_ITEM {
|
||||
return Err(DocError::Invalid(format!("numbering from {start} by {step} passes {MAX_ITEM}")));
|
||||
}
|
||||
row.item = next;
|
||||
next = next.saturating_add(step);
|
||||
}
|
||||
out.push(ItemNumber { key: row.key.clone(), number: row.item, locked: row.locked });
|
||||
renumber(&mut row.children, start, step, order, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each numbered view of the design with its new rows' numbers saved ([`Numbering::NewRows`]), and
|
||||
/// a component's views dropped once it is gone: the design's numberings to save, or `None` when
|
||||
/// none changed. A view that can't be listed now (too large) is left as it is. A view numbered
|
||||
/// twice (only a damaged file is) keeps its first numbering, the one listing reads.
|
||||
pub fn record(tree: &mut Tree) -> Option<Vec<ItemNumbers>> {
|
||||
let doc = tree.doc;
|
||||
// Each numbering that changes: its new self, or none when its component is gone or its view
|
||||
// already has one, so no view is listed twice after a change.
|
||||
let mut changes: Vec<(usize, Option<ItemNumbers>)> = Vec::new();
|
||||
let mut views = HashSet::new();
|
||||
for (i, numbering) in doc.assembly.item_numbers.iter().enumerate() {
|
||||
let gone = numbering.source != 0 && !tree.components.contains_key(&numbering.source);
|
||||
if gone || !views.insert((numbering.source, numbering.view)) {
|
||||
changes.push((i, None));
|
||||
} else if let Ok(Some(new)) = number(tree, &numbering.request(), Numbering::NewRows) {
|
||||
changes.push((i, Some(new)));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = doc.assembly.item_numbers.clone();
|
||||
for (i, change) in changes.into_iter().rev() {
|
||||
match change {
|
||||
Some(new) => {
|
||||
if let Some(numbering) = out.get_mut(i) {
|
||||
*numbering = new;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Quantities (#3874): what one placement of a component means in a bill of materials, typed in as
|
||||
//! its [`BOM_QUANTITY`](crate::metadata::BOM_QUANTITY) part property, and what a row's placements
|
||||
//! add up to. The property is an expression evaluated with the design's parameters, so a cable's
|
||||
//! length follows the design; without a unit it is a number of pieces (a pack of 4). Units come
|
||||
//! from expressions: mass, volume and area wait for their expression units (#4811).
|
||||
|
||||
use std::cell::OnceCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::expr::{self, Value};
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The largest quantity of one placement, in pieces or millimetres.
|
||||
const MAX_AMOUNT: f64 = 1e9;
|
||||
|
||||
/// The mass and volume units of #4811's expressions: until they reach expressions, a quantity
|
||||
/// naming one is refused for what it is, not as an unknown parameter.
|
||||
const MASS_AND_VOLUME: [&str; 7] = ["g", "kg", "lb", "ml", "mm³", "cm³", "m³"];
|
||||
|
||||
/// What one placement of a component counts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Amount {
|
||||
Pieces(f64),
|
||||
/// Millimetres.
|
||||
Length(f64),
|
||||
}
|
||||
|
||||
/// A row's quantity: what its placements add up to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum Quantity {
|
||||
/// None of its definitions has a quantity: its count says it all.
|
||||
#[default]
|
||||
Count,
|
||||
Pieces(f64),
|
||||
/// Millimetres.
|
||||
Length(f64),
|
||||
Unknown(Uncounted),
|
||||
}
|
||||
|
||||
/// Why a row's quantity isn't known.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Uncounted {
|
||||
/// Pieces and lengths can't be added (a placement without a quantity is one piece).
|
||||
Mixed,
|
||||
/// A definition's quantity doesn't evaluate: a parameter it names is in error (or missing from
|
||||
/// a damaged file).
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Evaluate a quantity expression with `doc`'s parameters.
|
||||
pub fn amount(doc: &Document, text: &str) -> Result<Amount> {
|
||||
amount_in(doc, &OnceCell::new(), text)
|
||||
}
|
||||
|
||||
/// [`amount`] with the parameters' values shared between calls, evaluated only when an expression
|
||||
/// names one.
|
||||
pub(super) fn amount_in(doc: &Document, values: &OnceCell<BTreeMap<String, Value>>, text: &str) -> Result<Amount> {
|
||||
let value = expr::eval_with(text, &|name| match values.get_or_init(|| doc.param_values().0).get(name) {
|
||||
Some(v) => Ok(*v),
|
||||
None if MASS_AND_VOLUME.contains(&name) => Err(mass_and_volume()),
|
||||
None => Err(DocError::Expr(format!("unknown parameter `{name}`"))),
|
||||
})?;
|
||||
let amount = match (value.len, value.ang) {
|
||||
(0, 0) => Amount::Pieces(value.v),
|
||||
(1, 0) => Amount::Length(value.v),
|
||||
(2, 0) => return Err(DocError::Invalid("not supported yet: area quantities".into())),
|
||||
(3, 0) => return Err(mass_and_volume()),
|
||||
_ => return Err(DocError::Invalid(format!("a quantity is a number of pieces or a length, not {}", value.describe()))),
|
||||
};
|
||||
let (Amount::Pieces(v) | Amount::Length(v)) = amount;
|
||||
if !(v > 0.0 && v <= MAX_AMOUNT) {
|
||||
return Err(DocError::Invalid(format!("a quantity is above 0 and at most {MAX_AMOUNT:e}")));
|
||||
}
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
fn mass_and_volume() -> DocError {
|
||||
DocError::Invalid("not supported yet: mass and volume quantities need the expression units of #4811".into())
|
||||
}
|
||||
|
||||
/// What placements add up to, from each one's entry in the definitions' amounts: none without a
|
||||
/// quantity (one piece), `Some(None)` when its quantity doesn't evaluate.
|
||||
pub(super) fn add_up<'a>(amounts: impl IntoIterator<Item = Option<&'a Option<Amount>>>) -> Quantity {
|
||||
let (mut pieces, mut length, mut typed) = (None, None, false);
|
||||
for amount in amounts {
|
||||
let (sum, n): (&mut Option<f64>, f64) = match amount {
|
||||
None => (&mut pieces, 1.0),
|
||||
Some(None) => return Quantity::Unknown(Uncounted::Invalid),
|
||||
Some(Some(Amount::Pieces(n))) => (&mut pieces, *n),
|
||||
Some(Some(Amount::Length(mm))) => (&mut length, *mm),
|
||||
};
|
||||
typed |= amount.is_some();
|
||||
*sum.get_or_insert(0.0) += n;
|
||||
}
|
||||
match (pieces, length) {
|
||||
_ if !typed => Quantity::Count,
|
||||
(Some(_), Some(_)) => Quantity::Unknown(Uncounted::Mixed),
|
||||
(None, Some(mm)) => Quantity::Length(mm),
|
||||
(n, None) => Quantity::Pieces(n.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::BomType::{Inseparable, Normal, Phantom, Purchased, Reference};
|
||||
use crate::{IDENTITY, Occurrence};
|
||||
|
||||
/// A new component placed once in `parent`, and that placement.
|
||||
fn add(doc: &mut Document, name: &str, parent: u64) -> (u64, u64) {
|
||||
let component = doc.add_component(Some(name), parent).unwrap();
|
||||
(component, doc.occurrences.last().unwrap().id)
|
||||
}
|
||||
|
||||
/// Another placement of `component` in `parent`.
|
||||
fn place(doc: &mut Document, component: u64, parent: u64) -> u64 {
|
||||
doc.add_occurrence(component, parent, IDENTITY).unwrap()
|
||||
}
|
||||
|
||||
/// A part property as a file holds it (reading never validates).
|
||||
fn set(doc: &mut Document, component: u64, key: &str, value: &str) {
|
||||
doc.components.iter_mut().find(|c| c.id == component).unwrap().metadata.0.insert(key.into(), json!(value));
|
||||
}
|
||||
|
||||
fn number(doc: &mut Document, component: u64, part_number: &str) {
|
||||
set(doc, component, "part_number", part_number);
|
||||
}
|
||||
|
||||
fn typed(doc: &mut Document, component: u64, bom_type: BomType) {
|
||||
set(doc, component, "bom_type", bom_type.as_str());
|
||||
}
|
||||
|
||||
/// What `request` lists for `doc` (none of whose components has bodies), with each row's members.
|
||||
fn listed(doc: &Document, request: Request) -> Result<Bom> {
|
||||
let state = ModelState::default();
|
||||
list_with(&mut Tree::new(doc, &state), &request, true)
|
||||
}
|
||||
|
||||
fn view(view: View) -> Request {
|
||||
Request { view, ..Request::default() }
|
||||
}
|
||||
|
||||
fn folded(view: View) -> Request {
|
||||
Request { view, fold: true, ..Request::default() }
|
||||
}
|
||||
|
||||
/// Each row, followed by the rows under it: item, names, type, count and each.
|
||||
fn rows(bom: &Bom) -> Vec<(String, String, BomType, u64, u64)> {
|
||||
bom.rows_deep().into_iter().map(|(item, r)| (item_text(&item), r.names.join("; "), r.bom_type, r.count, r.each)).collect()
|
||||
}
|
||||
|
||||
fn row(item: &str, names: &str, bom_type: BomType, count: u64, each: u64) -> (String, String, BomType, u64, u64) {
|
||||
(item.into(), names.into(), bom_type, count, each)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_and_grouping_words_are_the_commands() {
|
||||
for v in View::ALL {
|
||||
assert_eq!(View::parse(v.as_str()), Some(v));
|
||||
}
|
||||
for g in Group::ALL {
|
||||
assert_eq!(Group::parse(g.as_str()), Some(g));
|
||||
}
|
||||
assert_eq!((View::default().as_str(), Group::default().as_str()), ("flat", "part_number"), "what assembly.bom lists by default");
|
||||
for word in ["", "deep", "Flat", "part number"] {
|
||||
assert_eq!((View::parse(word), Group::parse(word)), (None, None), "{word}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_assembly_lists_nothing_and_weighs_nothing() {
|
||||
let mut doc = Document::default();
|
||||
let empty = listed(&doc, Request { mass: true, ..Request::default() }).unwrap();
|
||||
assert!(empty.rows.is_empty());
|
||||
assert_eq!(empty.total, Total { occurrences: 0, mass: Mass::Unknown(Unknown::NothingToWeigh) });
|
||||
assert_eq!(listed(&doc, Request::default()).unwrap().total.mass, Mass::NotAsked, "weighed only when asked");
|
||||
// A component that holds nothing, as the assembly listed; one that isn't there is refused.
|
||||
let (part, _) = add(&mut doc, "Part", 0);
|
||||
for v in View::ALL {
|
||||
assert!(listed(&doc, Request { source: part, view: v, ..Request::default() }).unwrap().rows.is_empty());
|
||||
}
|
||||
assert!(listed(&doc, Request { source: 999, ..Request::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_part_is_one_row_named_by_its_lowest_member() {
|
||||
let mut doc = Document::default();
|
||||
let (part, occurrence) = add(&mut doc, "Part", 0);
|
||||
for (v, stands_for) in [(View::PartsOnly, Holds::Own), (View::FirstLevel, Holds::All)] {
|
||||
let bom = listed(&doc, view(v)).unwrap();
|
||||
let [r] = bom.rows.as_slice() else { panic!("{bom:?}") };
|
||||
assert_eq!(r.key, RowKey { parents: vec![], member: Member { component: part, bom_type: Normal } });
|
||||
assert_eq!((r.item, r.count, r.each, r.stands_for, r.mass), (1, 1, 1, stands_for, Mass::NotAsked));
|
||||
assert_eq!((&r.components, &r.names, &r.members), (&vec![part], &vec!["Part".to_string()], &vec![vec![occurrence]]));
|
||||
assert!(r.children.is_empty() && r.materials.is_empty() && r.part_number.is_empty());
|
||||
assert_eq!(bom.total.occurrences, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_is_named_by_its_lowest_member_not_by_its_part_number() {
|
||||
let mut doc = Document::default();
|
||||
let (a, oa) = add(&mut doc, "A", 0);
|
||||
let (b, ob) = add(&mut doc, "B", 0);
|
||||
number(&mut doc, a, "P");
|
||||
number(&mut doc, b, "P");
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
let [r] = bom.rows.as_slice() else { panic!("{bom:?}") };
|
||||
assert_eq!((r.key.member.component, &r.components, &r.members, r.part_number.as_str()), (a, &vec![a, b], &vec![vec![oa], vec![ob]], "P"));
|
||||
// Renumbering the lower one: its row goes with it, and the other's row is named by the other.
|
||||
number(&mut doc, a, "Q");
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
let keys: Vec<(&str, u64)> = bom.rows.iter().map(|r| (r.part_number.as_str(), r.key.member.component)).collect();
|
||||
assert_eq!(keys, [("P", b), ("Q", a)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_part_number_counted_as_two_types_is_two_rows() {
|
||||
let mut doc = Document::default();
|
||||
let (a, _) = add(&mut doc, "Made", 0);
|
||||
let (b, _) = add(&mut doc, "Bought", 0);
|
||||
number(&mut doc, a, "P");
|
||||
number(&mut doc, b, "P");
|
||||
typed(&mut doc, b, Purchased);
|
||||
// A second placement of the made one, counted as bought: it joins the bought row.
|
||||
let second = place(&mut doc, a, 0);
|
||||
doc.occurrences.iter_mut().find(|o| o.id == second).unwrap().bom_type = Some(Purchased);
|
||||
for v in View::ALL {
|
||||
let bom = listed(&doc, view(v)).unwrap();
|
||||
assert_eq!(rows(&bom), [row("1", "Made", Normal, 1, 1), row("2", "Made; Bought", Purchased, 2, 2)], "{v:?}");
|
||||
let keys: Vec<Member> = bom.rows.iter().map(|r| r.key.member).collect();
|
||||
assert_eq!(keys, [Member { component: a, bom_type: Normal }, Member { component: a, bom_type: Purchased }]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phantom_in_a_phantom_promotes_what_it_holds_into_the_level_it_sits_in() {
|
||||
let mut doc = Document::default();
|
||||
let (kit, okit) = add(&mut doc, "Kit", 0);
|
||||
let (set_, oset) = add(&mut doc, "Set", kit);
|
||||
let oset2 = place(&mut doc, set_, kit);
|
||||
let (screw, oscrew) = add(&mut doc, "Screw", set_);
|
||||
let oscrew2 = place(&mut doc, screw, set_);
|
||||
let alone = place(&mut doc, screw, 0);
|
||||
typed(&mut doc, kit, Phantom);
|
||||
typed(&mut doc, set_, Phantom);
|
||||
let mut paths = vec![vec![okit, oset, oscrew], vec![okit, oset, oscrew2], vec![okit, oset2, oscrew], vec![okit, oset2, oscrew2], vec![alone]];
|
||||
paths.sort();
|
||||
for v in View::ALL {
|
||||
let bom = listed(&doc, view(v)).unwrap();
|
||||
assert_eq!(rows(&bom), [row("1", "Screw", Normal, 5, 5)], "{v:?}");
|
||||
assert_eq!(bom.rows[0].members, paths);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_counts_nowhere_and_its_own_list_lists_what_it_holds() {
|
||||
let mut doc = Document::default();
|
||||
let (fixture, ofixture) = add(&mut doc, "Fixture", 0);
|
||||
add(&mut doc, "Pin", fixture);
|
||||
typed(&mut doc, fixture, Reference);
|
||||
for v in View::ALL {
|
||||
let bom = listed(&doc, Request { mass: true, ..view(v) }).unwrap();
|
||||
assert_eq!((bom.rows.len(), bom.total.occurrences), (0, 0), "{v:?}");
|
||||
assert_eq!(bom.total.mass, Mass::Unknown(Unknown::NothingToWeigh));
|
||||
assert_eq!(rows(&listed(&doc, Request { source: fixture, view: v, ..Request::default() }).unwrap()), [row("1", "Pin", Normal, 1, 1)]);
|
||||
}
|
||||
// An occurrence counted as a reference, its component normal: the same.
|
||||
set(&mut doc, fixture, "bom_type", "normal");
|
||||
assert_eq!(rows(&listed(&doc, Request::default()).unwrap()), [row("1", "Pin", Normal, 1, 1)]);
|
||||
doc.occurrences.iter_mut().find(|o| o.id == ofixture).unwrap().bom_type = Some(Reference);
|
||||
assert!(listed(&doc, Request::default()).unwrap().rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_walks_64_levels_and_refuses_a_65th() {
|
||||
let mut doc = Document::default();
|
||||
let mut parent = 0;
|
||||
for level in 1..=64 {
|
||||
parent = add(&mut doc, &format!("L{level}"), parent).0;
|
||||
}
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
assert_eq!(rows(&bom), [row("1", "L64", Normal, 1, 1)]);
|
||||
assert_eq!(bom.rows[0].members[0].len(), 64);
|
||||
add(&mut doc, "L65", parent);
|
||||
assert!(listed(&doc, Request::default()).is_err(), "refused, never listed in part");
|
||||
// The first level only walks what the design places directly.
|
||||
assert_eq!(rows(&listed(&doc, view(View::FirstLevel)).unwrap()), [row("1", "L1", Normal, 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_placement_budget_covers_the_whole_list_folded_parts_included() {
|
||||
let mut doc = Document::default();
|
||||
let (a, _) = add(&mut doc, "A", 0);
|
||||
for _ in 1..1000 {
|
||||
place(&mut doc, a, 0);
|
||||
}
|
||||
let (b, _) = add(&mut doc, "B", a);
|
||||
for _ in 1..99 {
|
||||
place(&mut doc, b, a);
|
||||
}
|
||||
// 1,000 As holding 99 Bs each: 100,000 placements, as many as one list walks.
|
||||
let flat = listed(&doc, Request::default()).unwrap();
|
||||
assert_eq!((rows(&flat), flat.total.occurrences), (vec![row("1", "B", Normal, 99_000, 99_000)], 99_000));
|
||||
number(&mut doc, a, "A-1");
|
||||
let kit = listed(&doc, folded(View::PartsOnly)).unwrap();
|
||||
assert_eq!((rows(&kit), kit.total.occurrences), (vec![row("1", "A", Normal, 1000, 1000), row("1.1", "B", Normal, 99_000, 99)], 100_000));
|
||||
let (a_row, b_row) = (&kit.rows[0], &kit.rows[0].children[0]);
|
||||
assert_eq!(b_row.key, RowKey { parents: vec![a_row.key.member], member: Member { component: b, bom_type: Normal } });
|
||||
assert_eq!(a_row.stands_for, Holds::All, "a folded part's row is the kit");
|
||||
// One placement more, anywhere, is refused, folded or not: a folded part's contents are walked
|
||||
// under the list's budget, not one of their own.
|
||||
add(&mut doc, "C", 0);
|
||||
for request in [Request::default(), folded(View::PartsOnly), folded(View::FirstLevel)] {
|
||||
assert!(listed(&doc, request).is_err(), "{request:?}");
|
||||
}
|
||||
assert_eq!(listed(&doc, view(View::FirstLevel)).unwrap().total.occurrences, 1001, "the first level walks 1,001");
|
||||
}
|
||||
|
||||
/// Kits used to be compared as text (`"#12×1"`): a part without a part number, whose id is 12,
|
||||
/// read the same as a part numbered "#12", and a part numbered "P×1\nQ" the same as a P and a Q.
|
||||
#[test]
|
||||
fn kits_are_told_apart_by_what_they_hold_not_by_how_it_reads() {
|
||||
let mut doc = Document::default();
|
||||
let bolt = |doc: &mut Document, name: &str| {
|
||||
let (id, _) = add(doc, name, 0);
|
||||
number(doc, id, "BOLT");
|
||||
id
|
||||
};
|
||||
let (b1, b2, b3, b4) = (bolt(&mut doc, "Bolt 1"), bolt(&mut doc, "Bolt 2"), bolt(&mut doc, "Bolt 3"), bolt(&mut doc, "Bolt 4"));
|
||||
let (plain, _) = add(&mut doc, "Plain", b1);
|
||||
let (spelled, _) = add(&mut doc, "Spelled", b2);
|
||||
number(&mut doc, spelled, &format!("#{plain}"));
|
||||
let (pq, _) = add(&mut doc, "PQ", b3);
|
||||
number(&mut doc, pq, "P×1\nQ");
|
||||
let (p, _) = add(&mut doc, "P", b4);
|
||||
let (q, _) = add(&mut doc, "Q", b4);
|
||||
number(&mut doc, p, "P");
|
||||
number(&mut doc, q, "Q");
|
||||
let bolts = |doc: &Document| -> Vec<Vec<u64>> {
|
||||
let bom = listed(doc, folded(View::PartsOnly)).unwrap();
|
||||
bom.rows.iter().filter(|r| r.part_number == "BOLT").map(|r| r.components.clone()).collect()
|
||||
};
|
||||
let mut each: Vec<Vec<u64>> = bolts(&doc);
|
||||
each.sort();
|
||||
assert_eq!(each, [vec![b1], vec![b2], vec![b3], vec![b4]], "four kits, four rows");
|
||||
// The same parts by part number are one kit, whichever definitions they are.
|
||||
let b5 = bolt(&mut doc, "Bolt 5");
|
||||
let (p2, _) = add(&mut doc, "P again", b5);
|
||||
let (q2, _) = add(&mut doc, "Q again", b5);
|
||||
number(&mut doc, p2, "P");
|
||||
number(&mut doc, q2, "Q");
|
||||
assert!(bolts(&doc).contains(&vec![b4, b5]));
|
||||
let bom = listed(&doc, folded(View::PartsOnly)).unwrap();
|
||||
let both = bom.rows.iter().find(|r| r.components == [b4, b5]).unwrap();
|
||||
let inside: Vec<(&str, &[u64], u64, u64)> =
|
||||
both.children.iter().map(|c| (c.part_number.as_str(), c.components.as_slice(), c.each, c.count)).collect();
|
||||
assert_eq!(inside, [("P", &[p, p2][..], 1, 2), ("Q", &[q, q2][..], 1, 2)], "each bolt's, counted for both");
|
||||
// The same parts counted as another type are another kit: a bolt whose Q is bought is a row of its own.
|
||||
let b6 = bolt(&mut doc, "Bolt 6");
|
||||
let (p3, _) = add(&mut doc, "P third", b6);
|
||||
let (q3, bought) = add(&mut doc, "Q third", b6);
|
||||
number(&mut doc, p3, "P");
|
||||
number(&mut doc, q3, "Q");
|
||||
doc.occurrences.iter_mut().find(|o| o.id == bought).unwrap().bom_type = Some(Purchased);
|
||||
let each = bolts(&doc);
|
||||
assert!(each.contains(&vec![b4, b5]) && each.contains(&vec![b6]), "{each:?}");
|
||||
}
|
||||
|
||||
/// All levels walks under the list's one budget: 100,000 placements and 64 levels are listed, and
|
||||
/// one more of either is refused, never listed in part.
|
||||
#[test]
|
||||
fn all_levels_lists_at_each_cap_and_refuses_one_more() {
|
||||
let mut doc = Document::default();
|
||||
let (part, _) = add(&mut doc, "Part", 0);
|
||||
let base = doc.occurrences.iter().map(|o| o.id).max().unwrap_or(0);
|
||||
let placement =
|
||||
|id: u64| Occurrence { id, component: part, parent: 0, name: format!("Part:{id}"), transform: IDENTITY, grounded: false, bom_type: None };
|
||||
doc.occurrences.extend((1..MAX_PLACEMENTS as u64).map(|i| placement(base + i)));
|
||||
let all = listed(&doc, view(View::AllLevels)).unwrap();
|
||||
assert_eq!((all.rows.len(), all.total.occurrences), (1, MAX_PLACEMENTS as u64));
|
||||
doc.occurrences.push(placement(base + MAX_PLACEMENTS as u64));
|
||||
assert!(listed(&doc, view(View::AllLevels)).is_err(), "100,001 placements");
|
||||
let mut doc = Document::default();
|
||||
let mut parent = 0;
|
||||
for i in 0..MAX_PLACEMENT_DEPTH {
|
||||
parent = add(&mut doc, &format!("L{i}"), parent).0;
|
||||
}
|
||||
let all = listed(&doc, view(View::AllLevels)).unwrap();
|
||||
assert_eq!(all.rows_deep().last().map(|(item, _)| item.len()), Some(MAX_PLACEMENT_DEPTH));
|
||||
add(&mut doc, "Too deep", parent);
|
||||
assert!(listed(&doc, view(View::AllLevels)).is_err(), "65 levels");
|
||||
}
|
||||
|
||||
/// Two parts with one part number are one row only when what they hold is the same all the way
|
||||
/// down: here they differ two levels below, so in all levels they are two rows, each with its own.
|
||||
#[test]
|
||||
fn kits_are_compared_to_any_depth() {
|
||||
let mut doc = Document::default();
|
||||
let (a, _) = add(&mut doc, "A", 0);
|
||||
let (b, _) = add(&mut doc, "B", 0);
|
||||
let (x1, _) = add(&mut doc, "X1", a);
|
||||
let (x2, _) = add(&mut doc, "X2", b);
|
||||
let (bolt, _) = add(&mut doc, "Bolt", x1);
|
||||
for (id, part_number) in [(a, "P"), (b, "P"), (x1, "X"), (x2, "X"), (bolt, "BOLT")] {
|
||||
number(&mut doc, id, part_number);
|
||||
}
|
||||
let all = listed(&doc, view(View::AllLevels)).unwrap();
|
||||
assert_eq!(
|
||||
rows(&all),
|
||||
[
|
||||
row("1", "A", Normal, 1, 1),
|
||||
row("1.1", "X1", Normal, 1, 1),
|
||||
row("1.1.1", "Bolt", Normal, 1, 1),
|
||||
row("2", "B", Normal, 1, 1),
|
||||
row("2.1", "X2", Normal, 1, 1)
|
||||
],
|
||||
"two P rows, never one with children at each 0"
|
||||
);
|
||||
// The same contents two levels down: one row again.
|
||||
let (bolt2, _) = add(&mut doc, "Bolt 2", x2);
|
||||
number(&mut doc, bolt2, "BOLT");
|
||||
let all = listed(&doc, view(View::AllLevels)).unwrap();
|
||||
assert_eq!(rows(&all), [row("1", "A; B", Normal, 2, 2), row("1.1", "X1; X2", Normal, 2, 1), row("1.1.1", "Bolt; Bolt 2", Normal, 2, 1)]);
|
||||
}
|
||||
|
||||
/// What a folded part holds is listed as parts only lists it, in parts only and the first level,
|
||||
/// and isn't folded again: a numbered subassembly inside it is listed through its parts. In all
|
||||
/// levels it is listed in all levels again, so that subassembly is a row with its own rows.
|
||||
#[test]
|
||||
fn what_a_folded_part_holds_is_listed_as_parts_only_lists_it() {
|
||||
let mut doc = Document::default();
|
||||
let (machine, _) = add(&mut doc, "Machine", 0);
|
||||
let (gearbox, _) = add(&mut doc, "Gearbox", machine);
|
||||
let (shaft, _) = add(&mut doc, "Shaft", gearbox);
|
||||
place(&mut doc, shaft, gearbox);
|
||||
add(&mut doc, "Pin", gearbox);
|
||||
for (id, part_number) in [(machine, "M-1"), (gearbox, "GB-1"), (shaft, "SH-1")] {
|
||||
number(&mut doc, id, part_number);
|
||||
}
|
||||
for v in [View::PartsOnly, View::FirstLevel] {
|
||||
let bom = listed(&doc, folded(v)).unwrap();
|
||||
assert_eq!(rows(&bom), [row("1", "Machine", Normal, 1, 1), row("1.1", "Shaft", Normal, 2, 2), row("1.2", "Pin", Normal, 1, 1)], "{v:?}");
|
||||
}
|
||||
let all = listed(&doc, folded(View::AllLevels)).unwrap();
|
||||
let nested = [
|
||||
row("1", "Machine", Normal, 1, 1),
|
||||
row("1.1", "Gearbox", Normal, 1, 1),
|
||||
row("1.1.1", "Shaft", Normal, 2, 2),
|
||||
row("1.1.2", "Pin", Normal, 1, 1),
|
||||
];
|
||||
assert_eq!(rows(&all), nested);
|
||||
}
|
||||
|
||||
/// Bolt (purchased) holding a nut; Frame (inseparable) holding a plate, a purchased clip and a
|
||||
/// weld kit (phantom) of a rivet and a purchased insert; Fixture (reference) holding a pin; Set
|
||||
/// (phantom) holding a screw; Sub (normal, numbered, no bodies) holding a washer. Each is placed
|
||||
/// once.
|
||||
fn mixed() -> (Document, BTreeMap<&'static str, u64>) {
|
||||
let mut doc = Document::default();
|
||||
let mut at = BTreeMap::new();
|
||||
for (name, parent, bom_type) in [
|
||||
("Bolt", "", Purchased),
|
||||
("Nut", "Bolt", Normal),
|
||||
("Frame", "", Inseparable),
|
||||
("Plate", "Frame", Normal),
|
||||
("Clip", "Frame", Purchased),
|
||||
("Weld kit", "Frame", Phantom),
|
||||
("Rivet", "Weld kit", Normal),
|
||||
("Insert", "Weld kit", Purchased),
|
||||
("Fixture", "", Reference),
|
||||
("Pin", "Fixture", Normal),
|
||||
("Set", "", Phantom),
|
||||
("Screw", "Set", Normal),
|
||||
("Sub", "", Normal),
|
||||
("Washer", "Sub", Normal),
|
||||
] {
|
||||
let parent = doc.find_component(parent).unwrap_or(0);
|
||||
let (id, occurrence) = add(&mut doc, name, parent);
|
||||
typed(&mut doc, id, bom_type);
|
||||
at.insert(name, occurrence);
|
||||
}
|
||||
let sub = doc.find_component("Sub").unwrap();
|
||||
number(&mut doc, sub, "SUB-1");
|
||||
(doc, at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_of_finds_the_row_a_placement_is_counted_in() {
|
||||
let (doc, at) = mixed();
|
||||
let path = |names: &[&str]| -> Vec<u64> { names.iter().map(|n| at[*n]).collect() };
|
||||
let state = ModelState::default();
|
||||
let mut tree = Tree::new(&doc, &state);
|
||||
let name = |r: std::result::Result<&Row, Unlisted>| r.map(|r| r.names.join("; "));
|
||||
let flat = list_with(&mut tree, &Request::default(), true).unwrap();
|
||||
// Made for its rows alone, a list keeps no members.
|
||||
assert!(list(&mut tree, &Request::default()).unwrap().rows.iter().all(|r| r.members.is_empty()));
|
||||
let cases: &[(&[&str], std::result::Result<&str, Unlisted>)] = &[
|
||||
(&["Bolt"], Ok("Bolt")),
|
||||
(&["Bolt", "Nut"], Ok("Bolt")),
|
||||
(&["Frame"], Ok("Frame")),
|
||||
(&["Frame", "Plate"], Ok("Frame")),
|
||||
(&["Frame", "Clip"], Ok("Clip")),
|
||||
(&["Frame", "Weld kit"], Ok("Frame")),
|
||||
(&["Frame", "Weld kit", "Rivet"], Ok("Frame")),
|
||||
(&["Frame", "Weld kit", "Insert"], Ok("Insert")),
|
||||
(&["Fixture"], Err(Unlisted::Reference)),
|
||||
(&["Fixture", "Pin"], Err(Unlisted::Reference)),
|
||||
(&["Set"], Err(Unlisted::NoRow)),
|
||||
(&["Set", "Screw"], Ok("Screw")),
|
||||
(&["Sub"], Err(Unlisted::NoRow)),
|
||||
(&["Sub", "Washer"], Ok("Washer")),
|
||||
(&[], Err(Unlisted::NoRow)),
|
||||
(&["Nut"], Err(Unlisted::NotFound)),
|
||||
];
|
||||
for (names, found) in cases {
|
||||
assert_eq!(name(flat.row_of(&tree, &path(names))), found.map(String::from), "{names:?}");
|
||||
}
|
||||
assert_eq!(flat.row_of(&tree, &[999]).err(), Some(Unlisted::NotFound));
|
||||
assert_eq!(flat.row_of(&tree, &vec![at["Bolt"]; 65]).err(), Some(Unlisted::NotFound));
|
||||
let stands: Vec<(String, Holds)> = flat.rows.iter().map(|r| (r.names.join("; "), r.stands_for)).collect();
|
||||
let want = [
|
||||
("Bolt", Holds::All),
|
||||
("Clip", Holds::All),
|
||||
("Frame", Holds::AllButPurchased),
|
||||
("Insert", Holds::All),
|
||||
("Screw", Holds::Own),
|
||||
("Washer", Holds::Own),
|
||||
];
|
||||
assert_eq!(stands, want.map(|(n, h)| (n.to_string(), h)));
|
||||
|
||||
let top = list_with(&mut tree, &view(View::FirstLevel), true).unwrap();
|
||||
let cases: &[(&[&str], &str)] = &[(&["Sub", "Washer"], "Sub"), (&["Set", "Screw"], "Screw"), (&["Frame", "Clip"], "Frame")];
|
||||
for (names, found) in cases {
|
||||
assert_eq!(name(top.row_of(&tree, &path(names))), Ok(found.to_string()), "{names:?}");
|
||||
}
|
||||
assert!(top.rows.iter().all(|r| r.stands_for == Holds::All));
|
||||
|
||||
let kit = list_with(&mut tree, &folded(View::PartsOnly), true).unwrap();
|
||||
let sub = kit.row_of(&tree, &path(&["Sub"])).unwrap();
|
||||
let washer = kit.row_of(&tree, &path(&["Sub", "Washer"])).unwrap();
|
||||
assert_eq!((sub.names.join(""), sub.stands_for, washer.names.join("")), ("Sub".into(), Holds::All, "Washer".into()));
|
||||
assert_eq!(washer.key.parents, [sub.key.member]);
|
||||
}
|
||||
|
||||
/// Each placement is counted in one row or none, every view and grouping: the counts, the
|
||||
/// members and the total agree, and `row_of` finds each member's row.
|
||||
#[test]
|
||||
fn every_placement_is_in_one_row_or_none() {
|
||||
let (mut doc, _) = mixed();
|
||||
// More of the same: the subassembly and the bolt placed again, the washer twice in the subassembly.
|
||||
for name in ["Sub", "Bolt"] {
|
||||
let id = doc.find_component(name).unwrap();
|
||||
place(&mut doc, id, 0);
|
||||
}
|
||||
let (washer, sub) = (doc.find_component("Washer").unwrap(), doc.find_component("Sub").unwrap());
|
||||
place(&mut doc, washer, sub);
|
||||
let state = ModelState::default();
|
||||
let mut tree = Tree::new(&doc, &state);
|
||||
for v in View::ALL {
|
||||
for fold in [false, true] {
|
||||
for group in Group::ALL {
|
||||
let request = Request { view: v, fold, group, ..Request::default() };
|
||||
let bom = list_with(&mut tree, &request, true).unwrap();
|
||||
let deep = bom.rows_deep();
|
||||
let mut seen: Vec<&Vec<u64>> = deep.iter().flat_map(|(_, r)| &r.members).collect();
|
||||
assert_eq!(seen.len() as u64, bom.total.occurrences, "{request:?}");
|
||||
assert!(deep.iter().all(|(_, r)| r.count == r.members.len() as u64), "{request:?}");
|
||||
seen.sort();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len() as u64, bom.total.occurrences, "a placement in two rows: {request:?}");
|
||||
for (_, r) in &deep {
|
||||
for m in &r.members {
|
||||
assert_eq!(bom.row_of(&tree, m).map(|found| &found.key), Ok(&r.key), "{request:?} {m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_files_structure_is_listed_or_refused_never_followed_forever() {
|
||||
let mut doc = Document::default();
|
||||
let (a, _) = add(&mut doc, "A", 0);
|
||||
let ghost =
|
||||
|id, component, parent| Occurrence { id, component, parent, name: format!("{id}"), transform: IDENTITY, grounded: false, bom_type: None };
|
||||
// An occurrence of a component that isn't there has no row and counts nowhere.
|
||||
doc.occurrences.push(ghost(900, 999, 0));
|
||||
assert_eq!(rows(&listed(&doc, Request::default()).unwrap()), [row("1", "A", Normal, 1, 1)]);
|
||||
// A component placed inside itself: refused at the depth limit, never walked forever.
|
||||
doc.occurrences.push(ghost(901, a, a));
|
||||
assert!(listed(&doc, Request::default()).is_err());
|
||||
// At the first level A is one row, but its material and its mass are what it holds: both walk inside.
|
||||
assert!(listed(&doc, Request { mass: true, ..view(View::FirstLevel) }).is_err(), "weighing it walks inside");
|
||||
assert!(listed(&doc, view(View::FirstLevel)).is_err(), "naming its material walks inside");
|
||||
}
|
||||
|
||||
/// Moved from the engine's tests with the rule (it was `row_mass`), now with the reason.
|
||||
#[test]
|
||||
fn a_row_has_a_mass_only_when_its_definitions_are_weighed_and_agree() {
|
||||
let (steel, aluminium, surface, empty): (Weighed, Weighed, Weighed, Weighed) =
|
||||
(Some((392.5, 1)), Some((6.48, 1)), Some((0.0, 1)), Some((0.0, 0)));
|
||||
assert_eq!(agree(&[steel, steel]), Mass::Grams(392.5));
|
||||
assert_eq!(agree(&[steel, aluminium]), Mass::Unknown(Unknown::Differs));
|
||||
assert_eq!(agree(&[surface, surface]), Mass::Grams(0.0), "a body that weighs nothing (a surface) is weighed");
|
||||
assert_eq!(agree(&[surface, steel]), Mass::Unknown(Unknown::Differs));
|
||||
assert_eq!(agree(&[empty]), Mass::Unknown(Unknown::NothingToWeigh), "nothing to weigh");
|
||||
assert_eq!(agree(&[steel, empty]), Mass::Unknown(Unknown::Differs), "one weighs something, one nothing");
|
||||
assert_eq!(agree(&[empty, steel]), Mass::Unknown(Unknown::Differs), "whichever comes first");
|
||||
assert_eq!(agree(&[empty, empty]), Mass::Unknown(Unknown::NothingToWeigh));
|
||||
assert_eq!(agree(&[None]), Mass::Unknown(Unknown::Unmeasurable), "not measured");
|
||||
assert_eq!(agree(&[aluminium, None, steel]), Mass::Unknown(Unknown::Unmeasurable), "whichever definition it is");
|
||||
assert_eq!(agree(&[]), Mass::Unknown(Unknown::NothingToWeigh));
|
||||
}
|
||||
|
||||
/// What a row's part stands for leaves out a reference, and an inseparable part's purchased parts,
|
||||
/// which its list shows beside it; phantoms and inseparable parts inside are weighed through.
|
||||
#[test]
|
||||
fn what_adds_to_a_row_follows_what_it_stands_for() {
|
||||
for t in BomType::ALL {
|
||||
assert!(!adds(Holds::Own, t), "{t:?}");
|
||||
assert_eq!(adds(Holds::All, t), t != Reference, "{t:?}");
|
||||
assert_eq!(adds(Holds::AllButPurchased, t), !matches!(t, Reference | Purchased), "{t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `how` applied to the view `request` lists, its numbering saved as the engine saves it; whether
|
||||
/// anything changed.
|
||||
fn numbered(doc: &mut Document, request: Request, how: Numbering) -> bool {
|
||||
let state = ModelState::default();
|
||||
let Some(numbers) = super::number(&mut Tree::new(doc, &state), &request, how).unwrap() else { return false };
|
||||
let saved = &mut doc.assembly.item_numbers;
|
||||
saved.retain(|n| (n.source, n.view) != (numbers.source, numbers.view));
|
||||
saved.push(numbers);
|
||||
true
|
||||
}
|
||||
|
||||
/// [`record`] as the engine runs it after a change; whether anything changed.
|
||||
fn recorded(doc: &mut Document) -> bool {
|
||||
let state = ModelState::default();
|
||||
let Some(numbers) = record(&mut Tree::new(doc, &state)) else { return false };
|
||||
doc.assembly.item_numbers = numbers;
|
||||
true
|
||||
}
|
||||
|
||||
const RENUMBER: Numbering = Numbering::Renumber { start: 1, step: 1, order: Order::List };
|
||||
|
||||
/// Each row, followed by the rows under it: item, names, and its numbering flags as letters
|
||||
/// (provisional, locked, duplicate).
|
||||
fn items(bom: &Bom) -> Vec<(String, String, String)> {
|
||||
bom.rows_deep()
|
||||
.into_iter()
|
||||
.map(|(item, r)| {
|
||||
let flags = [(r.provisional, 'p'), (r.locked, 'l'), (r.duplicate, 'd')].iter().filter(|(on, _)| *on).map(|(_, c)| *c).collect();
|
||||
(item_text(&item), r.names.join("; "), flags)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn item(item: &str, names: &str, flags: &str) -> (String, String, String) {
|
||||
(item.into(), names.into(), flags.into())
|
||||
}
|
||||
|
||||
/// Parts A, B and C at the top, part numbers P1 to P3.
|
||||
fn three_parts() -> (Document, [(u64, u64); 3]) {
|
||||
let mut doc = Document::default();
|
||||
let parts = ["A", "B", "C"].map(|name| add(&mut doc, name, 0));
|
||||
for (i, (part, _)) in parts.iter().enumerate() {
|
||||
number(&mut doc, *part, &format!("P{}", i + 1));
|
||||
}
|
||||
(doc, parts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_a_view_saves_its_numbers_and_new_rows_take_the_next() {
|
||||
let (mut doc, [_, (b, ob), _]) = three_parts();
|
||||
// Never numbered: positions, provisional, and nothing saved by a change.
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
assert!(!bom.numbered);
|
||||
assert_eq!(items(&bom), [item("1", "A", "p"), item("2", "B", "p"), item("3", "C", "p")]);
|
||||
assert!(!recorded(&mut doc), "a design never numbered saves nothing");
|
||||
assert!(numbered(&mut doc, Request::default(), RENUMBER));
|
||||
assert!(!numbered(&mut doc, Request::default(), RENUMBER), "the same numbers again change nothing");
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
assert!(bom.numbered);
|
||||
assert_eq!(items(&bom), [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")]);
|
||||
// A new part, first by part number: the highest number + 1, listed by it, saved by the next change.
|
||||
let (d, _) = add(&mut doc, "D", 0);
|
||||
number(&mut doc, d, "P0");
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("4", "D", "p")));
|
||||
assert!(recorded(&mut doc));
|
||||
assert!(!recorded(&mut doc), "saved once");
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("4", "D", "")));
|
||||
// B removed leaves a gap, and back it has its number again.
|
||||
doc.occurrences.retain(|o| o.id != ob);
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("3", "C", ""), item("4", "D", "")]);
|
||||
assert!(!recorded(&mut doc), "a gap isn't filled");
|
||||
// Setting another row's number keeps B's: it isn't listed, but it is saved.
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![1], item: None, locked: Some(true) }));
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![1], item: None, locked: Some(false) }));
|
||||
let (e, _) = add(&mut doc, "E", 0);
|
||||
assert!(recorded(&mut doc));
|
||||
place(&mut doc, b, 0);
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
assert_eq!(items(&bom), [item("1", "A", ""), item("2", "B", ""), item("3", "C", ""), item("4", "D", ""), item("5", "E", "")]);
|
||||
assert_eq!(bom.rows.last().map(|r| r.key.member.component), Some(e));
|
||||
// A renumber closing the gaps in item order, or in the list's order (by part number).
|
||||
doc.occurrences.retain(|o| o.component != b);
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: 1, step: 1, order: Order::Item }));
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("3", "D", ""), item("4", "E", "")]);
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: 10, step: 5, order: Order::List }));
|
||||
assert_eq!(
|
||||
items(&listed(&doc, Request::default()).unwrap()),
|
||||
[item("10", "D", ""), item("15", "A", ""), item("20", "C", ""), item("25", "E", "")]
|
||||
);
|
||||
// The renumber forgot B: back, it is new.
|
||||
place(&mut doc, b, 0);
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("26", "B", "p")));
|
||||
}
|
||||
|
||||
/// A view numbered twice, which only a damaged file has: listing reads the first numbering, and
|
||||
/// recording drops the second, so a change never lists the view again for it.
|
||||
#[test]
|
||||
fn a_view_numbered_twice_keeps_its_first_numbering() {
|
||||
let (mut doc, _) = three_parts();
|
||||
assert!(numbered(&mut doc, Request::default(), RENUMBER));
|
||||
let mut second = doc.assembly.item_numbers[0].clone();
|
||||
second.items.iter_mut().for_each(|i| i.number += 10);
|
||||
doc.assembly.item_numbers.push(second);
|
||||
let first = [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")];
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), first);
|
||||
assert!(recorded(&mut doc));
|
||||
assert_eq!(doc.assembly.item_numbers.len(), 1);
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), first);
|
||||
assert!(!recorded(&mut doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_keeps_its_number_through_a_part_number_edit() {
|
||||
let (mut doc, [(a, _), _, _]) = three_parts();
|
||||
assert!(numbered(&mut doc, Request::default(), RENUMBER));
|
||||
number(&mut doc, a, "P9");
|
||||
assert!(!recorded(&mut doc), "no new row");
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")]);
|
||||
}
|
||||
|
||||
/// The defect the plan names: a renumber with locked rows never gives two rows one number.
|
||||
#[test]
|
||||
fn a_renumber_keeps_locked_numbers_and_never_gives_two_rows_one() {
|
||||
let (mut doc, _) = three_parts();
|
||||
let (d, _) = add(&mut doc, "D", 0);
|
||||
number(&mut doc, d, "P4");
|
||||
// Setting a number numbers the view first; one another row has is marked on both.
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![3], item: Some(2), locked: Some(true) }));
|
||||
assert_eq!(
|
||||
items(&listed(&doc, Request::default()).unwrap()),
|
||||
[item("1", "A", ""), item("2", "B", "d"), item("2", "C", "ld"), item("4", "D", "")]
|
||||
);
|
||||
for (start, step, want) in [(1, 1, ["1", "3", "2", "4"]), (2, 1, ["3", "4", "2", "5"]), (1, 2, ["1", "3", "2", "5"])] {
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start, step, order: Order::List }));
|
||||
let bom = listed(&doc, Request::default()).unwrap();
|
||||
let by_name: BTreeMap<String, (String, String)> = items(&bom).into_iter().map(|(i, n, f)| (n, (i, f))).collect();
|
||||
let got: Vec<&str> = ["A", "B", "C", "D"].iter().map(|n| by_name[*n].0.as_str()).collect();
|
||||
assert_eq!(got, want, "from {start} by {step}");
|
||||
assert!(bom.rows.iter().all(|r| !r.duplicate), "from {start} by {step}");
|
||||
assert_eq!(by_name["C"].1, "l");
|
||||
}
|
||||
// Unlocked, the next renumber moves it.
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![2], item: None, locked: Some(false) }));
|
||||
assert!(numbered(&mut doc, Request::default(), RENUMBER));
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "B", ""), item("3", "C", ""), item("4", "D", "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_levels_are_numbered_within_each_parent() {
|
||||
let mut doc = Document::default();
|
||||
let (frame, _) = add(&mut doc, "Frame", 0);
|
||||
let (bolt, _) = add(&mut doc, "Bolt", 0);
|
||||
number(&mut doc, frame, "F");
|
||||
number(&mut doc, bolt, "B");
|
||||
let (beam, _) = add(&mut doc, "Beam", frame);
|
||||
number(&mut doc, beam, "F1");
|
||||
let all = view(View::AllLevels);
|
||||
assert!(numbered(&mut doc, all, RENUMBER));
|
||||
assert_eq!(items(&listed(&doc, all).unwrap()), [item("1", "Bolt", ""), item("2", "Frame", ""), item("2.1", "Beam", "")]);
|
||||
// A plate in the frame is the frame's next; the design's parts-only view isn't numbered.
|
||||
let (plate, _) = add(&mut doc, "Plate", frame);
|
||||
number(&mut doc, plate, "F0");
|
||||
assert_eq!(items(&listed(&doc, all).unwrap()).last(), Some(&item("2.2", "Plate", "p")));
|
||||
assert!(recorded(&mut doc));
|
||||
assert_eq!(items(&listed(&doc, all).unwrap()).last(), Some(&item("2.2", "Plate", "")));
|
||||
assert!(!listed(&doc, Request::default()).unwrap().numbered);
|
||||
// Renumbered in the list's order, each level from the start.
|
||||
assert!(numbered(&mut doc, all, Numbering::Renumber { start: 5, step: 5, order: Order::List }));
|
||||
assert_eq!(
|
||||
items(&listed(&doc, all).unwrap()),
|
||||
[item("5", "Bolt", ""), item("10", "Frame", ""), item("10.5", "Plate", ""), item("10.10", "Beam", "")]
|
||||
);
|
||||
// A component's own list is numbered apart from the design's.
|
||||
let own = Request { source: frame, ..all };
|
||||
assert!(!listed(&doc, own).unwrap().numbered);
|
||||
assert!(numbered(&mut doc, own, RENUMBER));
|
||||
assert_eq!(items(&listed(&doc, own).unwrap()), [item("1", "Plate", ""), item("2", "Beam", "")]);
|
||||
assert_eq!(items(&listed(&doc, all).unwrap())[2], item("10.5", "Plate", ""));
|
||||
}
|
||||
|
||||
/// A numbering keeps the list options it was made with: the next change saves the rows they list.
|
||||
/// A list with other options reads the same numbers by row, the rest provisional.
|
||||
#[test]
|
||||
fn a_numbering_keeps_its_list_options_and_other_lists_read_its_numbers() {
|
||||
let (mut doc, [_, (b, _), _]) = three_parts();
|
||||
number(&mut doc, b, "P1");
|
||||
let by_definition = Request { group: Group::Definition, ..Request::default() };
|
||||
assert!(numbered(&mut doc, Request::default(), RENUMBER));
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A; B", ""), item("2", "C", "")]);
|
||||
assert_eq!(items(&listed(&doc, by_definition).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("3", "B", "p")]);
|
||||
assert!(!recorded(&mut doc), "the numbering lists by part number: nothing new");
|
||||
assert!(numbered(&mut doc, by_definition, Numbering::Set { row: vec![3], item: Some(7), locked: None }));
|
||||
let saved = &doc.assembly.item_numbers;
|
||||
assert_eq!((saved.len(), saved[0].group), (1, Group::Definition), "one numbering a view, with the options last used");
|
||||
assert_eq!(items(&listed(&doc, by_definition).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("7", "B", "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_refused_outside_their_range_and_rows_are_found_as_shown() {
|
||||
let (mut doc, _) = three_parts();
|
||||
let refused = |doc: &mut Document, how: Numbering| {
|
||||
let state = ModelState::default();
|
||||
super::number(&mut Tree::new(doc, &state), &Request::default(), how).unwrap_err().to_string()
|
||||
};
|
||||
for (start, step) in [(0, 1), (1, 0), (MAX_ITEM + 1, 1)] {
|
||||
assert!(refused(&mut doc, Numbering::Renumber { start, step, order: Order::List }).contains("1 to 999999"), "{start} by {step}");
|
||||
}
|
||||
assert!(refused(&mut doc, Numbering::Renumber { start: MAX_ITEM - 1, step: 1, order: Order::List }).contains("passes 999999"));
|
||||
assert!(refused(&mut doc, Numbering::Renumber { start: 1, step: MAX_ITEM, order: Order::List }).contains("passes 999999"));
|
||||
assert!(refused(&mut doc, Numbering::Set { row: vec![4], item: Some(1), locked: None }).contains("item 4"));
|
||||
assert!(refused(&mut doc, Numbering::Set { row: vec![1, 1], item: Some(1), locked: None }).contains("item 1.1"));
|
||||
assert!(refused(&mut doc, Numbering::Set { row: vec![1], item: Some(0), locked: None }).contains("1 to 999999"));
|
||||
assert!(doc.assembly.item_numbers.is_empty(), "nothing saved");
|
||||
// The highest number is taken; a renumber that would pass it is refused whole.
|
||||
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: MAX_ITEM - 2, step: 1, order: Order::List }));
|
||||
assert_eq!(listed(&doc, Request::default()).unwrap().rows.last().map(|r| r.item), Some(MAX_ITEM));
|
||||
}
|
||||
|
||||
/// A damaged file's numbers: one out of range is no number, and of a key saved twice the first
|
||||
/// holds. A component's numbering goes with it.
|
||||
#[test]
|
||||
fn saved_numbers_are_read_leniently_and_go_with_their_component() {
|
||||
let (mut doc, [(a, _), (b, _), (c, _)]) = three_parts();
|
||||
let key = |component| RowKey { parents: vec![], member: Member { component, bom_type: Normal } };
|
||||
let saved = |key, number| ItemNumber { key, number, locked: false };
|
||||
doc.assembly.item_numbers.push(ItemNumbers {
|
||||
source: 0,
|
||||
view: View::PartsOnly,
|
||||
group: Group::PartNumber,
|
||||
fold: false,
|
||||
purchased_contents: false,
|
||||
items: vec![saved(key(a), 4), saved(key(a), 9), saved(key(b), 0), saved(key(c), MAX_ITEM + 1)],
|
||||
});
|
||||
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("4", "A", ""), item("5", "B", "p"), item("6", "C", "p")]);
|
||||
let (sub, _) = add(&mut doc, "Sub", 0);
|
||||
add(&mut doc, "Inner", sub);
|
||||
assert!(numbered(&mut doc, Request { source: sub, ..Request::default() }, RENUMBER));
|
||||
assert_eq!(doc.assembly.item_numbers.len(), 2);
|
||||
doc.components.retain(|c| c.id != sub);
|
||||
doc.occurrences.retain(|o| o.component != sub && o.parent != sub);
|
||||
assert!(recorded(&mut doc));
|
||||
assert_eq!(doc.assembly.item_numbers.iter().map(|n| n.source).collect::<Vec<_>>(), [0], "the design's is kept");
|
||||
}
|
||||
|
||||
/// Numbers are saved in the design under `assembly.item_numbers`, and change no geometry.
|
||||
#[test]
|
||||
fn item_numbers_are_saved_and_are_part_properties() {
|
||||
let (mut doc, _) = three_parts();
|
||||
let before = doc.clone();
|
||||
assert!(numbered(&mut doc, Request { fold: true, ..view(View::AllLevels) }, RENUMBER));
|
||||
assert!(doc.differs_only_in_part_properties(&before));
|
||||
assert!(!doc.differs_only_in_part_properties(&doc.clone()), "no change is no part-properties change");
|
||||
let json = serde_json::to_value(&doc.assembly).unwrap();
|
||||
let first = &json["item_numbers"][0];
|
||||
assert_eq!(
|
||||
(&first["source"], &first["levels"], &first["group_by"], &first["group_children"]),
|
||||
(&json!(0), &json!("all"), &json!("part_number"), &json!(true))
|
||||
);
|
||||
assert_eq!(first["items"][0], json!({"member": {"component": doc.components[0].id, "bom_type": "normal"}, "number": 1}));
|
||||
// Through the whole design, as a save writes it: a design whose assembly holds only numbers keeps them.
|
||||
assert_eq!(Document::from_json(&doc.to_json()).unwrap().assembly, doc.assembly);
|
||||
assert!(serde_json::to_value(&before.assembly).unwrap().get("item_numbers").is_none(), "a design never numbered saves nothing new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quantity_is_pieces_or_a_length_with_the_designs_parameters() {
|
||||
let mut doc = Document::default();
|
||||
doc.set_param("span", "100", Some("mm"), None).unwrap();
|
||||
doc.set_param("packs", "3", Some(""), None).unwrap();
|
||||
let amount = |text: &str| super::amount(&doc, text);
|
||||
assert_eq!(amount("4").unwrap(), Amount::Pieces(4.0));
|
||||
assert_eq!(amount("packs * 4").unwrap(), Amount::Pieces(12.0));
|
||||
assert_eq!(amount("2.5 m").unwrap(), Amount::Length(2500.0));
|
||||
assert_eq!(amount("10 in").unwrap(), Amount::Length(254.0));
|
||||
assert_eq!(amount("2 * span + 50").unwrap(), Amount::Length(250.0), "a bare number beside a length is mm");
|
||||
let refused = |text: &str| amount(text).unwrap_err().to_string();
|
||||
assert!(refused("90 deg").contains("a number of pieces or a length"), "{}", refused("90 deg"));
|
||||
assert!(refused("span * span").contains("not supported yet: area"));
|
||||
for mass_or_volume in ["span^3", "50 g", "0.5 kg", "2 lb", "500 ml"] {
|
||||
assert!(refused(mass_or_volume).contains("not supported yet: mass and volume"), "{mass_or_volume}: {}", refused(mass_or_volume));
|
||||
}
|
||||
for out_of_range in ["0", "-1", "-2 m", "1e9 + 1", "1001 km"] {
|
||||
assert!(refused(out_of_range).contains("above 0"), "{out_of_range}: {}", refused(out_of_range));
|
||||
}
|
||||
assert_eq!(amount("1e9").unwrap(), Amount::Pieces(1e9), "the cap itself");
|
||||
assert!(refused("cable_length").contains("unknown parameter `cable_length`"));
|
||||
assert!(amount("").is_err() && amount("2 *").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rows_quantity_adds_up_its_placements() {
|
||||
let mut doc = Document::default();
|
||||
doc.set_param("cable_length", "2.5", Some("m"), None).unwrap();
|
||||
let (cable, _) = add(&mut doc, "Cable", 0);
|
||||
set(&mut doc, cable, "bom_quantity", "cable_length");
|
||||
place(&mut doc, cable, 0);
|
||||
place(&mut doc, cable, 0);
|
||||
let (pack, _) = add(&mut doc, "Pack", 0);
|
||||
set(&mut doc, pack, "bom_quantity", "4");
|
||||
place(&mut doc, pack, 0);
|
||||
add(&mut doc, "Plate", 0);
|
||||
// Two definitions of one part number: one with a length, one a piece.
|
||||
let (hose, _) = add(&mut doc, "Hose", 0);
|
||||
let (hose_b, _) = add(&mut doc, "Hose B", 0);
|
||||
number(&mut doc, hose, "H-1");
|
||||
number(&mut doc, hose_b, "H-1");
|
||||
set(&mut doc, hose, "bom_quantity", "500 mm");
|
||||
// A subassembly placed twice holding the pack once.
|
||||
let (kit, _) = add(&mut doc, "Kit", 0);
|
||||
place(&mut doc, kit, 0);
|
||||
place(&mut doc, pack, kit);
|
||||
let quantities = |doc: &Document, request: Request| -> Vec<(String, Quantity)> {
|
||||
listed(doc, request).unwrap().rows_deep().into_iter().map(|(_, r)| (r.names.join("; "), r.quantity)).collect()
|
||||
};
|
||||
let q = quantities(&doc, view(View::PartsOnly));
|
||||
let of = |q: &[(String, Quantity)], name: &str| q.iter().find(|(n, _)| n == name).map(|(_, q)| *q);
|
||||
assert_eq!(of(&q, "Cable"), Some(Quantity::Length(7500.0)), "{q:?}");
|
||||
assert_eq!(of(&q, "Pack"), Some(Quantity::Pieces(16.0)), "two of its own and one in each kit: {q:?}");
|
||||
assert_eq!(of(&q, "Plate"), Some(Quantity::Count));
|
||||
assert_eq!(of(&q, "Hose; Hose B"), Some(Quantity::Unknown(Uncounted::Mixed)));
|
||||
// The kit's row in the top level; its own pack listed under it in all levels.
|
||||
let q = quantities(&doc, view(View::AllLevels));
|
||||
assert_eq!(q.iter().filter(|(n, _)| n == "Pack").map(|(_, q)| *q).collect::<Vec<_>>(), [Quantity::Pieces(8.0), Quantity::Pieces(8.0)], "{q:?}");
|
||||
// A placement without a quantity beside packs counts one piece: a box of 4 and a loose one.
|
||||
let (boxed, _) = add(&mut doc, "Box", 0);
|
||||
let (loose, _) = add(&mut doc, "Loose", 0);
|
||||
number(&mut doc, boxed, "K-1");
|
||||
number(&mut doc, loose, "K-1");
|
||||
set(&mut doc, boxed, "bom_quantity", "4");
|
||||
assert_eq!(of(&quantities(&doc, view(View::PartsOnly)), "Box; Loose"), Some(Quantity::Pieces(5.0)));
|
||||
// A parameter deleted (a damaged file): that row is unknown, the list still works.
|
||||
doc.params.retain(|p| p.name != "cable_length");
|
||||
let q = quantities(&doc, view(View::PartsOnly));
|
||||
assert_eq!(of(&q, "Cable"), Some(Quantity::Unknown(Uncounted::Invalid)));
|
||||
assert_eq!(of(&q, "Pack"), Some(Quantity::Pieces(16.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_virtual_component_holding_something_is_named() {
|
||||
let mut doc = Document::default();
|
||||
let (grease, _) = add(&mut doc, "Grease", 0);
|
||||
let (frame, _) = add(&mut doc, "Frame", 0);
|
||||
let (bolt, _) = add(&mut doc, "Bolt", frame);
|
||||
assert!(doc.virtual_holding().is_empty());
|
||||
set_virtual(&mut doc, grease);
|
||||
assert!(doc.virtual_holding().is_empty(), "an empty virtual component holds nothing");
|
||||
place(&mut doc, bolt, grease);
|
||||
assert_eq!(doc.virtual_holding(), [grease]);
|
||||
set_virtual(&mut doc, frame);
|
||||
assert_eq!(doc.virtual_holding(), [grease, frame]);
|
||||
}
|
||||
|
||||
fn set_virtual(doc: &mut Document, component: u64) {
|
||||
doc.components.iter_mut().find(|c| c.id == component).unwrap().metadata.0.insert("virtual".into(), json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quantity_uses_its_parameters_as_any_expression_does() {
|
||||
let mut doc = Document::default();
|
||||
doc.set_param("span", "100", Some("mm"), None).unwrap();
|
||||
let (cable, _) = add(&mut doc, "Cable", 0);
|
||||
set(&mut doc, cable, "bom_quantity", "2 * span");
|
||||
assert_eq!(doc.param_users("span"), [crate::ParamUser::Quantity { component: cable, name: "Cable".into() }]);
|
||||
assert!(doc.remove_param("span").unwrap_err().to_string().contains("component Cable (quantity)"));
|
||||
assert_eq!(doc.rename_param("span", "width").unwrap(), 1);
|
||||
assert_eq!(doc.components[0].metadata.bom_quantity(), Some("2 * width"));
|
||||
assert_eq!(amount(&doc, "2 * width").unwrap(), Amount::Length(200.0));
|
||||
}
|
||||
@@ -11,6 +11,9 @@ pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solidcr
|
||||
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
|
||||
}
|
||||
|
||||
/// The longest face or edge name a saved model reference keeps, in bytes.
|
||||
pub const MAX_REFERENCE_NAME_BYTES: usize = 512;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFaceTarget {
|
||||
@@ -37,7 +40,7 @@ impl ConfigFaceTarget {
|
||||
if self.body.is_empty()
|
||||
|| self.body.len() > 128
|
||||
|| self.name.is_empty()
|
||||
|| self.name.len() > 512
|
||||
|| self.name.len() > MAX_REFERENCE_NAME_BYTES
|
||||
|| self.origin_feature == 0
|
||||
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
|
||||
{
|
||||
@@ -52,7 +55,7 @@ pub fn validate_face_target_json(target: &Value) -> Result<()> {
|
||||
if !(4..=5).contains(&fields.len())
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
@@ -90,7 +93,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
if !certificate.is_null()
|
||||
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|
||||
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|
||||
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
}))
|
||||
|
||||
@@ -204,7 +204,7 @@ fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)
|
||||
}
|
||||
|
||||
/// The most edge names a vertex reference keeps.
|
||||
const MAX_VERTEX_EDGES: usize = 16;
|
||||
pub(crate) const MAX_VERTEX_EDGES: usize = 16;
|
||||
|
||||
/// Where the edges named `names` meet: the end every one of them has, nearest `at` (two edges
|
||||
/// between the same two vertices share both). `None` when an edge is gone or they share no end.
|
||||
@@ -227,17 +227,32 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
|
||||
})
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let (first, rest) = ends.split_first()?;
|
||||
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
|
||||
first
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| p.is_finite() && rest.iter().all(|e| e.iter().any(|q| q.dist(*p) <= tol(*p))))
|
||||
.min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
|
||||
shared_ends(&ends).into_iter().min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
|
||||
}
|
||||
|
||||
/// How near two points of a body are the same point: a millionth of their distance from the
|
||||
/// origin, and at least a millionth of a millimetre.
|
||||
pub(crate) fn point_tolerance(p: Vec3) -> f64 {
|
||||
1e-6 * (1.0 + p.len())
|
||||
}
|
||||
|
||||
/// Where edges meet: the ends in `ends[0]` that every other list in `ends` has too (to
|
||||
/// [`point_tolerance`]), each once. Empty when they share none.
|
||||
pub(crate) fn shared_ends<E: AsRef<[Vec3]>>(ends: &[E]) -> Vec<Vec3> {
|
||||
let tol = point_tolerance;
|
||||
let Some((first, rest)) = ends.split_first() else { return Vec::new() };
|
||||
let mut shared: Vec<Vec3> = Vec::new();
|
||||
for p in first.as_ref().iter().copied() {
|
||||
let everywhere = rest.iter().all(|e| e.as_ref().iter().any(|q| q.dist(p) <= tol(p)));
|
||||
if p.is_finite() && everywhere && !shared.iter().any(|q| q.dist(p) <= tol(p)) {
|
||||
shared.push(p);
|
||||
}
|
||||
}
|
||||
shared
|
||||
}
|
||||
|
||||
/// A polyline that is straight: its line.
|
||||
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||
let d = (b - a).normalized()?;
|
||||
let len = (b - a).len();
|
||||
@@ -245,7 +260,7 @@ fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
}
|
||||
|
||||
/// A polyline on a circle: (centre, unit normal, radius).
|
||||
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
|
||||
pub(crate) fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return None;
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
//! The existing length display policy, shared by windowed and headless consumers.
|
||||
//!
|
||||
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
|
||||
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
|
||||
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
|
||||
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
|
||||
//! Values are stored in millimetres and displayed in any length unit of the expression unit table
|
||||
//! ([`UNITS`]): a design's five units and the others a drawing may give its measurements in. One
|
||||
//! table for both, so every length unit a design or a drawing accepts shows its own numbers. Other
|
||||
//! names retain the millimetre fallback. These functions format values, not validate them:
|
||||
//! non-finite inputs retain Rust's `NaN`, `inf` and `-inf` text. Callers that require finite
|
||||
//! measurements must validate them first.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{Kind, UNITS};
|
||||
use crate::{DocError, Result, expr};
|
||||
|
||||
/// Units per millimetre and the unit's name. Unknown names use millimetres.
|
||||
/// Units per millimetre and the unit's name, for a length unit of [`UNITS`]. Other names use
|
||||
/// millimetres.
|
||||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||||
match units {
|
||||
"cm" => (0.1, "cm"),
|
||||
"m" => (0.001, "m"),
|
||||
"in" => (1.0 / 25.4, "in"),
|
||||
"ft" => (1.0 / 304.8, "ft"),
|
||||
_ => (1.0, "mm"),
|
||||
match UNITS.iter().find(|(name, kind, _)| *name == units && *kind == Kind::Length) {
|
||||
Some(&(name, _, mm)) => (1.0 / mm, name),
|
||||
None => (1.0, "mm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
|
||||
/// `value` is already converted to the requested display unit, not a millimetre value.
|
||||
/// Inch labels (`in` or `inch`) retain a meaningful fourth digit without promoting floating-point
|
||||
/// noise. `value` is already converted to the requested display unit, not a millimetre value.
|
||||
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
|
||||
let base = usize::from(decimals.min(8));
|
||||
if units != "in" {
|
||||
if unit_scale(units).0 != unit_scale("in").0 {
|
||||
return base;
|
||||
}
|
||||
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
|
||||
@@ -182,6 +183,7 @@ fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_length, length_number, length_precision, unit_scale};
|
||||
use crate::expr::{Kind, UNITS};
|
||||
|
||||
#[test]
|
||||
fn all_design_units_keep_bare_and_suffixed_labels() {
|
||||
@@ -245,9 +247,26 @@ mod tests {
|
||||
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_length_unit_shows_its_own_numbers() {
|
||||
// Each length unit of the expression table, which a design's units and a drawing's unit
|
||||
// come from, never millimetres unless it is millimetres.
|
||||
for &(name, kind, mm) in UNITS {
|
||||
if kind != Kind::Length {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(unit_scale(name), (1.0 / mm, name), "{name}");
|
||||
let bare = if matches!(name, "in" | "inch") { "12.500" } else { "12.50" };
|
||||
assert_eq!(length_number(12.5 * mm, name, 2), bare, "{name}");
|
||||
assert_eq!(format_length(12.5 * mm, name, 2), format!("{bare} {name}"), "{name}");
|
||||
}
|
||||
assert_eq!(length_number(25.4 * 1.2345, "inch", 0), "1.2345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_display_units_keep_the_millimetre_fallback() {
|
||||
for units in ["", "inch", " in ", "IN", "unsupported"] {
|
||||
// Names that aren't a length unit of the expression table, angles included.
|
||||
for units in ["", " in ", "IN", "unsupported", "deg", "rad"] {
|
||||
assert_eq!(unit_scale(units), (1.0, "mm"), "{units}");
|
||||
assert_eq!(length_number(25.4, units, 2), "25.40", "{units}");
|
||||
assert_eq!(format_length(25.4, units, 2), "25.40 mm", "{units}");
|
||||
|
||||
@@ -468,12 +468,7 @@ impl EmbossFace {
|
||||
|
||||
/// Resolve this exact placement without expanding every assembly instance.
|
||||
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
let mut frame = Self::component_frame(doc, owner, &self.path)?;
|
||||
if let Some(offset) = doc.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
doc.body_frame_at(body, &self.path)
|
||||
}
|
||||
|
||||
/// Resolve one saved component path without expanding other occurrences.
|
||||
@@ -2768,6 +2763,12 @@ pub struct Document {
|
||||
/// it saves.
|
||||
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
|
||||
pub visibility: Visibility,
|
||||
/// The design's drawings (#3890), by name, then id ([`crate::drawing::in_design`]), each behind
|
||||
/// its own `Arc`, so an undo step shares every drawing it didn't change. Each is read by its own
|
||||
/// family's rules ([`crate::format::read`]), never as a field of the design; a container keeps
|
||||
/// each in its own entry.
|
||||
#[serde(skip_deserializing, skip_serializing_if = "Vec::is_empty")]
|
||||
pub drawings: Vec<std::sync::Arc<crate::drawing::Drawing>>,
|
||||
}
|
||||
|
||||
/// The browser's view of a design: items and folders hidden or shown, sketch profiles and
|
||||
@@ -2993,6 +2994,7 @@ impl Document {
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
drawings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3128,21 +3130,28 @@ impl Document {
|
||||
|
||||
/// This design with `other`'s part properties, or `None` when they are this design's
|
||||
/// already (then nothing is copied). The part properties are the fields that change no
|
||||
/// geometry: the design's metadata, and each component's metadata and free properties. A
|
||||
/// field that joins them is compared and copied here, and nowhere else.
|
||||
/// geometry: the design's metadata, each component's metadata and free properties, each
|
||||
/// placement's bill of materials type and the saved item numbers. A field that joins them is
|
||||
/// compared and copied here, and nowhere else.
|
||||
fn with_part_properties_of(&self, other: &Document) -> Option<Document> {
|
||||
let same = self.metadata == other.metadata
|
||||
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties);
|
||||
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties)
|
||||
&& self.occurrences.iter().zip(&other.occurrences).all(|(c, o)| c.bom_type == o.bom_type)
|
||||
&& self.assembly.item_numbers == other.assembly.item_numbers;
|
||||
if same {
|
||||
return None;
|
||||
}
|
||||
let mut out = self.clone();
|
||||
out.metadata.clone_from(&other.metadata);
|
||||
// Paired by position: if the components themselves differ, so do the designs.
|
||||
out.assembly.item_numbers.clone_from(&other.assembly.item_numbers);
|
||||
// Paired by position: if the components or placements themselves differ, so do the designs.
|
||||
for (c, o) in out.components.iter_mut().zip(&other.components) {
|
||||
c.metadata.clone_from(&o.metadata);
|
||||
c.properties.clone_from(&o.properties);
|
||||
}
|
||||
for (c, o) in out.occurrences.iter_mut().zip(&other.occurrences) {
|
||||
c.bom_type = o.bom_type;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -3158,6 +3167,32 @@ impl Document {
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// Only drawings changed: nothing to evaluate, as a drawing reads the model and never changes
|
||||
/// it. The same whole-Document proof as [`Document::differs_only_in_render`]; a model edit
|
||||
/// shares every drawing's `Arc`, so it costs nothing here.
|
||||
pub fn differs_only_in_drawings(&self, previous: &Document) -> bool {
|
||||
let shared =
|
||||
self.drawings.len() == previous.drawings.len() && self.drawings.iter().zip(&previous.drawings).all(|(a, b)| std::sync::Arc::ptr_eq(a, b));
|
||||
if shared || self.drawings == previous.drawings {
|
||||
return false;
|
||||
}
|
||||
let mut normalized = previous.clone();
|
||||
normalized.drawings = self.drawings.clone();
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// The design's drawing `id`.
|
||||
pub fn drawing(&self, id: u64) -> Option<&std::sync::Arc<crate::drawing::Drawing>> {
|
||||
self.drawings.iter().find(|d| d.id == id)
|
||||
}
|
||||
|
||||
/// Put `drawing` in, in place of the one with its id if there is one, in the drawings' order.
|
||||
pub fn put_drawing(&mut self, drawing: std::sync::Arc<crate::drawing::Drawing>) {
|
||||
self.drawings.retain(|d| d.id != drawing.id);
|
||||
self.drawings.push(drawing);
|
||||
crate::drawing::sort(&mut self.drawings);
|
||||
}
|
||||
|
||||
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
|
||||
/// else its component's, else its material's look, else the colour it was imported with.
|
||||
/// (Faces may have their own: `face_looks`.)
|
||||
@@ -3268,12 +3303,12 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
|
||||
self.legacy_body_component_with(body, feature, |f| self.feature(f).map(|f| f.component))
|
||||
}
|
||||
|
||||
/// Ownership in this evaluated timeline state, rather than a later transfer.
|
||||
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
self.model_body_component_with(body, |f| self.feature(f).map(|f| f.component))
|
||||
}
|
||||
|
||||
/// [`Document::model_body_component`] of every body of `state`, in its order, with the
|
||||
@@ -3298,17 +3333,25 @@ impl Document {
|
||||
/// in one pass: the timeline is looked up once, not once per body.
|
||||
pub fn body_owners(&self, state: &crate::ModelState) -> std::collections::HashSet<u64> {
|
||||
let features: std::collections::HashMap<u64, u64> = self.features.iter().rev().map(|f| (f.id, f.component)).collect();
|
||||
state
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
if b.transferred || b.source_name.is_some() {
|
||||
b.component
|
||||
} else {
|
||||
self.body_components.get(&b.name).copied().unwrap_or_else(|| features.get(&b.feature).copied().unwrap_or(0))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
state.bodies.iter().map(|b| self.model_body_component_with(b, |f| features.get(&f).copied())).collect()
|
||||
}
|
||||
|
||||
/// [`Document::model_body_component`]'s rule, written once: a body the evaluation transferred,
|
||||
/// or placed from a source, keeps the component it was given; any other is
|
||||
/// [`Document::legacy_body_component`]'s. `feature_component` finds a feature's component:
|
||||
/// the timeline for one body, a map built once for many ([`Document::body_owners`]).
|
||||
fn model_body_component_with(&self, body: &crate::ModelBody, feature_component: impl FnOnce(u64) -> Option<u64>) -> u64 {
|
||||
if body.transferred || body.source_name.is_some() {
|
||||
body.component
|
||||
} else {
|
||||
self.legacy_body_component_with(&body.name, body.feature, feature_component)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::legacy_body_component`]'s rule, written once: the component the body was moved
|
||||
/// to, else its feature's, else the root.
|
||||
fn legacy_body_component_with(&self, body: &str, feature: u64, feature_component: impl FnOnce(u64) -> Option<u64>) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| feature_component(feature).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Recorded transfers already converted geometry into the new local frame.
|
||||
@@ -3316,6 +3359,17 @@ impl Document {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
}
|
||||
|
||||
/// The frame `body`'s geometry is in, placed through the occurrence path `path` (empty: its
|
||||
/// component's own placement): the placement, then the body's offset. One placement, without
|
||||
/// expanding every assembly instance.
|
||||
pub fn body_frame_at(&self, body: &crate::ModelBody, path: &[u64]) -> Result<crate::Mat> {
|
||||
let mut frame = EmbossFace::component_frame(self, self.model_body_component(body), path)?;
|
||||
if let Some(offset) = self.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a
|
||||
//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs
|
||||
//! ([`DRAWING`]); format 1 is this record.
|
||||
//! A drawing (#3890): sheets of paper that draw a design. A drawing lives in the file of the design
|
||||
//! it draws ([`Document::drawings`](crate::Document::drawings)): a design container keeps each in
|
||||
//! its own entry, a plain JSON design inline. Each is a record of its own family,
|
||||
//! `"format": "solidcraft-drawing/N"`, versioned by the same code as designs ([`DRAWING`]), so a
|
||||
//! change to the record is a drawing change, not a design one.
|
||||
//!
|
||||
//! - **Sources** are the designs it draws: an id and the design file's path, relative to the
|
||||
//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked
|
||||
//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw
|
||||
//! it is not saved here.
|
||||
//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered
|
||||
//! at, the component it documents, and whether it is left out of the sheet count or of printing.
|
||||
//! The border is a copy of the profile it was made with, never a name, so a later built-in never
|
||||
//! changes a saved drawing.
|
||||
//! - Each drawing has an **id**, unique among its design's drawings, and a design lists its
|
||||
//! drawings by name, then id ([`in_design`]): nothing to merge when two people add drawings.
|
||||
//! - **Sources** are the designs it draws. For now a drawing draws the design it is in: at most one
|
||||
//! source, an id and nothing else. What the design was when the drawing last saw it is not saved
|
||||
//! here.
|
||||
//! - **Sheets**: the paper, a copy of its drawing border and of its title block with the text typed
|
||||
//! over its cells' values, the resolution its pictures are rendered at, the component it
|
||||
//! documents, and whether it is left out of the sheet count or of printing. The border and the
|
||||
//! block are copies of what they were made with, never names, so a later built-in never changes a
|
||||
//! saved drawing.
|
||||
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
|
||||
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
|
||||
//! interface's is. **Properties** follow [`Metadata`]'s rules.
|
||||
//! - The **font** its text is set in: a font family's name, never a file, so the drawing travels
|
||||
//! (none: the built-in font; [`solidcraft_sketch::check_family`]).
|
||||
//! - **Views** of its designs on its sheets, base and projected ([`view`]), and the **projection**
|
||||
//! (first or third angle) every projected view follows.
|
||||
//! - **Dimensions** on its views, each measuring named corners or edges of the view's model
|
||||
//! ([`dimension`]).
|
||||
//! - **Balloons** on its views, each pointing at a placed part by a named edge of it ([`balloon`]).
|
||||
//! - **Images** on its sheets, each file kept once however many places it sits ([`image`]).
|
||||
//! - **Revisions**: one revision record for the whole drawing ([`RevisionRecord`]), its rows in the
|
||||
//! order they were added, the newest last.
|
||||
//!
|
||||
//! The rules, which every later kind of record (views, annotations, tables) keeps too:
|
||||
//! - Every object has an id of [`crate::ids`]: random, so objects added side by side never take
|
||||
@@ -20,35 +34,54 @@
|
||||
//! never gives a removed object's id again ([`Drawing::allocate_id`]).
|
||||
//! - Every reference between records is an id and must resolve: a file with one that doesn't is
|
||||
//! damaged and refused, never patched.
|
||||
//! - 1 to [`MAX_SHEETS`] sheets and at most [`MAX_SOURCES`] sources; names of 1 to
|
||||
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
|
||||
//! - 1 to [`MAX_SHEETS`] sheets; names of 1 to [`MAX_NAME_BYTES`] bytes.
|
||||
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
|
||||
//! order, maps sorted.
|
||||
//! order, maps sorted, and lists of records in a fixed order (sheets as arranged; later
|
||||
//! records by id), never the order they were added in. Records listed by id read in that order
|
||||
//! whatever order a file lists them in: the order means nothing, so it is never damage.
|
||||
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
|
||||
//!
|
||||
//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the
|
||||
//! family's format check, every number in range, the upgrade, the typed record, then
|
||||
//! [`Drawing::validate`].
|
||||
//! [`Drawing::from_json`] reads a drawing in this order: at most [`MAX_DRAWING_BYTES`] of text,
|
||||
//! the family's format check, every number in range, the upgrade, the typed record with its views,
|
||||
//! dimensions, balloons and images by id, then [`Drawing::validate`] and each image's file
|
||||
//! ([`Drawing::check_image_files`]); [`Drawing::from_value`] the same from the format check on.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcraft_geom::cell::Projection;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
|
||||
use solidcraft_geom::revision::RevisionRecord;
|
||||
use solidcraft_geom::title_block::TitleBlock;
|
||||
use solidcraft_geom::{Aabb2, Vec2};
|
||||
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::{self, IdSource};
|
||||
use crate::{DocError, Metadata, Result, expr};
|
||||
|
||||
pub mod balloon;
|
||||
pub mod by_id;
|
||||
pub mod dimension;
|
||||
pub mod fields;
|
||||
pub mod image;
|
||||
pub mod template;
|
||||
pub mod view;
|
||||
|
||||
use balloon::Balloon;
|
||||
use by_id::ById;
|
||||
use dimension::Dimension;
|
||||
use image::{ImageFile, SheetImage};
|
||||
use view::View;
|
||||
|
||||
/// The most drawings a design may hold.
|
||||
pub const MAX_DRAWINGS: usize = 1000;
|
||||
/// The most sheets a drawing may have.
|
||||
pub const MAX_SHEETS: usize = 1000;
|
||||
/// The most designs a drawing may draw.
|
||||
pub const MAX_SOURCES: usize = 64;
|
||||
/// The longest name of a drawing or a sheet, in bytes.
|
||||
pub const MAX_NAME_BYTES: usize = 256;
|
||||
/// The longest path to a design, in bytes.
|
||||
pub const MAX_PATH_BYTES: usize = 4096;
|
||||
/// The longest language tag: what RFC 5646 asks every reader to take (§4.4.1).
|
||||
pub const MAX_LANGUAGE_CHARS: usize = 35;
|
||||
/// The largest drawing read, as JSON text.
|
||||
@@ -72,29 +105,54 @@ pub struct Drawing {
|
||||
/// otherwise.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub format_pending: Vec<String>,
|
||||
/// Unique among its design's drawings: a design container's entry for it is named by it.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The designs the drawing draws.
|
||||
/// The designs the drawing draws: for now at most one, the design it is in.
|
||||
pub sources: Vec<Source>,
|
||||
/// The drawing's properties and other tools' data, kept as they are.
|
||||
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
|
||||
pub properties: Metadata,
|
||||
/// The drawing's properties and other tools' data, kept as they are, behind an `Arc` as the
|
||||
/// sheets are, so an undo step shares them while they don't change.
|
||||
#[serde(default, skip_serializing_if = "no_properties")]
|
||||
pub properties: Arc<Metadata>,
|
||||
#[serde(default)]
|
||||
pub units: Units,
|
||||
/// First or third angle: what every projected view shows, and the title block's symbol.
|
||||
#[serde(default)]
|
||||
pub projection: Projection,
|
||||
/// The language of the drawing's content, a BCP 47 tag (`en-GB`).
|
||||
pub language: String,
|
||||
/// The font family its text is set in, by name; empty: the built-in font.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub font: String,
|
||||
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
|
||||
pub sheets: Vec<Arc<Sheet>>,
|
||||
/// By id ([`Drawing::add_views`]), each behind its own `Arc`, as the sheets are.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub views: Vec<Arc<View>>,
|
||||
/// By id ([`Drawing::add_dimensions`]), each behind its own `Arc`, as the views are.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dimensions: Vec<Arc<Dimension>>,
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub balloons: ById<Balloon>,
|
||||
/// The image files, once each however many places they sit.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub images: ById<ImageFile>,
|
||||
/// Where they sit on the sheets.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub sheet_images: ById<SheetImage>,
|
||||
/// Behind an `Arc` as the sheets are, so an undo step shares it while it doesn't change.
|
||||
#[serde(default, skip_serializing_if = "no_revisions")]
|
||||
pub revisions: Arc<RevisionRecord>,
|
||||
}
|
||||
|
||||
/// A design the drawing draws.
|
||||
/// A design the drawing draws: the design the drawing is in. Views and sheets name it by its id,
|
||||
/// so a drawing of other designs can add sources of another kind later.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Source {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// The design file: relative to the drawing's folder once the drawing is saved, else as given
|
||||
/// (`/` separates folders).
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// The length unit a drawing gives its measurements in.
|
||||
@@ -119,6 +177,14 @@ pub struct Sheet {
|
||||
pub sheet: drawing_sheet::Sheet,
|
||||
/// A copy of the drawing border's profile (`None`: no drawing border).
|
||||
pub border: Option<DrawingBorderProfile>,
|
||||
/// A copy of its title block's definition (`None`: no title block), on the drawing border's
|
||||
/// lower-right corner, so only a sheet with a border has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title_block: Option<TitleBlock>,
|
||||
/// Text shown in place of a title block cell's value, by the cell's name: the value is still
|
||||
/// worked out, so it can be shown beside it.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub title_block_overrides: BTreeMap<String, String>,
|
||||
/// The resolution its pictures (its shaded views) are rendered at, in dots per inch.
|
||||
#[serde(default = "default_dpi")]
|
||||
pub dpi: f64,
|
||||
@@ -149,49 +215,134 @@ pub struct Excluded {
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
/// Where the sheet's drawing goes, in paper millimetres: inside its drawing border, or the
|
||||
/// whole paper when it has none. Nothing on a sheet is saved against its border, so a change
|
||||
/// of paper regrows the border and its zones by itself.
|
||||
pub fn drawing_space(&self) -> Result<Aabb2> {
|
||||
match &self.border {
|
||||
Some(border) => Ok(border.layout(&self.sheet).map_err(from_sheet)?.drawing_border),
|
||||
None => Ok(Aabb2 { min: Vec2::ZERO, max: self.sheet.size_mm().map_err(from_sheet)? }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where its title block sits, in paper millimetres (none without one), once its drawing border
|
||||
/// and the block on it lay out on the paper: the rule a sheet is held to, as drawing them uses it.
|
||||
pub fn title_block_outline(&self) -> Result<Option<Aabb2>> {
|
||||
let border = self.border.as_ref().map(|border| border.layout(&self.sheet)).transpose().map_err(from_sheet)?;
|
||||
let Some(block) = &self.title_block else { return Ok(None) };
|
||||
let border = border.ok_or_else(|| DocError::Invalid("its title block sits on a drawing border, and it has none".into()))?;
|
||||
Ok(Some(block.layout(&border).map_err(|e| DocError::Invalid(e.to_string()))?.outline))
|
||||
}
|
||||
|
||||
/// Gives the sheet `block` (`None`: no title block). Text typed over a cell stays while `block`
|
||||
/// has a text cell of that name; answers the cells whose typed text is dropped.
|
||||
pub fn set_title_block(&mut self, block: Option<TitleBlock>) -> Vec<String> {
|
||||
let kept = |name: &String| fields::overridable(block.as_ref(), name);
|
||||
let lost = self.title_block_overrides.keys().filter(|name| !kept(name)).cloned().collect();
|
||||
self.title_block_overrides.retain(|name, _| kept(name));
|
||||
self.title_block = block;
|
||||
lost
|
||||
}
|
||||
}
|
||||
|
||||
/// What is on one of a drawing's sheets ([`Drawing::on_sheet`]), each kind by id.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OnSheet {
|
||||
pub views: Vec<u64>,
|
||||
/// The dimensions in its views.
|
||||
pub dimensions: Vec<u64>,
|
||||
/// The balloons in its views.
|
||||
pub balloons: Vec<u64>,
|
||||
/// The images placed on it.
|
||||
pub images: Vec<u64>,
|
||||
}
|
||||
|
||||
impl OnSheet {
|
||||
/// Nothing is on the sheet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.views.is_empty() && self.dimensions.is_empty() && self.balloons.is_empty() && self.images.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dpi() -> f64 {
|
||||
DEFAULT_DPI
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// A drawing of no designs yet, named `name` and written in `language`, with one sheet named
|
||||
/// `sheet`: the paper `paper` with a copy of `border`, its id from `id_source`.
|
||||
/// `sheet`: the paper `paper` with a copy of `border`. Its id, and then its sheet's, come from
|
||||
/// `id_source`, the drawing's differing from every one in `taken` (its design's drawings').
|
||||
pub fn new(
|
||||
name: &str,
|
||||
language: &str,
|
||||
sheet: &str,
|
||||
paper: drawing_sheet::Sheet,
|
||||
border: Option<DrawingBorderProfile>,
|
||||
taken: &BTreeSet<u64>,
|
||||
id_source: &mut IdSource,
|
||||
) -> Result<Drawing> {
|
||||
let id = id_source.next(&BTreeSet::new())?;
|
||||
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
|
||||
let id = id_source.next(taken)?;
|
||||
let sheet_id = id_source.next(&BTreeSet::from([id]))?;
|
||||
let first = Sheet {
|
||||
id: sheet_id,
|
||||
name: sheet.into(),
|
||||
sheet: paper,
|
||||
border,
|
||||
title_block: None,
|
||||
title_block_overrides: BTreeMap::new(),
|
||||
dpi: DEFAULT_DPI,
|
||||
documents: None,
|
||||
excluded: Excluded::default(),
|
||||
};
|
||||
let drawing = Drawing {
|
||||
format: DRAWING.format_string(DRAWING.current()),
|
||||
format_pending: DRAWING.pending_names(),
|
||||
id,
|
||||
name: name.into(),
|
||||
sources: Vec::new(),
|
||||
properties: Metadata::default(),
|
||||
properties: Arc::default(),
|
||||
units: Units::default(),
|
||||
projection: Projection::default(),
|
||||
language: language.into(),
|
||||
font: String::new(),
|
||||
sheets: vec![Arc::new(first)],
|
||||
views: Vec::new(),
|
||||
dimensions: Vec::new(),
|
||||
balloons: ById::default(),
|
||||
images: ById::default(),
|
||||
sheet_images: ById::default(),
|
||||
revisions: Arc::default(),
|
||||
};
|
||||
drawing.validate()?;
|
||||
Ok(drawing)
|
||||
}
|
||||
|
||||
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
|
||||
/// Read a drawing's JSON (any format this build knows; older ones are upgraded). A drawing
|
||||
/// that breaks a rule of the record is damaged and refused, never patched.
|
||||
pub fn from_json(s: &str) -> Result<Drawing> {
|
||||
if s.len() > MAX_DRAWING_BYTES {
|
||||
return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20)));
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
let v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
Drawing::from_value(v)
|
||||
}
|
||||
|
||||
/// Read a drawing from its JSON value, as a plain JSON design holds it: [`Drawing::from_json`]
|
||||
/// once the text is parsed.
|
||||
pub fn from_value(mut v: Value) -> Result<Drawing> {
|
||||
let (version, applied) = DRAWING.take_version(&mut v)?;
|
||||
DRAWING.check_numbers(&v, "properties")?;
|
||||
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
let mut d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
let (views, dimensions) = (std::mem::take(&mut d.views), std::mem::take(&mut d.dimensions));
|
||||
d.add_views(views);
|
||||
d.add_dimensions(dimensions);
|
||||
d.balloons.put_in_order();
|
||||
d.images.put_in_order();
|
||||
d.sheet_images.put_in_order();
|
||||
d.validate().map_err(damaged)?;
|
||||
d.check_image_files().map_err(damaged)?;
|
||||
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
|
||||
}
|
||||
|
||||
@@ -211,21 +362,87 @@ impl Drawing {
|
||||
id_source.next(&self.ids().collect())
|
||||
}
|
||||
|
||||
/// The position of the sheet `named` names: by id, else by a name only one sheet has.
|
||||
pub fn sheet_index(&self, named: &str) -> Result<usize> {
|
||||
if let Some(i) = ids::decode(named).and_then(|id| self.sheets.iter().position(|s| s.id == id)) {
|
||||
return Ok(i);
|
||||
}
|
||||
let mut called = self.sheets.iter().enumerate().filter(|(_, s)| s.name == named).map(|(i, _)| i);
|
||||
match (called.next(), called.next()) {
|
||||
(Some(i), None) => Ok(i),
|
||||
(Some(_), Some(_)) => Err(DocError::Invalid(format!("more than one sheet is called `{named}`: give its id"))),
|
||||
(None, _) => Err(DocError::Invalid(format!("no sheet `{named}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` names an object a sheet shows: a view, a dimension or a balloon.
|
||||
pub fn has_object(&self, id: u64) -> bool {
|
||||
self.view(id).is_some() || self.dimension(id).is_some() || self.balloons.by_id(id).is_some()
|
||||
}
|
||||
|
||||
/// What is on sheet `id`: its views and the dimensions and balloons in them, and the images placed on it.
|
||||
/// Each later kind of record on a sheet adds its own here, and goes with the sheet
|
||||
/// ([`Drawing::remove_sheet`]).
|
||||
pub fn on_sheet(&self, id: u64) -> OnSheet {
|
||||
// The views are listed by id, so the sheet's are too, and so are its images.
|
||||
let mut on = self.with_contents(self.views.iter().filter(|v| v.sheet == id).map(|v| v.id).collect());
|
||||
on.images = self.sheet_images.iter().filter(|i| i.sheet == id).map(|i| i.id).collect();
|
||||
on
|
||||
}
|
||||
|
||||
/// `views`, listed by id, with the dimensions and balloons in them. An image sits on a sheet, never in a
|
||||
/// view, so there are none.
|
||||
pub fn with_contents(&self, views: Vec<u64>) -> OnSheet {
|
||||
let dimensions = self.dimensions.iter().filter(|d| views.binary_search(&d.view).is_ok()).map(|d| d.id).collect();
|
||||
let balloons = self.balloons.iter().filter(|b| views.binary_search(&b.view).is_ok()).map(|b| b.id).collect();
|
||||
OnSheet { views, dimensions, balloons, images: Vec::new() }
|
||||
}
|
||||
|
||||
/// Takes sheet `id` out with everything on it ([`Drawing::on_sheet`]), and answers what that
|
||||
/// was. A view on another sheet projected from one of its views is left without its parent,
|
||||
/// which [`Drawing::validate`] refuses: what becomes of it is the caller's to decide first.
|
||||
pub fn remove_sheet(&mut self, id: u64) -> OnSheet {
|
||||
let on = self.on_sheet(id);
|
||||
self.sheets.retain(|s| s.id != id);
|
||||
self.remove_objects(&on);
|
||||
on
|
||||
}
|
||||
|
||||
/// Takes `objects` out, each list by id: the one rule a sheet's delete and a selection's delete
|
||||
/// remove by, with the image files nothing uses any more. As in [`Drawing::remove_sheet`], what is
|
||||
/// left must still keep the record's rules.
|
||||
pub fn remove_objects(&mut self, objects: &OnSheet) {
|
||||
self.views.retain(|v| objects.views.binary_search(&v.id).is_err());
|
||||
self.dimensions.retain(|d| objects.dimensions.binary_search(&d.id).is_err());
|
||||
self.balloons.retain(|b| objects.balloons.binary_search(&b.id).is_err());
|
||||
self.sheet_images.retain(|i| objects.images.binary_search(&i.id).is_err());
|
||||
self.drop_unused_images();
|
||||
}
|
||||
|
||||
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
|
||||
/// own here.
|
||||
fn ids(&self) -> impl Iterator<Item = u64> {
|
||||
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
|
||||
std::iter::once(self.id)
|
||||
.chain(self.sources.iter().map(|s| s.id))
|
||||
.chain(self.sheets.iter().map(|s| s.id))
|
||||
.chain(self.views.iter().map(|v| v.id))
|
||||
.chain(self.dimensions.iter().map(|d| d.id))
|
||||
.chain(self.balloons.iter().map(|b| b.id))
|
||||
.chain(self.images.iter().map(|i| i.id))
|
||||
.chain(self.sheet_images.iter().map(|i| i.id))
|
||||
.chain(self.revisions.ids())
|
||||
}
|
||||
|
||||
/// The record's rules (the module's notes): what reading a file checks, and what every edit
|
||||
/// The record's rules (the module's notes): what reading a drawing checks, and what every edit
|
||||
/// keeps.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
|
||||
require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
|
||||
require(self.sources.len() <= 1, || "a drawing draws only the design it is in".into())?;
|
||||
check_name(&self.name, "the drawing's")?;
|
||||
require(is_language_tag(&self.language), || {
|
||||
format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)")
|
||||
})?;
|
||||
solidcraft_sketch::check_family(&self.font).map_err(|e| DocError::Invalid(format!("the drawing's font: {e}")))?;
|
||||
if let Units::Fixed { unit } = &self.units {
|
||||
// A length unit of the expression unit table, written as the table writes it.
|
||||
let length = unit.trim() == unit.as_str() && expr::unit_info(unit).is_some_and(|(kind, _)| kind == expr::Kind::Length);
|
||||
@@ -236,25 +453,33 @@ impl Drawing {
|
||||
require(id != 0, || "an object has the id 0, which is never one".into())?;
|
||||
require(seen.insert(id), || format!("two objects have the id {}", ids::encode(id)))?;
|
||||
}
|
||||
for source in &self.sources {
|
||||
let path = &source.path;
|
||||
require(!path.is_empty() && path.len() <= MAX_PATH_BYTES && !path.contains('\0'), || {
|
||||
format!("source {}: a design's path is 1 to {MAX_PATH_BYTES} bytes, without NUL", ids::encode(source.id))
|
||||
})?;
|
||||
}
|
||||
for sheet in &self.sheets {
|
||||
self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?;
|
||||
}
|
||||
Ok(())
|
||||
// One record for the whole drawing until sheets keep their own (#3874).
|
||||
require(!self.revisions.per_sheet, || "a drawing keeps one revision record for all its sheets".into())?;
|
||||
self.revisions.validate().map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
self.check_views()?;
|
||||
self.check_dimensions()?;
|
||||
self.check_balloons()?;
|
||||
self.check_images()
|
||||
}
|
||||
|
||||
/// A sheet's own rules, and that the source it documents is one of the drawing's.
|
||||
fn check_sheet(&self, sheet: &Sheet) -> Result<()> {
|
||||
check_name(&sheet.name, "its")?;
|
||||
sheet.sheet.size_mm().map_err(from_sheet)?;
|
||||
if let Some(border) = &sheet.border {
|
||||
border.validate().map_err(from_sheet)?;
|
||||
sheet.title_block_outline()?;
|
||||
if let Some(block) = &sheet.title_block {
|
||||
fields::check_block(block)?;
|
||||
}
|
||||
// Each override names a text cell of its block, and its text is a cell's.
|
||||
for name in sheet.title_block_overrides.keys() {
|
||||
let named = || format!("its title block has no text cell `{name}` to override");
|
||||
require(fields::overridable(sheet.title_block.as_ref(), name), named)?;
|
||||
}
|
||||
let texts = sheet.title_block_overrides.values().map(String::as_str);
|
||||
solidcraft_geom::cell::check_text(texts).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
|
||||
match sheet.documents {
|
||||
Some(SheetComponent { source, .. }) => {
|
||||
@@ -264,6 +489,89 @@ impl Drawing {
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a message names this drawing: by its name and id.
|
||||
pub fn label(&self) -> String {
|
||||
format!("drawing {:?} ({})", self.name, ids::encode(self.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A design's drawings as read, each checked on its own already: in their order (by name, then id)
|
||||
/// and keeping [`check_in_design`]'s rules.
|
||||
pub fn in_design(drawings: Vec<Drawing>) -> Result<Vec<Arc<Drawing>>> {
|
||||
let mut drawings: Vec<_> = drawings.into_iter().map(Arc::new).collect();
|
||||
sort(&mut drawings);
|
||||
check_in_design(&drawings)?;
|
||||
Ok(drawings)
|
||||
}
|
||||
|
||||
/// Put a design's drawings in their order: by name, then id. Nothing stores the order, so two
|
||||
/// people adding drawings have nothing to merge.
|
||||
pub fn sort(drawings: &mut [Arc<Drawing>]) {
|
||||
drawings.sort_by(|a, b| (&a.name, a.id).cmp(&(&b.name, b.id)));
|
||||
}
|
||||
|
||||
/// The rules for a design's drawings: at most [`MAX_DRAWINGS`], and no two with one id.
|
||||
pub fn check_in_design(drawings: &[Arc<Drawing>]) -> Result<()> {
|
||||
check_count(drawings.len())?;
|
||||
let mut seen = BTreeSet::new();
|
||||
drawings.iter().try_for_each(|d| take_id(&mut seen, d.id))
|
||||
}
|
||||
|
||||
/// A drawing takes `id`, which no drawing before it in its design (`seen`) has.
|
||||
fn take_id(seen: &mut BTreeSet<u64>, id: u64) -> Result<()> {
|
||||
require(seen.insert(id), || format!("two drawings have the id {}", ids::encode(id)))
|
||||
}
|
||||
|
||||
/// A design of `count` drawings keeps the cap: what a reader checks before it reads any of them.
|
||||
pub fn check_count(count: usize) -> Result<()> {
|
||||
require(count <= MAX_DRAWINGS, || format!("a design holds at most {MAX_DRAWINGS} drawings"))
|
||||
}
|
||||
|
||||
/// Whether the drawing has no properties, so its file leaves them out.
|
||||
fn no_properties(properties: &Arc<Metadata>) -> bool {
|
||||
properties.is_empty()
|
||||
}
|
||||
|
||||
/// Adds `more` to `records`, each where its id puts it: the order every list of records after the
|
||||
/// sheets keeps (the module's rule), whatever order they come in.
|
||||
fn add_by_id<T>(records: &mut Vec<Arc<T>>, more: impl IntoIterator<Item = Arc<T>>, id: impl Fn(&T) -> u64) {
|
||||
records.extend(more);
|
||||
records.sort_by_key(|r| id(r));
|
||||
}
|
||||
|
||||
/// Whether `records` are in the order [`add_by_id`] keeps.
|
||||
fn in_id_order<T>(records: &[Arc<T>], id: impl Fn(&T) -> u64) -> bool {
|
||||
records.is_sorted_by_key(|r| id(r))
|
||||
}
|
||||
|
||||
fn no_revisions(revisions: &Arc<RevisionRecord>) -> bool {
|
||||
**revisions == RevisionRecord::default()
|
||||
}
|
||||
|
||||
/// `doc` without the drawings that break a rule, and a line naming each one left out and the
|
||||
/// rule. Reading refuses a design whole for one such drawing, so a copy that must open again (an
|
||||
/// autosave, a crash report's design) keeps the rest: the first [`MAX_DRAWINGS`], the first of
|
||||
/// two with one id, and only those keeping their own rules and small enough to save.
|
||||
pub fn openable(doc: &crate::Document) -> (Cow<'_, crate::Document>, Vec<String>) {
|
||||
let (mut seen, mut left_out) = (BTreeSet::new(), Vec::new());
|
||||
let kept: Vec<_> = doc
|
||||
.drawings
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
// Its own rules and its size before its id: a drawing left out takes no id from a later one.
|
||||
let checked =
|
||||
check_count(seen.len() + 1).and_then(|()| d.validate()).and_then(|()| d.to_json().map(drop)).and_then(|()| take_id(&mut seen, d.id));
|
||||
checked.map_err(|e| left_out.push(format!("{} left out: {e}", d.label()))).is_ok()
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if left_out.is_empty() {
|
||||
return (Cow::Borrowed(doc), left_out);
|
||||
}
|
||||
let mut doc = doc.clone();
|
||||
doc.drawings = kept;
|
||||
(Cow::Owned(doc), left_out)
|
||||
}
|
||||
|
||||
/// `Ok` when `ok`, else the rule `broken` names.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! A drawing's balloons (W26, #3874): each points at a part placed in its view's model, by an edge
|
||||
//! of it, to number that part on the drawing. A balloon saves what it points at by name, never a
|
||||
//! picked point, as a dimension does ([`super::dimension`]): when the model changes it follows the
|
||||
//! part, or says what it lost, and it never moves to another part.
|
||||
//!
|
||||
//! - **The part** it numbers is the occurrence its edge's body is placed through: the full path,
|
||||
//! from the top. A body of the design itself is not a placed part, so no balloon points at one.
|
||||
//! - **The arrow** ends on the middle of its edge, whichever way the kernel runs the edge.
|
||||
//! - **The balloon** sits at a place on paper, in millimetres from its view's centre, so it moves
|
||||
//! with the view and not with the model.
|
||||
//!
|
||||
//! The number a balloon shows comes from a parts list, once parts lists are placed on sheets.
|
||||
//!
|
||||
//! The rules, beside the record's: a balloon is on one of the drawing's views; its edge reference
|
||||
//! keeps a dimension's rules, with an occurrence path of 1 to [`crate::MAX_PLACEMENT_DEPTH`] ids; its place
|
||||
//! is on paper ([`is_paper_coordinate`]); at most [`MAX_BALLOONS`] balloons, listed by id.
|
||||
//!
|
||||
//! [`Document::resolve_balloons`] finds each balloon's edge again as a dimension's is found
|
||||
//! ([`super::dimension::Status`]): by name, strictly, and what it can't decide it says.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::drawing_view::is_paper_coordinate;
|
||||
use solidcraft_geom::paper::MAX_SIDE_MM;
|
||||
|
||||
use super::by_id::Record;
|
||||
use super::dimension::{Lookup, ModelEdge, Status, check_edge, edge_candidates};
|
||||
use super::{Drawing, require};
|
||||
use crate::{DocError, Document, Model, Result, ids};
|
||||
|
||||
/// The most balloons a drawing may have.
|
||||
pub const MAX_BALLOONS: usize = 10_000;
|
||||
|
||||
/// One balloon of a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Balloon {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// The view it points into and moves with.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub view: u64,
|
||||
/// The edge its arrow points at. Its occurrence path is the part the balloon numbers.
|
||||
pub edge: ModelEdge,
|
||||
/// The balloon's centre, in millimetres of paper from its view's centre.
|
||||
pub at: [f64; 2],
|
||||
}
|
||||
|
||||
impl Balloon {
|
||||
/// The part it numbers: the occurrences its edge's body is placed through, from the top.
|
||||
pub fn occurrence(&self) -> &[u64] {
|
||||
&self.edge.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Balloon {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The balloons' rules (the module's notes). The views are checked first, so they are in id
|
||||
/// order here.
|
||||
pub(super) fn check_balloons(&self) -> Result<()> {
|
||||
self.balloons.check(MAX_BALLOONS, "balloon", |b| self.check_balloon(b))
|
||||
}
|
||||
|
||||
fn check_balloon(&self, b: &Balloon) -> Result<()> {
|
||||
require(self.view(b.view).is_some(), || format!("it is on view {}, which the drawing doesn't have", ids::encode(b.view)))?;
|
||||
check_edge(&b.edge)?;
|
||||
require(!b.edge.path.is_empty(), || "it points at a placed part: its edge's body is placed through an occurrence".into())?;
|
||||
let [x, y] = b.at;
|
||||
require(is_paper_coordinate(x) && is_paper_coordinate(y), || format!("its place is at most {MAX_SIDE_MM} mm on paper"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A balloon as the model has it now ([`Document::resolve_balloons`]): never saved.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Resolved {
|
||||
pub status: Status,
|
||||
/// Where its arrow ends, the middle of its edge, in the design's frame: when the edge was found.
|
||||
pub point: Option<Vec3>,
|
||||
/// Its edge was found by exact history, as one edge.
|
||||
pub exact: bool,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The balloons `wanted` of `drawing` (one of this design's drawings) as `model` (this
|
||||
/// design's) has them now: each one's edge found again by name, and the middle of it. A body is
|
||||
/// looked up and its edge names worked out once for the call, however many balloons name it.
|
||||
/// `Err` only for an id the drawing doesn't have.
|
||||
pub fn resolve_balloons(&self, model: &Model, drawing: &Drawing, wanted: &[u64]) -> Result<Vec<Resolved>> {
|
||||
let mut lookup = Lookup::new(self, model);
|
||||
wanted
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let b = drawing.balloons.by_id(*id).ok_or_else(|| DocError::Unknown(format!("balloon {}", ids::encode(*id))))?;
|
||||
Ok(match lookup.edges(&b.edge) {
|
||||
// A split edge has no one middle: the pieces are listed, and none is chosen.
|
||||
Ok(found) if !found.whole => {
|
||||
Resolved { status: Status::Ambiguous(edge_candidates(&b.edge.body, &found)), point: None, exact: false }
|
||||
}
|
||||
Ok(found) => {
|
||||
let point = found.pieces.first().and_then(|(_, line)| crate::joints::edge::middle(line)).map(|(p, _)| p);
|
||||
let status = if point.is_some() { Status::Ok } else { Status::Lost(format!("edge `{}` has no length", b.edge.name)) };
|
||||
Resolved { status, point, exact: found.exact }
|
||||
}
|
||||
Err(status) => Resolved { status, point: None, exact: false },
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "balloon_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::Source;
|
||||
use crate::drawing::view::{View, ViewKind, ViewPlace};
|
||||
use crate::drawing_view::{ViewDirection, ViewOverrides};
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const SEED: u64 = 3874;
|
||||
|
||||
fn edge(name: &str, path: Vec<u64>) -> ModelEdge {
|
||||
ModelEdge { body: "Bolt".into(), origin_feature: 4, name: name.into(), path }
|
||||
}
|
||||
|
||||
/// A drawing of one design on one A3 sheet, its front view at (150, 150), and on the view two
|
||||
/// balloons: one on a bolt placed straight in the design (occurrence 3), one on a bolt inside a
|
||||
/// sub-assembly (occurrence 7 in 3). Their ids, in the order they were added.
|
||||
fn ballooned() -> (Drawing, [u64; 2]) {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Bolted", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let source = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id: source });
|
||||
let (view, sheet) = (d.allocate_id(&mut id_source).unwrap(), d.sheets[0].id);
|
||||
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
d.add_views([Arc::new(View {
|
||||
id: view,
|
||||
sheet,
|
||||
kind,
|
||||
place: ViewPlace::At([150.0, 150.0]),
|
||||
settings: ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() },
|
||||
reference_shown_percent: 0.0,
|
||||
})]);
|
||||
let mut added = [0; 2];
|
||||
for ((path, at), id) in [(vec![3], [40.0, 25.0]), (vec![3, 7], [-35.5, 60.0])].into_iter().zip(&mut added) {
|
||||
*id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.balloons.add([Arc::new(Balloon { id: *id, view, edge: edge("F1:side|F2:head", path), at })]);
|
||||
}
|
||||
d.validate().unwrap();
|
||||
(d, added)
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Balloon `id` in a drawing's file.
|
||||
fn balloon<'a>(v: &'a mut Value, id: &str) -> &'a mut Value {
|
||||
v["balloons"].as_array_mut().unwrap().iter_mut().find(|x| x["id"] == id).unwrap()
|
||||
}
|
||||
|
||||
/// Why `d`'s file, changed by `edit`, is refused, or "read".
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
/// A balloon saves its view, its edge by name with the part's occurrence path, and its place, and
|
||||
/// nothing worked out. Balloons save by id and read back as saved, the same bytes again; a file
|
||||
/// that lists them in another order reads by id. An older drawing reads with none, and a drawing
|
||||
/// with none writes none.
|
||||
#[test]
|
||||
fn balloons_save_what_they_point_at_and_read_back_as_saved() {
|
||||
let (d, added) = ballooned();
|
||||
let [placed, nested] = added.map(ids::encode);
|
||||
let view = ids::encode(d.views[0].id);
|
||||
let mut v = file(&d);
|
||||
assert_eq!(
|
||||
*balloon(&mut v, &nested),
|
||||
json!({
|
||||
"id": nested, "view": view,
|
||||
"edge": {"body": "Bolt", "origin_feature": 4, "name": "F1:side|F2:head", "path": [3, 7]},
|
||||
"at": [-35.5, 60.0]
|
||||
})
|
||||
);
|
||||
assert_eq!(balloon(&mut v, &placed)["edge"]["path"], json!([3]));
|
||||
assert_eq!(d.balloons.by_id(added[1]).unwrap().occurrence(), [3, 7], "the part it numbers is its edge's path");
|
||||
let listed: Vec<u64> = v["balloons"].as_array().unwrap().iter().map(|x| ids::decode(x["id"].as_str().unwrap()).unwrap()).collect();
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_eq!(listed, by_id);
|
||||
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!(reopened.to_json().unwrap(), d.to_json().unwrap());
|
||||
let mut other_order = v.clone();
|
||||
other_order["balloons"].as_array_mut().unwrap().reverse();
|
||||
assert_eq!(Drawing::from_json(&other_order.to_string()).unwrap(), d);
|
||||
let mut unsorted = d.clone();
|
||||
unsorted.balloons.reverse();
|
||||
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its balloons by id");
|
||||
let mut none = d.clone();
|
||||
none.balloons.clear();
|
||||
assert_eq!(file(&none).get("balloons"), None, "no balloons, none written");
|
||||
let older = json!({
|
||||
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
|
||||
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
|
||||
});
|
||||
assert!(Drawing::from_json(&older.to_string()).unwrap().balloons.is_empty());
|
||||
}
|
||||
|
||||
/// What reading refuses, each rule by name, on both sides of every cap: the view a balloon is on,
|
||||
/// its edge's names, feature and path (a placed part's: 1 to 64 occurrences, none of them 0), its
|
||||
/// place on paper, its id, how many there are, and fields it doesn't know.
|
||||
#[test]
|
||||
fn the_balloons_rules_are_kept_on_reading() {
|
||||
let (d, added) = ballooned();
|
||||
let [placed, nested] = added.map(ids::encode);
|
||||
let rule = |id: &str, rule: &str| format!("damaged drawing: balloon {id}: {rule}");
|
||||
let nothing = ids::encode(99);
|
||||
let on = format!("it is on view {nothing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["view"] = json!(nothing)), rule(&placed, &on));
|
||||
// The edge keeps a dimension's reference rules.
|
||||
let names = "a name in a reference is 1 to 512 bytes, without control characters";
|
||||
let body = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["body"] = json!(name));
|
||||
let edge_name = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["name"] = json!(name));
|
||||
for fits in ["x".repeat(512), "é".repeat(256)] {
|
||||
assert_eq!([body(&fits), edge_name(&fits)], ["read", "read"], "{} bytes", fits.len());
|
||||
}
|
||||
for broken in ["x".repeat(513), String::new(), "two\nlines".into()] {
|
||||
assert_eq!([body(&broken), edge_name(&broken)], [rule(&nested, names), rule(&nested, names)], "{} bytes", broken.len());
|
||||
}
|
||||
let feature = rule(&placed, "a reference names the feature that made its body");
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["edge"]["origin_feature"] = json!(0)), feature);
|
||||
// A placed part's path: 1 to 64 occurrences, none of them 0. A design's own body is no part.
|
||||
let path = |p: Value| refused(&d, |v| balloon(v, &nested)["edge"]["path"] = p);
|
||||
let deep = rule(&nested, "an occurrence path is at most 64 occurrences, none of them 0");
|
||||
let own = rule(&nested, "it points at a placed part: its edge's body is placed through an occurrence");
|
||||
assert_eq!(
|
||||
[path(json!([3])), path(json!(vec![3; 64])), path(json!(vec![3; 65])), path(json!([3, 0])), path(json!([]))],
|
||||
["read".to_string(), "read".into(), deep.clone(), deep, own.clone()]
|
||||
);
|
||||
let no_path = refused(&d, |v| {
|
||||
balloon(v, &nested)["edge"].as_object_mut().unwrap().remove("path");
|
||||
});
|
||||
assert_eq!(no_path, own, "no path");
|
||||
// Its place is on paper.
|
||||
let paper = rule(&placed, "its place is at most 5000 mm on paper");
|
||||
let at = |at: [f64; 2]| refused(&d, |v| balloon(v, &placed)["at"] = json!(at));
|
||||
assert_eq!([at([5000.0, -5000.0]), at([5000.001, 0.0]), at([0.0, -5000.001])], ["read".to_string(), paper.clone(), paper]);
|
||||
assert!(refused(&d, |v| balloon(v, &placed)["at"] = json!([1.0])).starts_with("damaged drawing: "), "two numbers");
|
||||
// Its id is the drawing's: never another object's.
|
||||
let view_id = ids::encode(d.views[0].id);
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["id"] = json!(view_id)), format!("damaged drawing: two objects have the id {view_id}"));
|
||||
// Fields it doesn't know, at every level.
|
||||
let unknown: [fn(&mut Value, &str); 3] = [
|
||||
|v, id| balloon(v, id)["item"] = json!(3),
|
||||
|v, id| balloon(v, id)["edge"]["point"] = json!([0.0, 0.0, 0.0]),
|
||||
|v, id| balloon(v, id)["shape"] = json!("circle"),
|
||||
];
|
||||
for (i, edit) in unknown.into_iter().enumerate() {
|
||||
assert!(refused(&d, |v| edit(v, &placed)).starts_with("damaged drawing: "), "{i}");
|
||||
}
|
||||
// At most MAX_BALLOONS balloons.
|
||||
let many = |n: usize| {
|
||||
let mut v = file(&d);
|
||||
let one = balloon(&mut v, &placed).clone();
|
||||
let copies = (1..=n as u64).map(|i| {
|
||||
let mut copy = one.clone();
|
||||
copy["id"] = json!(ids::encode(i));
|
||||
copy
|
||||
});
|
||||
v["balloons"] = Value::Array(copies.collect());
|
||||
Drawing::from_json(&v.to_string()).map(|d| d.balloons.len()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(many(MAX_BALLOONS), Ok(MAX_BALLOONS));
|
||||
assert_eq!(many(MAX_BALLOONS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_BALLOONS} balloons")));
|
||||
}
|
||||
|
||||
/// A sheet's balloons are its views' and go with it; a new id never takes a balloon's.
|
||||
#[test]
|
||||
fn a_sheets_balloons_go_with_it() {
|
||||
let (mut d, added) = ballooned();
|
||||
let sheet = d.sheets[0].id;
|
||||
let on = d.on_sheet(sheet);
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_eq!(on.balloons, by_id);
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
for _ in 0..64 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
assert!(!added.contains(&id), "a new id never takes a balloon's");
|
||||
}
|
||||
let second = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheets.push(Arc::new(crate::drawing::Sheet { id: second, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }));
|
||||
assert_eq!(d.remove_sheet(sheet), on);
|
||||
d.validate().unwrap();
|
||||
assert!(d.balloons.is_empty() && d.views.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! A drawing's list of one kind of record: each behind its own `Arc`, so an undo step shares what it
|
||||
//! didn't change, and by id, whatever order they came in. It holds what every such list does, so a
|
||||
//! kind's own file holds only its own rules.
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::require;
|
||||
use crate::{DocError, Result, ids};
|
||||
|
||||
/// A record a drawing lists by its id.
|
||||
pub trait Record {
|
||||
fn id(&self) -> u64;
|
||||
}
|
||||
|
||||
/// The records of one kind, by id. It reads as the `Vec` it is, and a file holds them as one.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ById<T>(Vec<Arc<T>>);
|
||||
|
||||
impl<T> Default for ById<T> {
|
||||
fn default() -> Self {
|
||||
ById(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for ById<T> {
|
||||
type Target = Vec<Arc<T>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for ById<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a ById<T> {
|
||||
type Item = &'a Arc<T>;
|
||||
type IntoIter = std::slice::Iter<'a, Arc<T>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ById<T> {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Record> ById<T> {
|
||||
/// Adds `more`, each where its id puts it.
|
||||
pub fn add(&mut self, more: impl IntoIterator<Item = Arc<T>>) {
|
||||
self.0.extend(more);
|
||||
self.put_in_order();
|
||||
}
|
||||
|
||||
/// Puts what a file listed in another order where each id puts it.
|
||||
pub(super) fn put_in_order(&mut self) {
|
||||
self.0.sort_by_key(|record| record.id());
|
||||
}
|
||||
|
||||
/// Where record `id` is in the list, found by halves.
|
||||
pub fn index_of(&self, id: u64) -> Option<usize> {
|
||||
self.0.binary_search_by_key(&id, |record| record.id()).ok()
|
||||
}
|
||||
|
||||
/// Record `id`.
|
||||
pub fn by_id(&self, id: u64) -> Option<&T> {
|
||||
self.index_of(id).and_then(|at| self.0.get(at)).map(|record| &**record)
|
||||
}
|
||||
|
||||
/// The list's rules, each named by `word`: at most `most` records, by id, each keeping `rule`.
|
||||
pub(super) fn check(&self, most: usize, word: &str, rule: impl Fn(&T) -> Result<()>) -> Result<()> {
|
||||
require(self.0.len() <= most, || format!("a drawing has at most {most} {word}s"))?;
|
||||
require(self.0.is_sorted_by_key(|record| record.id()), || format!("a drawing lists its {word}s by id"))?;
|
||||
self.0.iter().try_for_each(|record| rule(record).map_err(|e| DocError::Invalid(format!("{word} {}: {e}", ids::encode(record.id())))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "by_id_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Thing(u64);
|
||||
|
||||
impl Record for Thing {
|
||||
fn id(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn things(ids: &[u64]) -> ById<Thing> {
|
||||
let mut list = ById::default();
|
||||
list.add(ids.iter().map(|id| Arc::new(Thing(*id))));
|
||||
list
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_go_where_their_ids_put_them_and_are_found_there() {
|
||||
let mut list = things(&[30, 10]);
|
||||
list.add([Arc::new(Thing(20))]);
|
||||
assert_eq!(list.iter().map(|t| t.0).collect::<Vec<_>>(), [10, 20, 30]);
|
||||
assert_eq!((list.index_of(20), list.by_id(30).map(|t| t.0)), (Some(1), Some(30)));
|
||||
assert_eq!((list.index_of(25), list.by_id(25)), (None, None));
|
||||
assert!(ById::<Thing>::default().is_empty() && !list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_holds_the_plain_list() {
|
||||
let list = things(&[1, 2]);
|
||||
assert_eq!(serde_json::to_value(&list).unwrap(), json!([1, 2]));
|
||||
assert_eq!(serde_json::from_value::<ById<Thing>>(json!([1, 2])).unwrap(), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rules_name_the_kind_and_the_record() {
|
||||
let fine = |_: &Thing| Ok(());
|
||||
things(&[1, 2]).check(2, "thing", fine).unwrap();
|
||||
assert!(things(&[1, 2, 3]).check(2, "thing", fine).is_err_and(|e| e.to_string().contains("at most 2 things")));
|
||||
let mut out_of_order = things(&[1, 2]);
|
||||
out_of_order.reverse();
|
||||
assert!(out_of_order.check(2, "thing", fine).is_err_and(|e| e.to_string().contains("lists its things by id")));
|
||||
out_of_order.put_in_order();
|
||||
out_of_order.check(2, "thing", fine).unwrap();
|
||||
let odd = |t: &Thing| require(t.0.is_multiple_of(2), || "it is odd".into());
|
||||
let e = things(&[2, 3]).check(2, "thing", odd).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("thing {}: it is odd", ids::encode(3))), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::Source;
|
||||
use crate::drawing::view::{View, ViewKind, ViewPlace};
|
||||
use crate::drawing_view::{ViewDirection, ViewOverrides};
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const SEED: u64 = 4896;
|
||||
/// The body every reference here is on, and the feature that made it.
|
||||
const BODY: &str = "Box";
|
||||
const FEATURE: u64 = 7;
|
||||
|
||||
fn corner(edges: &[&str]) -> ModelPoint {
|
||||
ModelPoint { body: BODY.into(), origin_feature: FEATURE, edges: edges.iter().map(|e| e.to_string()).collect(), centre: false, path: Vec::new() }
|
||||
}
|
||||
|
||||
fn edge(name: &str) -> ModelEdge {
|
||||
ModelEdge { body: BODY.into(), origin_feature: FEATURE, name: name.into(), path: Vec::new() }
|
||||
}
|
||||
|
||||
/// A drawing of one design on one A3 sheet, its front view at 1:2 at (150, 150), and on the view a
|
||||
/// linear dimension between two corners, an edge's length and an angle between two edges: their
|
||||
/// ids, in the order they were added.
|
||||
fn measured() -> (Drawing, [u64; 3]) {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Box", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let source = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id: source });
|
||||
let (view, sheet) = (d.allocate_id(&mut id_source).unwrap(), d.sheets[0].id);
|
||||
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
let settings = ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() };
|
||||
d.add_views([Arc::new(View { id: view, sheet, kind, place: ViewPlace::At([150.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
|
||||
let kinds = [
|
||||
DimensionKind::Linear {
|
||||
a: corner(&["F1:front|F1:bottom", "F1:front|F1:left"]),
|
||||
b: corner(&["F1:front|F1:bottom", "F1:front|F1:right"]),
|
||||
direction: LinearDirection::Horizontal,
|
||||
reading: Reading::Projected,
|
||||
},
|
||||
DimensionKind::Length { edge: edge("F1:front|F1:top"), direction: LinearDirection::Aligned, reading: Reading::True },
|
||||
DimensionKind::Angle { a: edge("F1:front|F1:top"), b: edge("F2:chamfer|F1:front"), reading: Reading::Projected, other_side: true },
|
||||
];
|
||||
let texts = [TextPlace::Centred, TextPlace::At([0.0, -20.0]), TextPlace::At([12.5, 30.0])];
|
||||
let mut added = [0; 3];
|
||||
for ((kind, text), id) in kinds.into_iter().zip(texts).zip(&mut added) {
|
||||
*id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.add_dimensions([Arc::new(Dimension { id: *id, view, kind, offset_mm: -10.0, text, decimals: 2, override_text: None, last_good: None })]);
|
||||
}
|
||||
d.validate().unwrap();
|
||||
(d, added)
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Dimension `id` in a drawing's file.
|
||||
fn dim<'a>(v: &'a mut Value, id: &str) -> &'a mut Value {
|
||||
v["dimensions"].as_array_mut().unwrap().iter_mut().find(|x| x["id"] == id).unwrap()
|
||||
}
|
||||
|
||||
/// Why `d`'s file, changed by `edit`, is refused, or "read".
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
/// A dimension saves what it measures by name, its offset, its text's place and its decimals, and
|
||||
/// nothing it leaves out. The dimensions save by id, whatever order they were added in, and read
|
||||
/// back as saved, the same bytes again; a file that lists them in another order reads by id. An
|
||||
/// older drawing reads with none, and a drawing with none writes none.
|
||||
#[test]
|
||||
fn dimensions_save_what_they_measure_and_read_back_as_saved() {
|
||||
let (d, added) = measured();
|
||||
let [linear_id, length_id, angle_id] = added.map(ids::encode);
|
||||
let view = ids::encode(d.views[0].id);
|
||||
let mut v = file(&d);
|
||||
let box_corner = |edges: [&str; 2]| json!({"body": "Box", "origin_feature": 7, "edges": edges});
|
||||
assert_eq!(
|
||||
*dim(&mut v, &linear_id),
|
||||
json!({
|
||||
"id": linear_id, "view": view,
|
||||
"kind": {"linear": {
|
||||
"a": box_corner(["F1:front|F1:bottom", "F1:front|F1:left"]),
|
||||
"b": box_corner(["F1:front|F1:bottom", "F1:front|F1:right"]),
|
||||
"direction": "horizontal", "reading": "projected"
|
||||
}},
|
||||
"offset_mm": -10.0, "text": "centred", "decimals": 2
|
||||
})
|
||||
);
|
||||
let length = dim(&mut v, &length_id).clone();
|
||||
let top_edge = json!({"body": "Box", "origin_feature": 7, "name": "F1:front|F1:top"});
|
||||
assert_eq!(length["kind"], json!({"length": {"edge": top_edge, "direction": "aligned", "reading": "true"}}));
|
||||
assert_eq!(length["text"], json!({"at": [0.0, -20.0]}));
|
||||
assert_eq!(dim(&mut v, &angle_id)["kind"]["angle"]["other_side"], json!(true));
|
||||
let mut one_side = v.clone();
|
||||
dim(&mut one_side, &angle_id)["kind"]["angle"].as_object_mut().unwrap().remove("other_side");
|
||||
let one_side = Drawing::from_json(&one_side.to_string()).unwrap();
|
||||
assert_eq!(dim(&mut file(&one_side), &angle_id)["kind"]["angle"].get("other_side"), None, "the usual side, not written");
|
||||
let listed: Vec<u64> = v["dimensions"].as_array().unwrap().iter().map(|x| ids::decode(x["id"].as_str().unwrap()).unwrap()).collect();
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_eq!(listed, by_id);
|
||||
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!(reopened.to_json().unwrap(), d.to_json().unwrap());
|
||||
let mut other_order = v.clone();
|
||||
other_order["dimensions"].as_array_mut().unwrap().reverse();
|
||||
assert_eq!(Drawing::from_json(&other_order.to_string()).unwrap(), d);
|
||||
let mut unsorted = d.clone();
|
||||
unsorted.dimensions.reverse();
|
||||
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its dimensions by id");
|
||||
let mut none = d.clone();
|
||||
none.dimensions.clear();
|
||||
assert_eq!(file(&none).get("dimensions"), None, "no dimensions, none written");
|
||||
let older = json!({
|
||||
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
|
||||
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
|
||||
});
|
||||
assert!(Drawing::from_json(&older.to_string()).unwrap().dimensions.is_empty());
|
||||
}
|
||||
|
||||
/// What reading refuses, each rule by name, on both sides of every cap: the view a dimension is
|
||||
/// on, the names, the feature and the path of its references, a corner's edges, its direction and
|
||||
/// reading, its offset and text place, its decimals and its text, its id, how many there are, and
|
||||
/// fields it doesn't know.
|
||||
#[test]
|
||||
fn the_dimensions_rules_are_kept_on_reading() {
|
||||
let (d, added) = measured();
|
||||
let [linear_id, length_id, angle_id] = added.map(ids::encode);
|
||||
let rule = |id: &str, rule: &str| format!("damaged drawing: dimension {id}: {rule}");
|
||||
let nothing = ids::encode(99);
|
||||
let on = format!("it is on view {nothing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| dim(v, &linear_id)["view"] = json!(nothing)), rule(&linear_id, &on));
|
||||
// Names: of the body, an edge and a corner's edges, 1 to 512 bytes without control characters.
|
||||
let names = "a name in a reference is 1 to 512 bytes, without control characters";
|
||||
let body = |name: &str| refused(&d, |v| dim(v, &length_id)["kind"]["length"]["edge"]["body"] = json!(name));
|
||||
let edge_name = |name: &str| refused(&d, |v| dim(v, &angle_id)["kind"]["angle"]["b"]["name"] = json!(name));
|
||||
let corner_edge = |name: &str| refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["a"]["edges"][1] = json!(name));
|
||||
for fits in ["x".repeat(512), "é".repeat(256), "F1:front|F1:top".into()] {
|
||||
assert_eq!([body(&fits), edge_name(&fits), corner_edge(&fits)], ["read", "read", "read"], "{} bytes", fits.len());
|
||||
}
|
||||
for broken in ["x".repeat(513), "é".repeat(256) + "a", "x".repeat(1_000_000), String::new(), "two\nlines".into(), "nul\0".into()] {
|
||||
let expected = [rule(&length_id, names), rule(&angle_id, names), rule(&linear_id, names)];
|
||||
assert_eq!([body(&broken), edge_name(&broken), corner_edge(&broken)], expected, "{} bytes", broken.len());
|
||||
}
|
||||
// A corner is where 2 to 16 named edges meet.
|
||||
let edges = |n: usize| {
|
||||
refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["b"]["edges"] = json!((0..n).map(|i| format!("F1:e{i}")).collect::<Vec<_>>()))
|
||||
};
|
||||
let meet = rule(&linear_id, "a corner is where 2 to 16 named edges meet");
|
||||
assert_eq!([edges(0), edges(1), edges(2), edges(16), edges(17)], [meet.clone(), meet.clone(), "read".into(), "read".into(), meet]);
|
||||
// A centre is of one named circle or arc, and saves as `centre`.
|
||||
let centred = |n: usize| {
|
||||
let mut v = file(&d);
|
||||
let b = &mut dim(&mut v, &linear_id)["kind"]["linear"]["b"];
|
||||
b["centre"] = json!(true);
|
||||
b["edges"] = json!((0..n).map(|i| format!("F3:e{i}")).collect::<Vec<_>>());
|
||||
let read = Drawing::from_json(&v.to_string()).map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>(dim(&mut file(&read), &linear_id)["kind"]["linear"]["b"].clone())
|
||||
};
|
||||
let one = Err(rule(&linear_id, "a centre is of one named circle or arc"));
|
||||
assert_eq!([centred(0), centred(2)], [one.clone(), one]);
|
||||
assert_eq!(centred(1), Ok(json!({"body": "Box", "origin_feature": 7, "edges": ["F3:e0"], "centre": true})));
|
||||
// The feature that made the body, and the occurrence path.
|
||||
let feature = rule(&length_id, "a reference names the feature that made its body");
|
||||
assert_eq!(refused(&d, |v| dim(v, &length_id)["kind"]["length"]["edge"]["origin_feature"] = json!(0)), feature);
|
||||
let path = |p: Vec<u64>| refused(&d, |v| dim(v, &angle_id)["kind"]["angle"]["a"]["path"] = json!(p));
|
||||
let deep = rule(&angle_id, "an occurrence path is at most 64 occurrences, none of them 0");
|
||||
assert_eq!([path(vec![3; 64]), path(vec![3; 65]), path(vec![3, 0]), path(vec![])], ["read".into(), deep.clone(), deep, "read".to_string()]);
|
||||
// A direction to measure along that isn't zero, and a true length measured aligned.
|
||||
let along = |a: Value| refused(&d, |v| dim(v, &length_id)["kind"]["length"]["direction"] = json!({"along": a}));
|
||||
let zero = rule(&length_id, "the direction to measure along is zero or not finite");
|
||||
assert_eq!(along(json!([0.0, 0.0])), zero);
|
||||
assert!(along(json!([1e308, 1e308])).ends_with("is out of range"), "the file's own number check, before the record's");
|
||||
// A whole number passes the file's check but would save as one past it: the record refuses it.
|
||||
assert_eq!(along(json!([u64::MAX, 0])), rule(&length_id, "the direction to measure along is at most 1000000000 each way"));
|
||||
assert_eq!(along(json!([1_000_000_000, 1])), rule(&length_id, "a true length is measured aligned"), "1e9 each way passes");
|
||||
assert_eq!(along(json!([0.0, 2.0])), rule(&length_id, "a true length is measured aligned"), "along, read true");
|
||||
let projected_along = refused(&d, |v| {
|
||||
dim(v, &length_id)["kind"]["length"]["direction"] = json!({"along": [0.0, 2.0]});
|
||||
dim(v, &length_id)["kind"]["length"]["reading"] = json!("projected");
|
||||
});
|
||||
assert_eq!(projected_along, "read");
|
||||
let true_horizontal = refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["reading"] = json!("true"));
|
||||
assert_eq!(true_horizontal, rule(&linear_id, "a true length is measured aligned"));
|
||||
// On paper: the offset and the text's place.
|
||||
let paper = rule(&angle_id, "its offset and its text's place are at most 5000 mm on paper");
|
||||
let offset = |mm: f64| refused(&d, |v| dim(v, &angle_id)["offset_mm"] = json!(mm));
|
||||
let text = |at: [f64; 2]| refused(&d, |v| dim(v, &angle_id)["text"] = json!({"at": at}));
|
||||
assert_eq!([offset(5000.0), offset(-5000.0), offset(5000.001), offset(-5000.001)], ["read".into(), "read".into(), paper.clone(), paper.clone()]);
|
||||
assert_eq!([text([5000.0, -5000.0]), text([5000.001, 0.0]), text([0.0, -5000.001])], ["read".into(), paper.clone(), paper]);
|
||||
// Decimals, and the text shown instead of the number.
|
||||
let decimals = |n: u64| refused(&d, |v| dim(v, &angle_id)["decimals"] = json!(n));
|
||||
assert_eq!([decimals(0), decimals(6), decimals(7)], ["read".into(), "read".into(), rule(&angle_id, "it shows at most 6 decimals")]);
|
||||
assert!(decimals(256).starts_with("damaged drawing: "), "past a u8");
|
||||
let instead = |text: &str| refused(&d, |v| dim(v, &linear_id)["override_text"] = json!(text));
|
||||
let text_rule = rule(&linear_id, "the text shown instead of its number is 1 to 1024 bytes");
|
||||
assert_eq!(
|
||||
[instead("≈80"), instead(&"x".repeat(1024)), instead(""), instead(&"x".repeat(1025))],
|
||||
["read".into(), "read".into(), text_rule.clone(), text_rule]
|
||||
);
|
||||
// Its id is the drawing's: never another object's.
|
||||
let view_id = ids::encode(d.views[0].id);
|
||||
assert_eq!(refused(&d, |v| dim(v, &length_id)["id"] = json!(view_id)), format!("damaged drawing: two objects have the id {view_id}"));
|
||||
// Fields it doesn't know, at every level.
|
||||
let unknown: [fn(&mut Value, &str); 5] = [
|
||||
|v, id| dim(v, id)["label"] = json!("A"),
|
||||
|v, id| dim(v, id)["kind"]["linear"]["tolerance"] = json!(0.1),
|
||||
|v, id| dim(v, id)["kind"]["linear"]["a"]["point"] = json!([0.0, 0.0, 0.0]),
|
||||
|v, id| dim(v, id)["text"] = json!({"at": [1.0, 2.0], "angle": 3.0}),
|
||||
|v, id| dim(v, id)["kind"] = json!({"baseline": {}}),
|
||||
];
|
||||
for (i, edit) in unknown.into_iter().enumerate() {
|
||||
assert!(refused(&d, |v| edit(v, &linear_id)).starts_with("damaged drawing: "), "{i}");
|
||||
}
|
||||
// At most MAX_DIMENSIONS dimensions.
|
||||
let many = |n: usize| {
|
||||
let mut v = file(&d);
|
||||
let one = dim(&mut v, &linear_id).clone();
|
||||
let copies = (1..=n as u64).map(|i| {
|
||||
let mut copy = one.clone();
|
||||
copy["id"] = json!(ids::encode(i));
|
||||
copy
|
||||
});
|
||||
v["dimensions"] = Value::Array(copies.collect());
|
||||
Drawing::from_json(&v.to_string()).map(|d| d.dimensions.len()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(many(MAX_DIMENSIONS), Ok(MAX_DIMENSIONS));
|
||||
assert_eq!(many(MAX_DIMENSIONS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_DIMENSIONS} dimensions")));
|
||||
}
|
||||
|
||||
/// Two lines at 45° read 45°, and 135° on the other side, whichever way each runs: the angle
|
||||
/// between their directions is 45° or 135° by how the kernel runs the edges.
|
||||
#[test]
|
||||
fn an_angle_between_lines_reads_the_same_whichever_way_they_run() {
|
||||
let (acute, obtuse) = (45f64.to_radians(), 135f64.to_radians());
|
||||
for between in [acute, obtuse] {
|
||||
assert!((between_lines(between, false) - acute).abs() < 1e-12, "{between}");
|
||||
assert!((between_lines(between, true) - obtuse).abs() < 1e-12, "{between}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial dimension saves its edge, and `diameter` only when it is one; its edge keeps an edge's
|
||||
/// rules, and it has no reading.
|
||||
#[test]
|
||||
fn a_radial_dimension_saves_its_edge_and_whether_it_is_a_diameter() {
|
||||
let (mut d, _) = measured();
|
||||
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
|
||||
let radial = |diameter| DimensionKind::Radial { edge: edge("F3:hole|F1:front"), diameter };
|
||||
let kind = radial(true);
|
||||
d.add_dimensions([Arc::new(Dimension {
|
||||
id,
|
||||
view,
|
||||
kind,
|
||||
offset_mm: 5.0,
|
||||
text: TextPlace::Centred,
|
||||
decimals: 1,
|
||||
override_text: None,
|
||||
last_good: None,
|
||||
})]);
|
||||
d.validate().unwrap();
|
||||
let radial_id = ids::encode(id);
|
||||
let mut v = file(&d);
|
||||
let hole = json!({"body": "Box", "origin_feature": 7, "name": "F3:hole|F1:front"});
|
||||
assert_eq!(dim(&mut v, &radial_id)["kind"], json!({"radial": {"edge": hole, "diameter": true}}));
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
|
||||
dim(&mut v, &radial_id)["kind"]["radial"].as_object_mut().unwrap().remove("diameter");
|
||||
let radius = Drawing::from_json(&v.to_string()).unwrap();
|
||||
assert_eq!(radius.dimension(id).map(|r| &r.kind), Some(&radial(false)));
|
||||
assert_eq!(dim(&mut file(&radius), &radial_id)["kind"]["radial"].get("diameter"), None, "a radius, not written");
|
||||
let names = format!("damaged drawing: dimension {radial_id}: a name in a reference is 1 to 512 bytes, without control characters");
|
||||
assert_eq!(refused(&d, |v| dim(v, &radial_id)["kind"]["radial"]["edge"]["name"] = json!("")), names);
|
||||
assert!(refused(&d, |v| dim(v, &radial_id)["kind"]["radial"]["reading"] = json!("true")).starts_with("damaged drawing: "));
|
||||
}
|
||||
|
||||
/// A chain saves its points in order and its direction, and keeps its rules on reading: 3 to
|
||||
/// [`MAX_CHAIN_POINTS`] points, each with a point's rules, a direction to measure along that isn't
|
||||
/// zero, no reading (it reads projected) and no text instead of its members' numbers.
|
||||
#[test]
|
||||
fn a_chain_saves_its_points_in_order_and_keeps_its_rules() {
|
||||
let (mut d, _) = measured();
|
||||
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
|
||||
let hole = |n: u64| {
|
||||
let rim = format!("F{n}:hole|F1:front");
|
||||
ModelPoint { centre: true, ..corner(&[rim.as_str()]) }
|
||||
};
|
||||
let points = vec![corner(&["F1:front|F1:bottom", "F1:front|F1:left"]), hole(3), hole(4), hole(5)];
|
||||
let kind = DimensionKind::Chain { points, direction: LinearDirection::Horizontal };
|
||||
d.add_dimensions([Arc::new(Dimension {
|
||||
id,
|
||||
view,
|
||||
kind,
|
||||
offset_mm: 8.0,
|
||||
text: TextPlace::Centred,
|
||||
decimals: 1,
|
||||
override_text: None,
|
||||
last_good: None,
|
||||
})]);
|
||||
d.validate().unwrap();
|
||||
let chain_id = ids::encode(id);
|
||||
let mut v = file(&d);
|
||||
let first = json!({"body": "Box", "origin_feature": 7, "edges": ["F1:front|F1:bottom", "F1:front|F1:left"]});
|
||||
let centre = |n: u64| json!({"body": "Box", "origin_feature": 7, "edges": [format!("F{n}:hole|F1:front")], "centre": true});
|
||||
let saved = json!({"chain": {"points": [first, centre(3), centre(4), centre(5)], "direction": "horizontal"}});
|
||||
assert_eq!(dim(&mut v, &chain_id)["kind"], saved);
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
|
||||
let rule = |rule: &str| format!("damaged drawing: dimension {chain_id}: {rule}");
|
||||
let count = |n: usize| {
|
||||
refused(&d, |v| {
|
||||
let points = &mut dim(v, &chain_id)["kind"]["chain"]["points"];
|
||||
let one = points[1].clone();
|
||||
*points = json!(vec![one; n]);
|
||||
})
|
||||
};
|
||||
let three_to = rule(&format!("a chain has 3 to {MAX_CHAIN_POINTS} points"));
|
||||
let counts = [count(0), count(2), count(3), count(MAX_CHAIN_POINTS), count(MAX_CHAIN_POINTS + 1)];
|
||||
assert_eq!(counts, [three_to.clone(), three_to.clone(), "read".into(), "read".into(), three_to]);
|
||||
let two_edges = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["points"][2]["edges"] = json!(["F3:a", "F3:b"]));
|
||||
assert_eq!(two_edges, rule("a centre is of one named circle or arc"));
|
||||
let zero = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["direction"] = json!({"along": [0.0, 0.0]}));
|
||||
assert_eq!(zero, rule("the direction to measure along is zero or not finite"));
|
||||
let instead = refused(&d, |v| dim(v, &chain_id)["override_text"] = json!("60"));
|
||||
assert_eq!(instead, rule("a chain shows its members' numbers, never a text instead"));
|
||||
let reading = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["reading"] = json!("projected"));
|
||||
assert!(reading.starts_with("damaged drawing: "), "{reading}");
|
||||
}
|
||||
|
||||
/// A cylinder dimension saves its face by name, and the face keeps a reference's rules.
|
||||
#[test]
|
||||
fn a_cylinder_dimension_saves_its_face() {
|
||||
let (mut d, _) = measured();
|
||||
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
|
||||
let face = ModelFace { body: BODY.into(), origin_feature: FEATURE, name: "F4:boss".into(), path: vec![3] };
|
||||
let kind = DimensionKind::Cylinder { face };
|
||||
d.add_dimensions([Arc::new(Dimension {
|
||||
id,
|
||||
view,
|
||||
kind,
|
||||
offset_mm: 6.0,
|
||||
text: TextPlace::Centred,
|
||||
decimals: 1,
|
||||
override_text: None,
|
||||
last_good: None,
|
||||
})]);
|
||||
d.validate().unwrap();
|
||||
let cylinder = ids::encode(id);
|
||||
let mut v = file(&d);
|
||||
let saved = json!({"cylinder": {"face": {"body": "Box", "origin_feature": 7, "name": "F4:boss", "path": [3]}}});
|
||||
assert_eq!(dim(&mut v, &cylinder)["kind"], saved);
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
|
||||
let rule = |rule: &str| format!("damaged drawing: dimension {cylinder}: {rule}");
|
||||
let changed = |key: &str, value: Value| refused(&d, |v| dim(v, &cylinder)["kind"]["cylinder"]["face"][key] = value);
|
||||
assert_eq!(changed("name", json!("")), rule("a name in a reference is 1 to 512 bytes, without control characters"));
|
||||
assert_eq!(changed("origin_feature", json!(0)), rule("a reference names the feature that made its body"));
|
||||
assert_eq!(changed("path", json!([0])), rule("an occurrence path is at most 64 occurrences, none of them 0"));
|
||||
assert!(changed("edges", json!(["F4:boss"])).starts_with("damaged drawing: "), "a face has no edges");
|
||||
}
|
||||
|
||||
/// A dimension between two cylinders' silhouettes saves each one's face and side, and its
|
||||
/// direction; each face keeps a reference's rules, a side is near or far, and it reads as the view
|
||||
/// shows it (no `reading`).
|
||||
#[test]
|
||||
fn a_silhouettes_dimension_saves_each_face_and_side() {
|
||||
let (mut d, _) = measured();
|
||||
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
|
||||
let face = |name: &str| ModelFace { body: BODY.into(), origin_feature: FEATURE, name: name.into(), path: Vec::new() };
|
||||
let (a, b) = (Silhouette { face: face("F4:boss"), side: SilhouetteSide::Near }, Silhouette { face: face("F5:pin"), side: SilhouetteSide::Far });
|
||||
let kind = DimensionKind::Silhouettes { a, b, direction: LinearDirection::Vertical };
|
||||
d.add_dimensions([Arc::new(Dimension {
|
||||
id,
|
||||
view,
|
||||
kind,
|
||||
offset_mm: 6.0,
|
||||
text: TextPlace::Centred,
|
||||
decimals: 1,
|
||||
override_text: None,
|
||||
last_good: None,
|
||||
})]);
|
||||
d.validate().unwrap();
|
||||
let silhouettes = ids::encode(id);
|
||||
let mut v = file(&d);
|
||||
let face = |name: &str| json!({"body": "Box", "origin_feature": 7, "name": name});
|
||||
let saved = json!({"silhouettes": {"a": {"face": face("F4:boss"), "side": "near"}, "b": {"face": face("F5:pin"), "side": "far"}, "direction": "vertical"}});
|
||||
assert_eq!(dim(&mut v, &silhouettes)["kind"], saved);
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
|
||||
let rule = |rule: &str| format!("damaged drawing: dimension {silhouettes}: {rule}");
|
||||
let changed = |edit: &dyn Fn(&mut Value)| refused(&d, |v| edit(&mut dim(v, &silhouettes)["kind"]["silhouettes"]));
|
||||
assert_eq!(changed(&|k| k["b"]["face"]["name"] = json!("")), rule("a name in a reference is 1 to 512 bytes, without control characters"));
|
||||
assert_eq!(changed(&|k| k["a"]["face"]["origin_feature"] = json!(0)), rule("a reference names the feature that made its body"));
|
||||
assert_eq!(changed(&|k| k["direction"] = json!({"along": [0.0, 0.0]})), rule("the direction to measure along is zero or not finite"));
|
||||
let malformed: [fn(&mut Value); 3] = [|k| k["a"]["side"] = json!("left"), |k| k["reading"] = json!("true"), |k| k["b"]["edges"] = json!([])];
|
||||
for edit in malformed {
|
||||
let why = changed(&edit);
|
||||
assert!(why.starts_with("damaged drawing: "), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A direction along a picked line saves as its plane coordinates and reads back as the same
|
||||
/// direction: what W19a measures along.
|
||||
#[test]
|
||||
fn a_direction_along_a_line_saves_as_its_coordinates() {
|
||||
let along = LinearDirection::Along(Vec2::new(0.6, 0.8));
|
||||
assert_eq!(serde_json::to_value(along).unwrap(), json!({"along": [0.6, 0.8]}));
|
||||
assert_eq!(serde_json::from_value::<LinearDirection>(json!({"along": [0.6, 0.8]})).unwrap(), along);
|
||||
assert_eq!(serde_json::to_value(Reading::True).unwrap(), json!("true"));
|
||||
}
|
||||
|
||||
/// A last good value saves only when there is one, reads back as saved, and an older drawing reads
|
||||
/// with none.
|
||||
#[test]
|
||||
fn a_last_good_value_saves_only_when_there_is_one() {
|
||||
let (mut d, [linear, ..]) = measured();
|
||||
let id = ids::encode(linear);
|
||||
assert_eq!(dim(&mut file(&d), &id).get("last_good"), None, "none: not written");
|
||||
let at = d.dimension_index(linear).unwrap();
|
||||
Arc::make_mut(&mut d.dimensions[at]).last_good = Some(80.5);
|
||||
let mut v = file(&d);
|
||||
assert_eq!(dim(&mut v, &id)["last_good"], json!(80.5));
|
||||
let read = Drawing::from_json(&v.to_string()).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert_eq!(read.to_json().unwrap(), d.to_json().unwrap(), "the same bytes again");
|
||||
dim(&mut v, &id).as_object_mut().unwrap().remove("last_good");
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap().dimension(linear).unwrap().last_good, None);
|
||||
}
|
||||
|
||||
/// A last good value is from 0 to the largest number a drawing saves; a file with any other is
|
||||
/// refused, and so is a record built with one that JSON can't hold.
|
||||
#[test]
|
||||
fn a_last_good_value_keeps_its_rule() {
|
||||
let (d, [linear, ..]) = measured();
|
||||
let id = ids::encode(linear);
|
||||
let with = |value: Value| refused(&d, |v| dim(v, &id)["last_good"] = value);
|
||||
for good in [json!(0.0), json!(-0.0), json!(5e-324), json!(80), json!(MAX_REAL)] {
|
||||
assert_eq!(with(good.clone()), "read", "{good}");
|
||||
}
|
||||
for bad in [json!(-5e-324), json!(-1.0)] {
|
||||
assert!(with(bad.clone()).contains("last good"), "{bad}: {}", with(bad.clone()));
|
||||
}
|
||||
// Past every drawing's largest number: the file's own check refuses it first.
|
||||
let past = MAX_REAL * (1.0 + f64::EPSILON);
|
||||
for bad in [json!(past), json!(f64::MAX), json!(-f64::MAX)] {
|
||||
assert_ne!(with(bad.clone()), "read", "{bad}");
|
||||
}
|
||||
let mut direct = d.clone();
|
||||
Arc::make_mut(&mut direct.dimensions[0]).last_good = Some(past);
|
||||
assert!(direct.validate().is_err());
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut direct = d.clone();
|
||||
Arc::make_mut(&mut direct.dimensions[0]).last_good = Some(bad);
|
||||
assert!(direct.validate().is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What dimensions last read goes into their records by drawing and dimension, leaving out a
|
||||
/// dimension or a drawing no longer there and a value the record would refuse. A drawing with no
|
||||
/// new value stays shared with the document it was copied from, and that document keeps none.
|
||||
#[test]
|
||||
fn keep_last_good_keeps_only_what_a_record_may_save() {
|
||||
let (first, [a, b, c]) = measured();
|
||||
let second = Drawing { id: first.id + 1, ..first.clone() };
|
||||
let (one, two) = (first.id, second.id);
|
||||
let mut doc = Document::new("Box");
|
||||
doc.drawings = vec![Arc::new(first), Arc::new(second)];
|
||||
let before = doc.clone();
|
||||
let values = BTreeMap::from([
|
||||
((one, a), 80.0),
|
||||
((one, b), MAX_REAL),
|
||||
((one, c), MAX_REAL * 2.0),
|
||||
((one, 1), 5.0),
|
||||
((one, u64::MAX), 5.0),
|
||||
((two + 1, a), 5.0),
|
||||
]);
|
||||
doc.keep_last_good(&values);
|
||||
let read = |doc: &Document, drawing: usize, id| doc.drawings[drawing].dimension(id).unwrap().last_good;
|
||||
assert_eq!([read(&doc, 0, a), read(&doc, 0, b), read(&doc, 0, c)], [Some(80.0), Some(MAX_REAL), None]);
|
||||
assert_eq!([read(&doc, 1, a), read(&doc, 1, b), read(&doc, 1, c)], [None; 3], "another drawing's dimensions, with the same ids");
|
||||
assert!(Arc::ptr_eq(&doc.drawings[1], &before.drawings[1]), "no new value: still shared");
|
||||
assert_eq!(read(&before, 0, a), None, "the document it was copied from keeps none");
|
||||
doc.drawings.iter().for_each(|d| d.validate().unwrap());
|
||||
let kept = doc.clone();
|
||||
doc.keep_last_good(&values);
|
||||
assert!(Arc::ptr_eq(&doc.drawings[0], &kept.drawings[0]), "the same values again: still shared");
|
||||
}
|
||||
|
||||
/// Whatever a read hands over, at every boundary and over random bit patterns, the saved record is
|
||||
/// one a file can open: a design is never lost to its last good values.
|
||||
#[test]
|
||||
fn keep_last_good_never_writes_a_record_a_file_refuses() {
|
||||
let (d, [linear, ..]) = measured();
|
||||
let id = d.id;
|
||||
let mut doc = Document::new("Box");
|
||||
doc.drawings = vec![Arc::new(d)];
|
||||
let tricky = [0.0, -0.0, 5e-324, MAX_REAL, MAX_REAL * (1.0 + f64::EPSILON), -1.0, 1e12, f64::MAX, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
let mut bits = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
let random = std::iter::repeat_with(|| {
|
||||
bits = bits.wrapping_mul(0x5851_F42D_4C95_7F2D).wrapping_add(1);
|
||||
f64::from_bits(bits)
|
||||
});
|
||||
for value in tricky.into_iter().chain(random.take(2000)) {
|
||||
doc.keep_last_good(&BTreeMap::from([((id, linear), value)]));
|
||||
let saved = doc.drawings[0].to_json().unwrap();
|
||||
let read = Drawing::from_json(&saved).unwrap_or_else(|e| panic!("{value}: {e}"));
|
||||
assert_eq!(read.dimension(linear).unwrap().last_good, doc.drawings[0].dimension(linear).unwrap().last_good, "{value}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! A title block's fields (#3875): the keys each source has, and what they read. Stateless: the
|
||||
//! drawing gives its own fields, its sheets' and its newest revision's, and the caller hands in the
|
||||
//! component a sheet documents with what the model says of it (its material and mass, worked out
|
||||
//! by the bill of materials). A key its source doesn't have is refused when a block is given or
|
||||
//! read; a value that isn't there, or is empty, shows [`MISSING`]. Sheet tables' cells (B3) read
|
||||
//! the same fields.
|
||||
//!
|
||||
//! - **component:** `name`, `part_number`, `description`, `revision` (its part properties),
|
||||
//! `material`, `mass`, and `properties.<name>` (its free-text properties, such as a finish);
|
||||
//! - **drawing:** `name`, `number` (its `part_number` property: the drawing's identification
|
||||
//! number; without one, the name of the component the sheet documents), `organization`, `author`,
|
||||
//! `project`, and `properties.<key>` (any of its properties);
|
||||
//! - **sheet:** `name`, `number`, `count`: sheets left out of the count are neither counted nor
|
||||
//! numbered, and such a sheet shows neither;
|
||||
//! - **revision:** the newest revision row's fields ([`Field`]);
|
||||
//! - **prompt:** any key; answers come with templates, so a prompt shows [`MISSING`] until then.
|
||||
|
||||
use solidcraft_geom::cell::{Content, MISSING, Part, Source};
|
||||
use solidcraft_geom::revision::Field;
|
||||
use solidcraft_geom::title_block::{CellKind, TitleBlock};
|
||||
|
||||
use super::Drawing;
|
||||
use crate::{DocError, Metadata, Result};
|
||||
|
||||
const COMPONENT: [&str; 6] = ["name", "part_number", "description", "revision", "material", "mass"];
|
||||
const DRAWING: [&str; 5] = ["name", "number", "organization", "author", "project"];
|
||||
const SHEET: [&str; 3] = ["name", "number", "count"];
|
||||
/// Before a property's name: the field reads that property.
|
||||
const PROPERTIES: &str = "properties.";
|
||||
|
||||
/// The component a sheet documents, as its fields read it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Component<'a> {
|
||||
pub name: &'a str,
|
||||
/// Its part properties.
|
||||
pub metadata: &'a Metadata,
|
||||
/// Its free-text properties (the design's own: none).
|
||||
pub properties: Option<&'a std::collections::BTreeMap<String, String>>,
|
||||
/// What it is made of, as its bill of materials row would say: none when nothing is assigned.
|
||||
pub material: Option<&'a str>,
|
||||
/// What one of it weighs, written for the sheet: none when it can't be weighed.
|
||||
pub mass: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Whether `source` has the field `key`.
|
||||
pub fn has_key(source: Source, key: &str) -> bool {
|
||||
let property = key.strip_prefix(PROPERTIES).is_some_and(|name| !name.is_empty());
|
||||
match source {
|
||||
Source::Component => COMPONENT.contains(&key) || property,
|
||||
Source::Drawing => DRAWING.contains(&key) || property,
|
||||
Source::Sheet => SHEET.contains(&key),
|
||||
Source::Revision => Field::ALL.iter().any(|f| f.key() == key),
|
||||
Source::Prompt => !key.is_empty(),
|
||||
Source::Row => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `block` has a text cell `name`: what a sheet's override may name.
|
||||
pub fn overridable(block: Option<&TitleBlock>, name: &str) -> bool {
|
||||
block.is_some_and(|block| block.cells.iter().any(|cell| cell.name == name && matches!(cell.kind, CellKind::Text(_))))
|
||||
}
|
||||
|
||||
/// Each field of `block` names a key its source has: what a sheet holds its block to.
|
||||
pub fn check_block(block: &TitleBlock) -> Result<()> {
|
||||
for cell in &block.cells {
|
||||
let CellKind::Text(text) = &cell.kind else { continue };
|
||||
for part in &text.content.0 {
|
||||
if let Part::Field { source, key, .. } = part
|
||||
&& !has_key(*source, key)
|
||||
{
|
||||
let source = serde_json::to_value(source).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(DocError::Invalid(format!("cell `{}`: the {source} has no field `{key}`", cell.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a sheet's fields read: its drawing, its place among the drawing's sheets, and the component
|
||||
/// it documents.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Values<'a> {
|
||||
pub drawing: &'a Drawing,
|
||||
pub sheet: usize,
|
||||
pub component: Option<Component<'a>>,
|
||||
}
|
||||
|
||||
impl Values<'_> {
|
||||
/// `content` as its cell shows it: text as written, each field its value or [`MISSING`].
|
||||
pub fn text(&self, content: &Content) -> String {
|
||||
let part = |part: &Part| match part {
|
||||
Part::Text(text) => text.clone(),
|
||||
Part::Field { source, key, .. } => self.field(*source, key).filter(|v| !v.is_empty()).unwrap_or_else(|| MISSING.into()),
|
||||
};
|
||||
content.0.iter().map(part).collect()
|
||||
}
|
||||
|
||||
/// The field `key` of `source`, when it has a value.
|
||||
fn field(&self, source: Source, key: &str) -> Option<String> {
|
||||
let d = self.drawing;
|
||||
let property = |metadata: &Metadata| key.strip_prefix(PROPERTIES).and_then(|name| metadata.text(name)).map(str::to_string);
|
||||
match source {
|
||||
Source::Drawing => match key {
|
||||
"name" => Some(d.name.clone()),
|
||||
"number" => d.properties.part_number().or(self.component.map(|c| c.name)).map(str::to_string),
|
||||
"organization" | "author" | "project" => d.properties.text(key).map(str::to_string),
|
||||
_ => property(&d.properties),
|
||||
},
|
||||
Source::Sheet => {
|
||||
let sheet = d.sheets.get(self.sheet)?;
|
||||
if sheet.excluded.count {
|
||||
return (key == "name").then(|| sheet.name.clone());
|
||||
}
|
||||
let counted = |s: &&std::sync::Arc<super::Sheet>| !s.excluded.count;
|
||||
match key {
|
||||
"name" => Some(sheet.name.clone()),
|
||||
"number" => Some((d.sheets.iter().take(self.sheet).filter(counted).count() + 1).to_string()),
|
||||
"count" => Some(d.sheets.iter().filter(counted).count().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Source::Revision => {
|
||||
// The newest row in the sheet's scope: the drawing's, or the sheet's own once rows are per sheet.
|
||||
let scope = if d.revisions.per_sheet { Some(d.sheets.get(self.sheet)?.id) } else { None };
|
||||
d.revisions.newest(scope)?.field(key).map(str::to_string)
|
||||
}
|
||||
Source::Component => {
|
||||
let c = self.component?;
|
||||
match key {
|
||||
"name" => Some(c.name.to_string()),
|
||||
"material" => c.material.map(str::to_string),
|
||||
"mass" => c.mass.map(str::to_string),
|
||||
"part_number" | "description" | "revision" => c.metadata.text(key).map(str::to_string),
|
||||
_ => key.strip_prefix(PROPERTIES).and_then(|name| c.properties?.get(name)).cloned(),
|
||||
}
|
||||
}
|
||||
Source::Prompt | Source::Row => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fields_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::cell::Format;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
|
||||
use solidcraft_geom::title_block::default_block;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::Sheet;
|
||||
use crate::ids::IdSource;
|
||||
use crate::metadata::Owner;
|
||||
|
||||
/// A drawing of three A3 sheets, each with the built-in block.
|
||||
fn drawing() -> Drawing {
|
||||
let mut ids = IdSource::seeded(3875);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Bracket drawing", "en", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut ids).unwrap();
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block = Some(default_block());
|
||||
for n in 2..=3 {
|
||||
let id = d.allocate_id(&mut ids).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet { id, name: format!("Sheet {n}"), ..(*d.sheets[0]).clone() }));
|
||||
}
|
||||
d.validate().unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn field(source: Source, key: &str) -> Part {
|
||||
Part::Field { source, key: key.into(), format: Format::default() }
|
||||
}
|
||||
|
||||
fn shown(d: &Drawing, sheet: usize, component: Option<Component>, parts: Vec<Part>) -> String {
|
||||
Values { drawing: d, sheet, component }.text(&Content(parts))
|
||||
}
|
||||
|
||||
/// The drawing's own fields: its name, its number (its part number property), its organization
|
||||
/// and any property; a property taken away shows nothing, never the value it had.
|
||||
#[test]
|
||||
fn the_drawings_fields() {
|
||||
let mut d = drawing();
|
||||
let properties = Arc::make_mut(&mut d.properties);
|
||||
properties.set(Owner::Design, "part_number", json!("DRW-0042")).unwrap();
|
||||
properties.set(Owner::Design, "organization", json!("Tangency Labs")).unwrap();
|
||||
let parts = vec![
|
||||
field(Source::Drawing, "name"),
|
||||
Part::Text(" / ".into()),
|
||||
field(Source::Drawing, "number"),
|
||||
Part::Text(" / ".into()),
|
||||
field(Source::Drawing, "organization"),
|
||||
];
|
||||
assert_eq!(shown(&d, 0, None, parts.clone()), "Bracket drawing / DRW-0042 / Tangency Labs");
|
||||
Arc::make_mut(&mut d.properties).remove("organization");
|
||||
assert_eq!(shown(&d, 0, None, parts), "Bracket drawing / DRW-0042 / ");
|
||||
assert_eq!(shown(&d, 0, None, vec![field(Source::Drawing, "properties.acme.finish")]), "");
|
||||
// Without a number of its own, the drawing is numbered by the component its sheet documents,
|
||||
// by name; without either, nothing.
|
||||
let metadata = Metadata::default();
|
||||
let bracket = Component { name: "Bracket", metadata: &metadata, properties: None, material: None, mass: None };
|
||||
let number = || vec![field(Source::Drawing, "number")];
|
||||
assert_eq!(shown(&d, 0, Some(bracket), number()), "DRW-0042", "its own number first");
|
||||
Arc::make_mut(&mut d.properties).set(Owner::Design, "part_number", json!("")).unwrap();
|
||||
assert_eq!(shown(&d, 0, Some(bracket), number()), "Bracket");
|
||||
assert_eq!(shown(&d, 0, None, number()), "");
|
||||
}
|
||||
|
||||
/// Sheets are numbered among the counted ones: the second of three is "2/3". A sheet left out of
|
||||
/// the count shows neither, and the sheets after it close up.
|
||||
#[test]
|
||||
fn sheets_are_numbered_among_the_counted_ones() {
|
||||
let mut d = drawing();
|
||||
let sheet = || vec![field(Source::Sheet, "number"), Part::Text("/".into()), field(Source::Sheet, "count")];
|
||||
assert_eq!(shown(&d, 1, None, sheet()), "2/3");
|
||||
Arc::make_mut(&mut d.sheets[1]).excluded.count = true;
|
||||
assert_eq!([0, 1, 2].map(|at| shown(&d, at, None, sheet())), ["1/2", "/", "2/2"]);
|
||||
assert_eq!(shown(&d, 1, None, vec![field(Source::Sheet, "name")]), "Sheet 2");
|
||||
}
|
||||
|
||||
/// The revision fields read the newest row; with none, nothing.
|
||||
#[test]
|
||||
fn revision_fields_read_the_newest_row() {
|
||||
let mut d = drawing();
|
||||
let date = || vec![field(Source::Revision, "revision"), Part::Text(" ".into()), field(Source::Revision, "date")];
|
||||
assert_eq!(shown(&d, 0, None, date()), " ");
|
||||
let revisions = Arc::make_mut(&mut d.revisions);
|
||||
for (id, day) in [(901, "2026-10-01"), (902, "2026-10-30")] {
|
||||
revisions.add(None, id).unwrap();
|
||||
revisions.set(id, Field::Date, day).unwrap();
|
||||
}
|
||||
assert_eq!(shown(&d, 0, None, date()), "2 2026-10-30");
|
||||
}
|
||||
|
||||
/// A component's fields: its name and part properties, its free-text properties, and the material
|
||||
/// and mass the caller worked out; without a component, or a value, or with an empty one, nothing.
|
||||
#[test]
|
||||
fn the_components_fields() {
|
||||
let d = drawing();
|
||||
let mut metadata = Metadata::default();
|
||||
metadata.set(Owner::Component, "part_number", json!("BRK-7")).unwrap();
|
||||
let properties = BTreeMap::from([("finish".to_string(), "Zinc plated".to_string())]);
|
||||
let bracket = Component { name: "Bracket", metadata: &metadata, properties: Some(&properties), material: Some("Steel"), mass: Some("0.39 kg") };
|
||||
let keys = ["name", "part_number", "material", "mass", "properties.finish", "description"];
|
||||
let read = |component| keys.map(|key| shown(&d, 0, component, vec![field(Source::Component, key)]));
|
||||
assert_eq!(read(Some(bracket)), ["Bracket", "BRK-7", "Steel", "0.39 kg", "Zinc plated", ""]);
|
||||
let unweighed = Component { material: None, mass: None, properties: None, ..bracket };
|
||||
assert_eq!(read(Some(unweighed)), ["Bracket", "BRK-7", "", "", "", ""]);
|
||||
assert_eq!(read(None), [""; 6]);
|
||||
let blank = BTreeMap::from([("finish".to_string(), String::new())]);
|
||||
let finish = vec![field(Source::Component, "properties.finish")];
|
||||
assert_eq!(shown(&d, 0, Some(Component { properties: Some(&blank), ..bracket }), finish), "");
|
||||
}
|
||||
|
||||
/// A field its source doesn't have is refused when a block is given and when a drawing is read,
|
||||
/// naming the cell; so is an override of a cell the block doesn't have or that shows no text, or
|
||||
/// one with a control character. A sheet without overrides saves none.
|
||||
#[test]
|
||||
fn unknown_fields_and_overrides_are_refused() {
|
||||
let mut block = default_block();
|
||||
let CellKind::Text(text) = &mut block.cells[8].kind else { panic!("the mass cell has text") };
|
||||
text.content = Content(vec![field(Source::Component, "weight")]);
|
||||
assert_eq!(check_block(&block).unwrap_err().to_string(), "cell `mass`: the component has no field `weight`");
|
||||
let mut d = drawing();
|
||||
let saved = d.to_json().unwrap();
|
||||
assert!(!saved.contains("title_block_overrides"));
|
||||
let mut v: serde_json::Value = serde_json::from_str(&saved).unwrap();
|
||||
v["sheets"][0]["title_block"] = serde_json::to_value(&block).unwrap();
|
||||
let refusal = Drawing::from_json(&v.to_string()).unwrap_err().to_string();
|
||||
assert!(refusal.contains("cell `mass`: the component has no field `weight`"), "{refusal}");
|
||||
// Overrides: a cell's name, a cell's text.
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block_overrides.insert("material".into(), "See note 3".into());
|
||||
let saved = d.to_json().unwrap();
|
||||
assert_eq!(Drawing::from_json(&saved).unwrap(), d);
|
||||
let refusals =
|
||||
[("weight", "x", "no text cell `weight`"), ("projection", "x", "no text cell `projection`"), ("material", "a\u{7}", "control character")];
|
||||
for (cell, text, why) in refusals {
|
||||
let mut wrong = d.clone();
|
||||
Arc::make_mut(&mut wrong.sheets[0]).title_block_overrides.insert(cell.into(), text.into());
|
||||
let refusal = wrong.validate().unwrap_err().to_string();
|
||||
assert!(refusal.contains(why), "{refusal}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Pending drawing-format changes
|
||||
|
||||
One file per change to what a drawing file saves that no release has shipped yet. Drawings
|
||||
(`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
|
||||
One file per change to what a drawing saves (in its design's file) that no release has shipped
|
||||
yet. Drawings (`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
|
||||
(`crates/doc/src/format.rs`, `Family`), from their own first format, 1. Adding a file here is the
|
||||
whole format bump: never edit the `DRAWING` family in `format.rs`, another change's file or a
|
||||
`../v<N>/` folder.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Drawings save their balloons (`balloons`, #3874 W26): each on one of the drawing's views, pointing
|
||||
//! at a placed part by a named edge of it (the edge's body, the feature that made it, its name and
|
||||
//! the occurrences the body is placed through), with its place on paper from the view's centre. An
|
||||
//! older drawing has no balloons. The last release refuses a drawing saved with them, as a newer
|
||||
//! format.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A drawing's dimensions save their last good value (`last_good`, W19d, #4896) when they have one:
|
||||
//! what the dimension last read, so one that can't be measured any more still says what it was
|
||||
//! after the design is reopened. An older drawing has none, and each dimension has one again from
|
||||
//! its next read. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Drawings save their dimensions (`dimensions`, #4896 W19b): each on one of the drawing's views,
|
||||
//! measuring named corners, circles' and arcs' centres, edges or faces of the view's model (a
|
||||
//! linear distance, an edge's length, an angle, a circle's or an arc's radius or diameter, a chain
|
||||
//! of distances along one line, a cylinder's diameter between its silhouettes, or the distance
|
||||
//! between two cylinders' silhouettes), with how far its line sits from them and where its text
|
||||
//! sits on paper, its decimals and any text shown instead of its number. An older drawing has no
|
||||
//! dimensions. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawings save the font their text is set in (`font`, #4890): a font family's name, never a
|
||||
//! file. An older drawing has none, and is set in the built-in font. The last release refuses a
|
||||
//! drawing saved with one, as a newer format.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Drawings save their images (#5426): `images` holds each file once, with its format, pixel size and
|
||||
//! bytes (base64), and `sheet_images` each place one sits, with its sheet, its rectangle and the file's
|
||||
//! id. The file's path is never saved. An older drawing has neither. The last release refuses a drawing
|
||||
//! saved with them, as a newer format.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A drawing lives in the file of the design it draws (#3890): it has an `id`, unique among its
|
||||
//! design's drawings, and at most one source, the design it is in, which has an `id` and no `path`.
|
||||
//! No step: no release saved a drawing before this, and a drawing file of its own is no longer
|
||||
//! read, so an older drawing is refused as damaged. The last release refuses a design that holds a
|
||||
//! drawing (the design's newer format number).
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Drawings save their revisions (`revisions`, #3874): one revision record for the whole drawing,
|
||||
//! its rows (revision, description, designer, checker, date, hidden) in the order they were added,
|
||||
//! each with its id. An older drawing has no revisions. The last release refuses a drawing saved
|
||||
//! with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawing sheets save the text typed over their title block's cells (`title_block_overrides`,
|
||||
//! #3875), by cell name. An older drawing's sheets have none. The last release refuses a drawing
|
||||
//! saved with one, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawing sheets save a copy of their title block's definition (`title_block`, #3875 W18): its size,
|
||||
//! its two line widths and its cells, on the drawing border's lower-right corner. An older drawing's
|
||||
//! sheets have no title block. The last release refuses a drawing saved with one, as a newer format.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Drawings save their views (`views`, #4816): base views of their designs and the views projected
|
||||
//! from them, on their sheets, each with the settings it sets itself; and the projection every
|
||||
//! projected view follows (`projection`: `first` or `third` angle). An older drawing has no views,
|
||||
//! and its projection is third angle. The last release refuses a drawing saved with them, as a
|
||||
//! newer format.
|
||||
@@ -0,0 +1,156 @@
|
||||
//! A drawing's images (W38e, #5426): a PNG or JPEG placed on a sheet, such as a logo or a photo.
|
||||
//!
|
||||
//! - **A file and its placements are two records.** A logo sits on every sheet: [`Drawing::images`]
|
||||
//! holds it once, and each [`SheetImage`] in [`Drawing::sheet_images`] says where one sits. A file
|
||||
//! goes with the last thing that uses it ([`Drawing::images_in_use`]).
|
||||
//! - **The bytes** travel in the drawing's record (base64, as a canvas's do), so the design opens
|
||||
//! without the original file, and the file's path is never saved.
|
||||
//! - **The caps** are checked from the file's bytes and header, before anything decodes its pixels:
|
||||
//! a small file can claim to hold more pixels than memory has. The header can lie in the record
|
||||
//! too, so reading a drawing compares the two ([`Drawing::check_image_files`]); an edit, which only
|
||||
//! ever adds a checked file, doesn't decode them again.
|
||||
//!
|
||||
//! The rules, beside the record's: a file is used; a placement names a file the drawing has, and is
|
||||
//! on one of its sheets, in a rectangle on paper with a size; at most [`MAX_IMAGES`] files of at most
|
||||
//! [`MAX_IMAGE_BYTES`] and [`MAX_IMAGE_PIXELS`] each, and [`MAX_IMAGES_BYTES`] between them; at most
|
||||
//! [`MAX_SHEET_IMAGES`] placements; each list by id.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::drawing_view::is_paper_coordinate;
|
||||
use solidcraft_geom::paper::{MAX_IMAGE_PIXELS, MAX_SIDE_MM};
|
||||
|
||||
use super::by_id::Record;
|
||||
use super::{Drawing, require};
|
||||
use crate::canvas::{base64_decode, base64_encode, image_info};
|
||||
use crate::{DocError, Result, ids};
|
||||
|
||||
/// The most image files a drawing may have.
|
||||
pub const MAX_IMAGES: usize = 1_000;
|
||||
/// The most placements of them a drawing may have.
|
||||
pub const MAX_SHEET_IMAGES: usize = 1_000;
|
||||
/// The largest file an image may be.
|
||||
pub const MAX_IMAGE_BYTES: usize = 4 << 20;
|
||||
/// The most a drawing's image files may be between them: two of the largest and some, whose 12 MiB
|
||||
/// of text leaves room under [`super::MAX_DRAWING_BYTES`] for the rest of the record.
|
||||
pub const MAX_IMAGES_BYTES: usize = 9 << 20;
|
||||
|
||||
/// One image file of a drawing, however many places it sits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImageFile {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// `png` or `jpeg`, as the file's header says.
|
||||
pub format: String,
|
||||
/// Its width and height in pixels, as the file's header says.
|
||||
pub pixels: [u32; 2],
|
||||
/// The file, base64.
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// One place an image file sits, on a sheet of a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SheetImage {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
#[serde(with = "crate::ids")]
|
||||
pub sheet: u64,
|
||||
/// Where it sits, `[x0, y0, x1, y1]` in millimetres from the sheet's lower-left corner.
|
||||
pub rect_mm: [f64; 4],
|
||||
/// The file it shows, by id.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub image: u64,
|
||||
}
|
||||
|
||||
/// The length of `bytes` bytes in base64.
|
||||
const fn encoded(bytes: usize) -> usize {
|
||||
bytes.div_ceil(3) * 4
|
||||
}
|
||||
|
||||
/// What a file's bytes and header say it is, when its caps allow it. Nothing is decoded.
|
||||
fn file_info(bytes: &[u8]) -> Result<(&'static str, [u32; 2])> {
|
||||
require(bytes.len() <= MAX_IMAGE_BYTES, || format!("the file is over {} MiB", MAX_IMAGE_BYTES >> 20))?;
|
||||
let (format, pixels) = image_info(bytes).ok_or_else(|| DocError::Invalid("the file isn't a PNG or JPEG".into()))?;
|
||||
let [w, h] = pixels;
|
||||
require(u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, || format!("the file has {w} by {h} pixels, over {MAX_IMAGE_PIXELS}"))?;
|
||||
Ok((format, pixels))
|
||||
}
|
||||
|
||||
impl ImageFile {
|
||||
/// The image file of a file's `bytes`, when its caps allow it: nothing is decoded.
|
||||
pub fn from_file(id: u64, bytes: &[u8]) -> Result<ImageFile> {
|
||||
let (format, pixels) = file_info(bytes)?;
|
||||
Ok(ImageFile { id, format: format.into(), pixels, data: base64_encode(bytes) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for ImageFile {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for SheetImage {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The image files something in the drawing uses: the one list of what keeps a file. Anything new
|
||||
/// that shows a file adds its own here.
|
||||
pub fn images_in_use(&self) -> BTreeSet<u64> {
|
||||
self.sheet_images.iter().map(|placement| placement.image).collect()
|
||||
}
|
||||
|
||||
/// Takes out each image file nothing uses now.
|
||||
pub(super) fn drop_unused_images(&mut self) {
|
||||
let used = self.images_in_use();
|
||||
self.images.retain(|file| used.contains(&file.id));
|
||||
}
|
||||
|
||||
pub(super) fn check_images(&self) -> Result<()> {
|
||||
// The placements first: one that names no file says so, before its file reads as unused.
|
||||
self.sheet_images.check(MAX_SHEET_IMAGES, "placed image", |placement| self.check_sheet_image(placement))?;
|
||||
let used = self.images_in_use();
|
||||
self.images.check(MAX_IMAGES, "image", |file| {
|
||||
check_image(file)?;
|
||||
require(used.contains(&file.id), || "nothing uses it".into())
|
||||
})?;
|
||||
let total = self.images.iter().fold(0, |total: usize, i| total.saturating_add(i.data.len()));
|
||||
require(total <= encoded(MAX_IMAGES_BYTES), || format!("a drawing's images are at most {} MiB between them", MAX_IMAGES_BYTES >> 20))
|
||||
}
|
||||
|
||||
fn check_sheet_image(&self, i: &SheetImage) -> Result<()> {
|
||||
require(self.images.by_id(i.image).is_some(), || format!("it shows image {}, which the drawing doesn't have", ids::encode(i.image)))?;
|
||||
require(self.sheets.iter().any(|s| s.id == i.sheet), || format!("it is on sheet {}, which the drawing doesn't have", ids::encode(i.sheet)))?;
|
||||
let [x0, y0, x1, y1] = i.rect_mm;
|
||||
let on_paper = [x0, y0, x1, y1].into_iter().all(is_paper_coordinate);
|
||||
require(on_paper && x0 < x1 && y0 < y1, || format!("its rectangle has a size and is at most {MAX_SIDE_MM} mm on paper"))
|
||||
}
|
||||
|
||||
/// Each image's bytes are the file its record says: what reading a drawing checks once, as it
|
||||
/// decodes the base64, so that nothing later trusts a size the file doesn't have.
|
||||
pub(super) fn check_image_files(&self) -> Result<()> {
|
||||
self.images.iter().try_for_each(|i| {
|
||||
let bytes = base64_decode(&i.data).ok_or_else(|| DocError::Invalid(format!("image {}: its file isn't base64", ids::encode(i.id))))?;
|
||||
let (format, pixels) = file_info(&bytes).map_err(|e| DocError::Invalid(format!("image {}: {e}", ids::encode(i.id))))?;
|
||||
require(format == i.format && pixels == i.pixels, || format!("image {}: its record and its file disagree", ids::encode(i.id)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's own rules, from its record alone: what every edit keeps, without decoding it.
|
||||
fn check_image(i: &ImageFile) -> Result<()> {
|
||||
require(matches!(i.format.as_str(), "png" | "jpeg"), || "it is a PNG or a JPEG".into())?;
|
||||
let [w, h] = i.pixels;
|
||||
require(w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, || format!("it has 1 to {MAX_IMAGE_PIXELS} pixels"))?;
|
||||
require(i.data.len() <= encoded(MAX_IMAGE_BYTES), || format!("its file is at most {} MiB", MAX_IMAGE_BYTES >> 20))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
|
||||
use super::*;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const RECT: [f64; 4] = [10.0, 20.0, 60.0, 45.0];
|
||||
|
||||
/// A PNG's header, saying these pixels: all a cap reads.
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
b.extend(w.to_be_bytes());
|
||||
b.extend(h.to_be_bytes());
|
||||
b.extend([8, 6, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A JPEG's start, saying these pixels.
|
||||
fn jpeg(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8];
|
||||
b.extend(h.to_be_bytes());
|
||||
b.extend(w.to_be_bytes());
|
||||
b.extend([0; 12]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A drawing on one A3 sheet with one image of `bytes` placed on it: the drawing, the file's id and
|
||||
/// the placement's.
|
||||
fn pictured(bytes: &[u8]) -> (Drawing, u64, u64) {
|
||||
let mut id_source = IdSource::seeded(5426);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Pictured", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let (file, placed) = (d.allocate_id(&mut id_source).unwrap(), d.allocate_id(&mut id_source).unwrap());
|
||||
d.images.add([Arc::new(ImageFile::from_file(file, bytes).unwrap())]);
|
||||
d.sheet_images.add([Arc::new(SheetImage { id: placed, sheet: d.sheets[0].id, rect_mm: RECT, image: file })]);
|
||||
d.validate().unwrap();
|
||||
(d, file, placed)
|
||||
}
|
||||
|
||||
/// A copy of the drawing's first sheet, as a sheet of its own.
|
||||
fn drawing_sheet_copy(d: &Drawing, id: u64) -> crate::drawing::Sheet {
|
||||
crate::drawing::Sheet { id, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_saved_with_its_bytes_and_reads_back_the_same() {
|
||||
let bytes = png(640, 480);
|
||||
let (d, image, placed) = pictured(&bytes);
|
||||
let saved = d.to_json().unwrap();
|
||||
let read = Drawing::from_json(&saved).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert_eq!(read.to_json().unwrap(), saved);
|
||||
let found = read.images.by_id(image).unwrap();
|
||||
assert_eq!((found.format.as_str(), found.pixels), ("png", [640, 480]));
|
||||
assert_eq!(base64_decode(&found.data).unwrap(), bytes);
|
||||
assert_eq!(read.sheet_images.by_id(placed).map(|i| (i.rect_mm, i.image)), Some((RECT, image)));
|
||||
assert!(read.images.by_id(placed).is_none() && read.sheet_images.by_id(image).is_none());
|
||||
}
|
||||
|
||||
/// One file sits on two sheets and is saved once. Its bytes count once towards the caps, so a logo on
|
||||
/// every sheet costs what one does.
|
||||
#[test]
|
||||
fn one_file_on_two_sheets_is_saved_once() {
|
||||
let mut logo = png(10, 10);
|
||||
logo.resize(1 << 20, 0);
|
||||
let (mut d, image, _) = pictured(&logo);
|
||||
let first = d.sheets[0].id;
|
||||
let mut id_source = IdSource::seeded(4);
|
||||
let second = d.allocate_id(&mut id_source).unwrap();
|
||||
let sheet = drawing_sheet_copy(&d, second);
|
||||
d.sheets.push(Arc::new(sheet));
|
||||
for n in 0..20u64 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheet_images.add([Arc::new(SheetImage { id, sheet: second, rect_mm: [n as f64, 0.0, n as f64 + 5.0, 5.0], image })]);
|
||||
}
|
||||
d.validate().unwrap();
|
||||
assert_eq!((d.images.len(), d.sheet_images.len()), (1, 21));
|
||||
let read = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert!(d.to_json().unwrap().len() < 2 << 20, "the bytes are there once");
|
||||
assert_eq!(d.images_in_use(), BTreeSet::from([image]));
|
||||
|
||||
// The first sheet's going leaves the file for the second's, and the second's takes it.
|
||||
d.remove_sheet(first);
|
||||
assert_eq!((d.images.len(), d.sheet_images.len()), (1, 20));
|
||||
d.validate().unwrap();
|
||||
d.remove_sheet(second);
|
||||
assert!(d.images.is_empty() && d.sheet_images.is_empty());
|
||||
}
|
||||
|
||||
/// A file goes with its last placement, and nothing is left to save.
|
||||
#[test]
|
||||
fn a_file_goes_with_its_last_placement() {
|
||||
let (mut d, _, placed) = pictured(&png(1, 1));
|
||||
let (sheet, other) = (d.sheets[0].id, d.allocate_id(&mut IdSource::seeded(4)).unwrap());
|
||||
let empty = drawing_sheet_copy(&d, other);
|
||||
d.sheets.push(Arc::new(empty));
|
||||
assert_eq!(d.on_sheet(sheet).images, [placed]);
|
||||
assert!(!d.on_sheet(sheet).is_empty() && d.on_sheet(other).is_empty());
|
||||
assert_eq!(d.remove_sheet(sheet).images, [placed]);
|
||||
assert!(d.images.is_empty() && d.sheet_images.is_empty());
|
||||
d.validate().unwrap();
|
||||
let v = file(&d);
|
||||
assert!(v.get("images").is_none() && v.get("sheet_images").is_none(), "{v}");
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d, "an older drawing has neither");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stay_apart_across_files_and_placements() {
|
||||
let (d, image, placed) = pictured(&png(1, 1));
|
||||
let taken = d.ids().collect::<BTreeSet<_>>();
|
||||
assert!(taken.contains(&image) && taken.contains(&placed));
|
||||
let mut twin = d.clone();
|
||||
Arc::make_mut(&mut twin.sheet_images[0]).id = image;
|
||||
assert!(twin.validate().is_err_and(|e| e.to_string().contains("two objects")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caps_are_read_from_the_file_before_anything_decodes_it() {
|
||||
let make = |b: &[u8]| ImageFile::from_file(1, b).map(|i| i.pixels).map_err(|e| e.to_string());
|
||||
assert_eq!(make(&png(8_000, 8_000)), Ok([8_000, 8_000]));
|
||||
assert_eq!(make(&jpeg(8_000, 8_000)), Ok([8_000, 8_000]));
|
||||
// The limits, one over, and sizes whose product would overflow 32 bits.
|
||||
for (w, h) in [(8_001, 8_000), (64_000_001, 1), (1, 64_000_001), (65_536, 65_536), (u32::MAX, u32::MAX)] {
|
||||
assert!(make(&png(w, h)).is_err_and(|e| e.contains("pixels")), "{w} by {h}");
|
||||
}
|
||||
assert!(make(&jpeg(u16::MAX, u16::MAX)).is_err_and(|e| e.contains("pixels")));
|
||||
let not_images: [&[u8]; 6] = [b"", b"GIF89a", b"\x89PNG", &png(0, 5), &png(5, 0), &jpeg(0, 5)];
|
||||
for not_image in not_images {
|
||||
assert!(make(not_image).is_err_and(|e| e.contains("PNG or JPEG")), "{not_image:?}");
|
||||
}
|
||||
let mut full = png(10, 10);
|
||||
full.resize(MAX_IMAGE_BYTES, 0);
|
||||
assert_eq!(make(&full), Ok([10, 10]));
|
||||
full.push(0);
|
||||
assert!(make(&full).is_err_and(|e| e.contains("MiB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_lies_is_refused_on_reading() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
let with = |key: &'static str, value: Value| move |v: &mut Value| v["images"][0][key] = value;
|
||||
assert!(refused(&d, with("pixels", json!([1, 1]))).contains("disagree"));
|
||||
assert!(refused(&d, with("format", "jpeg".into())).contains("disagree"));
|
||||
assert!(refused(&d, with("format", "gif".into())).contains("PNG or a JPEG"));
|
||||
assert!(refused(&d, with("data", "!!!".into())).contains("base64"));
|
||||
assert!(refused(&d, with("data", base64_encode(&png(9_000, 9_000)).into())).contains("pixels"));
|
||||
assert!(refused(&d, with("data", "".into())).contains("PNG or JPEG"));
|
||||
assert!(refused(&d, with("pixels", json!([0, 10]))).contains("pixels"));
|
||||
assert!(refused(&d, with("pixels", json!([65_536, 65_536]))).contains("pixels"));
|
||||
assert!(refused(&d, with("path", "C:/secret.png".into())).contains("path"), "an unknown field is damage");
|
||||
}
|
||||
|
||||
/// A placement names a file and a sheet the drawing has, and a file nothing places isn't kept.
|
||||
#[test]
|
||||
fn a_placement_and_its_file_name_each_other() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
let placed = |key: &'static str, value: Value| move |v: &mut Value| v["sheet_images"][0][key] = value;
|
||||
assert!(refused(&d, placed("sheet", ids::encode(1 << 41).into())).contains("sheet"));
|
||||
assert!(refused(&d, placed("image", ids::encode(1 << 41).into())).contains("doesn't have"));
|
||||
assert!(refused(&d, |v| v["sheet_images"] = json!([])).contains("nothing uses it"));
|
||||
assert!(refused(&d, |v| v["images"] = json!([])).contains("doesn't have"));
|
||||
assert!(refused(&d, placed("data", "AAAA".into())).contains("data"), "the file's bytes aren't the placement's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_is_on_paper_and_has_a_size() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
for rect in [
|
||||
[60.0, 20.0, 10.0, 45.0],
|
||||
[10.0, 45.0, 60.0, 20.0],
|
||||
[10.0, 20.0, 10.0, 45.0],
|
||||
[10.0, 20.0, 60.0, 20.0],
|
||||
[1e300, 20.0, 2e300, 45.0],
|
||||
[f64::NAN; 4],
|
||||
] {
|
||||
let mut bad = d.clone();
|
||||
Arc::make_mut(&mut bad.sheet_images[0]).rect_mm = rect;
|
||||
assert!(bad.validate().is_err_and(|e| e.to_string().contains("rectangle")), "{rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawings_images_keep_their_counts() {
|
||||
let (d, image, placed) = pictured(&png(1, 1));
|
||||
// A file and something to use it, ids after the first ones.
|
||||
let again = |d: &mut Drawing, n: u64, data: &str| {
|
||||
let mut file = (*d.images[0]).clone();
|
||||
file.id = image + 2 * n;
|
||||
file.data = data.into();
|
||||
let use_of = SheetImage { id: placed + 2 * n, sheet: d.sheets[0].id, rect_mm: RECT, image: file.id };
|
||||
d.images.add([Arc::new(file)]);
|
||||
d.sheet_images.add([Arc::new(use_of)]);
|
||||
};
|
||||
// One file's cap and the cap between them, checked on every edit from the text's length alone.
|
||||
let mut big = d.clone();
|
||||
Arc::make_mut(&mut big.images[0]).data = "A".repeat(encoded(MAX_IMAGE_BYTES) + 1);
|
||||
assert!(big.validate().is_err_and(|e| e.to_string().contains("its file")));
|
||||
let mut many = d.clone();
|
||||
let full = "A".repeat(encoded(MAX_IMAGE_BYTES));
|
||||
Arc::make_mut(&mut many.images[0]).data.clone_from(&full);
|
||||
again(&mut many, 1, &full);
|
||||
let room = encoded(MAX_IMAGES_BYTES) - 2 * full.len();
|
||||
again(&mut many, 2, &"A".repeat(room));
|
||||
many.validate().unwrap();
|
||||
again(&mut many, 3, "A");
|
||||
assert!(many.validate().is_err_and(|e| e.to_string().contains("between them")));
|
||||
let mut crowd = d.clone();
|
||||
(1..=MAX_IMAGES as u64).for_each(|n| again(&mut crowd, n, "AAAA"));
|
||||
assert!(crowd.validate().is_err_and(|e| e.to_string().contains("at most")));
|
||||
let mut placements = d.clone();
|
||||
let logo = placements.sheet_images[0].clone();
|
||||
let copies = (1..=MAX_SHEET_IMAGES as u64).map(|n| Arc::new(SheetImage { id: placed + 2 * n, ..(*logo).clone() }));
|
||||
placements.sheet_images.add(copies.collect::<Vec<_>>());
|
||||
assert!(placements.validate().is_err_and(|e| e.to_string().contains("at most")));
|
||||
let mut twins = d.clone();
|
||||
let twin = twins.sheet_images[0].clone();
|
||||
twins.sheet_images.push(twin);
|
||||
assert!(twins.validate().is_err());
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Drawing templates (#3875): what a new drawing starts from. A template is a drawing with no
|
||||
//! model: its sheets with their borders, title blocks and the text typed over their cells, its
|
||||
//! projection, units, language, properties and revision numbering, and nothing that draws or
|
||||
//! documents a design ([`check`]). So it is saved and read as a drawing is, under the same caps
|
||||
//! and damage rules, and a new drawing is a copy of it with ids of its own ([`instantiate`]).
|
||||
//!
|
||||
//! A drawing saved as a template ([`from_drawing`]) keeps its format and leaves its model behind; a
|
||||
//! template file reads as a drawing does, then is held to the template rules ([`read`]).
|
||||
//!
|
||||
//! A template applied to a drawing ([`apply`]) gives each sheet its paper, drawing border and title
|
||||
//! block, and moves nothing on it.
|
||||
//!
|
||||
//! The built-ins ([`BUILT_INS`]) are made in code when asked for, never stored: ISO A4 to A0, each
|
||||
//! one sheet with the ISO border and nothing more. A title block, tables and text are added to a
|
||||
//! drawing, and a template saved from it keeps them; the built-in block
|
||||
//! ([`default_block`](solidcraft_geom::title_block::default_block)) is one a sheet can take. A
|
||||
//! later built-in never changes a drawing made from an earlier one: a drawing keeps copies, never
|
||||
//! names.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_geom::cell::Part;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
|
||||
use solidcraft_geom::revision::RevisionRecord;
|
||||
use solidcraft_geom::title_block::CellKind;
|
||||
|
||||
use super::Drawing;
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::IdSource;
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// The built-in templates, by name: each ISO size, with its border only.
|
||||
pub const BUILT_INS: &[&str] = &["iso-a4", "iso-a3", "iso-a2", "iso-a1", "iso-a0"];
|
||||
/// Where every built-in's ids come from, so one built-in is always the same bytes.
|
||||
const BUILT_IN_SEED: u64 = 3875;
|
||||
/// The ISO sizes the built-ins come in, as their names and titles write them.
|
||||
const SIZES: [(PaperSize, &str); 5] =
|
||||
[(PaperSize::A4, "A4"), (PaperSize::A3, "A3"), (PaperSize::A2, "A2"), (PaperSize::A1, "A1"), (PaperSize::A0, "A0")];
|
||||
|
||||
/// The built-in a new drawing starts from when nothing else is chosen.
|
||||
pub const DEFAULT: &str = "iso-a3";
|
||||
|
||||
/// The template rules: a drawing's ([`Drawing::validate`]), and nothing of a model. A template
|
||||
/// with anything of one is refused, naming all it has.
|
||||
pub fn check(template: &Drawing) -> Result<()> {
|
||||
template.validate()?;
|
||||
let has = [
|
||||
(!template.sources.is_empty(), "a source"),
|
||||
(!template.views.is_empty(), "views"),
|
||||
(!template.dimensions.is_empty(), "dimensions"),
|
||||
(!template.balloons.is_empty(), "balloons"),
|
||||
(template.revisions.ids().next().is_some(), "revision rows"),
|
||||
(template.sheets.iter().any(|sheet| sheet.documents.is_some()), "a sheet that documents a component"),
|
||||
];
|
||||
let has: Vec<&str> = has.into_iter().filter(|(has, _)| *has).map(|(_, what)| what).collect();
|
||||
if has.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(DocError::Invalid(format!("a template draws no design, and this one has {}", has.join(", "))))
|
||||
}
|
||||
|
||||
/// A new drawing named `name` from `template`: a copy with the format this build writes, an id
|
||||
/// none of `taken` has (its design's drawings'), and each sheet an id of its own, all from
|
||||
/// `id_source`.
|
||||
pub fn instantiate(template: &Drawing, name: &str, taken: &BTreeSet<u64>, id_source: &mut IdSource) -> Result<Drawing> {
|
||||
check(template)?;
|
||||
let id = id_source.next(taken)?;
|
||||
let mut in_use = BTreeSet::from([id]);
|
||||
let mut sheets = Vec::with_capacity(template.sheets.len());
|
||||
for sheet in &template.sheets {
|
||||
let sheet_id = id_source.next(&in_use)?;
|
||||
in_use.insert(sheet_id);
|
||||
sheets.push(Arc::new(super::Sheet { id: sheet_id, ..(**sheet).clone() }));
|
||||
}
|
||||
let format = DRAWING.format_string(DRAWING.current());
|
||||
let drawing = Drawing { format, format_pending: DRAWING.pending_names(), id, name: name.into(), sheets, ..template.clone() };
|
||||
drawing.validate()?;
|
||||
Ok(drawing)
|
||||
}
|
||||
|
||||
/// `drawing` as a template: its format, and nothing it draws or documents (its sources, views,
|
||||
/// dimensions, balloons, revision rows, and the component each sheet documents), nor its number,
|
||||
/// which drawings made from it get their own of. Its other properties, its revision numbering and
|
||||
/// the text typed over its cells of plain text stay; text typed over a cell that shows a field
|
||||
/// (a parameter, a value or a reference) goes with the design that field reads.
|
||||
pub fn from_drawing(drawing: &Drawing) -> Result<Drawing> {
|
||||
let mut template = drawing.clone();
|
||||
template.sources.clear();
|
||||
template.views.clear();
|
||||
template.dimensions.clear();
|
||||
template.balloons.clear();
|
||||
Arc::make_mut(&mut template.properties).remove(crate::metadata::PART_NUMBER);
|
||||
let mut revisions = RevisionRecord::default();
|
||||
(revisions.numbering, revisions.per_sheet) = (template.revisions.numbering, template.revisions.per_sheet);
|
||||
template.revisions = Arc::new(revisions);
|
||||
for sheet in &mut template.sheets {
|
||||
let sheet = Arc::make_mut(sheet);
|
||||
sheet.documents = None;
|
||||
let shows_a_field = |name: &String| {
|
||||
let cell = sheet.title_block.as_ref().and_then(|block| block.cells.iter().find(|cell| cell.name == *name));
|
||||
cell.is_some_and(
|
||||
|cell| matches!(&cell.kind, CellKind::Text(text) if text.content.0.iter().any(|part| matches!(part, Part::Field { .. }))),
|
||||
)
|
||||
};
|
||||
sheet.title_block_overrides.retain(|name, _| !shows_a_field(name));
|
||||
}
|
||||
check(&template)?;
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// A template file's text, read as a drawing is ([`Drawing::from_json`]: its size cap, the format
|
||||
/// check and the damage rules), then held to the template rules.
|
||||
pub fn read(text: &str) -> Result<Drawing> {
|
||||
let template = Drawing::from_json(text)?;
|
||||
check(&template)?;
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// What applying a template changed on one sheet ([`apply`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Restyled {
|
||||
pub sheet: u64,
|
||||
pub paper: bool,
|
||||
pub border: bool,
|
||||
pub title_block: bool,
|
||||
/// The cells whose typed text is dropped: the new block has no text cell of that name.
|
||||
pub lost: Vec<String>,
|
||||
}
|
||||
|
||||
/// `drawing` in `template`'s sheet formats: each sheet takes the paper, drawing border and title
|
||||
/// block of the template's sheet in its place (its last for the sheets past its own), and keeps the
|
||||
/// text typed over a cell while the new block has a text cell of that name. Nothing on a sheet
|
||||
/// moves. Answers the drawing, and what changed on each sheet that did.
|
||||
pub fn apply(drawing: &Drawing, template: &Drawing) -> Result<(Drawing, Vec<Restyled>)> {
|
||||
check(template)?;
|
||||
let mut d = drawing.clone();
|
||||
let mut changed = Vec::new();
|
||||
for (at, sheet) in d.sheets.iter_mut().enumerate() {
|
||||
let Some(from) = template.sheets.get(at).or(template.sheets.last()) else { continue };
|
||||
let paper = sheet.sheet != from.sheet;
|
||||
let (border, title_block) = (sheet.border != from.border, sheet.title_block != from.title_block);
|
||||
// A sheet's typed text is always its own block's ([`Drawing::validate`]), so the same block loses none.
|
||||
if !(paper || border || title_block) {
|
||||
continue;
|
||||
}
|
||||
let sheet = Arc::make_mut(sheet);
|
||||
sheet.sheet = from.sheet;
|
||||
sheet.border = from.border;
|
||||
let lost = sheet.set_title_block(from.title_block.clone());
|
||||
changed.push(Restyled { sheet: sheet.id, paper, border, title_block, lost });
|
||||
}
|
||||
d.validate()?;
|
||||
Ok((d, changed))
|
||||
}
|
||||
|
||||
/// The built-in `name`'s paper size and its title ("ISO A3"); none for another name.
|
||||
fn parse(name: &str) -> Option<(PaperSize, String)> {
|
||||
let size = name.strip_prefix("iso-")?;
|
||||
let (size, label) = SIZES.into_iter().find(|(_, label)| label.to_ascii_lowercase() == size)?;
|
||||
Some((size, format!("ISO {label}")))
|
||||
}
|
||||
|
||||
/// The title of the built-in template `name` ("ISO A3"), without making it; none for another name.
|
||||
pub fn title(name: &str) -> Option<String> {
|
||||
parse(name).map(|(_, title)| title)
|
||||
}
|
||||
|
||||
/// The built-in template `name` ([`BUILT_INS`]), made now; none for another name.
|
||||
pub fn built_in(name: &str) -> Option<Result<Drawing>> {
|
||||
let (size, title) = parse(name)?;
|
||||
// A4 is used upright (ISO 5457); the larger sizes lie flat.
|
||||
let orientation = if size == PaperSize::A4 { Orientation::Portrait } else { Orientation::Landscape };
|
||||
Some(made(&title, drawing_sheet::Sheet::standard(size, orientation)))
|
||||
}
|
||||
|
||||
/// One sheet of `paper` with the ISO border.
|
||||
fn made(title: &str, paper: drawing_sheet::Sheet) -> Result<Drawing> {
|
||||
let mut id_source = IdSource::seeded(BUILT_IN_SEED);
|
||||
let d = Drawing::new(title, "en", "Sheet 1", paper, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut id_source)?;
|
||||
check(&d)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "template_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,192 @@
|
||||
use solidcraft_geom::cell::{Content, Projection};
|
||||
use solidcraft_geom::drawing_sheet::SheetSize;
|
||||
use solidcraft_geom::title_block;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::Sheet;
|
||||
use crate::drawing::tests::plate;
|
||||
|
||||
fn made(name: &str) -> Drawing {
|
||||
built_in(name).unwrap_or_else(|| panic!("no built-in {name}")).unwrap()
|
||||
}
|
||||
|
||||
/// The built-in `name` with the built-in block on its sheet.
|
||||
fn titled(name: &str) -> Drawing {
|
||||
let mut d = made(name);
|
||||
Arc::make_mut(&mut d.sheets[0]).set_title_block(Some(title_block::default_block()));
|
||||
check(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The built-in `name` with a block whose general tolerances are `text`: a shop's own template.
|
||||
fn shop(name: &str, text: &str) -> Drawing {
|
||||
let mut d = titled(name);
|
||||
let block = Arc::make_mut(&mut d.sheets[0]).title_block.as_mut().unwrap();
|
||||
let cell = block.cells.iter_mut().find(|cell| cell.name == "general_tolerances").unwrap();
|
||||
let CellKind::Text(cell) = &mut cell.kind else { panic!("the tolerances cell shows text") };
|
||||
cell.content = Content(vec![Part::Text(text.into())]);
|
||||
check(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Every built-in is a template on its ISO paper (A4 upright, the rest flat) with the ISO border
|
||||
/// and nothing more, the same bytes each time it is made. Another name is none.
|
||||
#[test]
|
||||
fn every_built_in_is_a_template_on_its_paper() {
|
||||
let mut seen = BTreeSet::new();
|
||||
assert!(BUILT_INS.contains(&DEFAULT));
|
||||
for &name in BUILT_INS {
|
||||
let d = made(name);
|
||||
check(&d).unwrap();
|
||||
assert_eq!(d.sheets.len(), 1, "{name}");
|
||||
let sheet = &d.sheets[0];
|
||||
let size = name.strip_prefix("iso-").unwrap();
|
||||
let SheetSize::Standard(paper) = sheet.sheet.size else { panic!("{name}: a standard size") };
|
||||
assert_eq!(serde_json::to_value(paper).unwrap(), serde_json::json!(size), "{name}");
|
||||
let upright = if size == "a4" { Orientation::Portrait } else { Orientation::Landscape };
|
||||
assert_eq!((sheet.sheet.orientation, &sheet.border), (upright, &Some(DrawingBorderProfile::iso())), "{name}");
|
||||
assert_eq!(sheet.title_block, None, "{name}: a border only");
|
||||
assert_eq!(d.name, format!("ISO {}", size.to_uppercase()));
|
||||
assert_eq!(title(name), Some(d.name.clone()), "{name}: its title without making it");
|
||||
assert_eq!(d.to_json().unwrap(), made(name).to_json().unwrap(), "{name}: the same bytes");
|
||||
assert!(seen.insert(d.to_json().unwrap()), "{name}: a built-in of its own");
|
||||
}
|
||||
for name in ["iso-a5", "iso-a3-part", "iso-a3-assembly", "iso-A3", "a3", ""] {
|
||||
assert!(built_in(name).is_none() && title(name).is_none(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A template is saved and read as a drawing is, and is still a template.
|
||||
#[test]
|
||||
fn a_template_reads_back_as_saved() {
|
||||
let mut d = shop("iso-a2", "ISO 13920-B");
|
||||
d.projection = Projection::First;
|
||||
let saved = d.to_json().unwrap();
|
||||
let read = Drawing::from_json(&saved).unwrap();
|
||||
check(&read).unwrap();
|
||||
assert_eq!(read, d);
|
||||
}
|
||||
|
||||
/// A drawing with anything of a model is no template: one message names all it has.
|
||||
#[test]
|
||||
fn a_template_names_no_model() {
|
||||
let refusal = check(&plate()).unwrap_err().to_string();
|
||||
let all = "a template draws no design, and this one has a source, views, dimensions, balloons, a sheet that documents a component";
|
||||
assert_eq!(refusal, all);
|
||||
let mut d = titled("iso-a3");
|
||||
Arc::make_mut(&mut d.revisions).add(None, 77).unwrap();
|
||||
assert_eq!(check(&d).unwrap_err().to_string(), "a template draws no design, and this one has revision rows");
|
||||
// A drawing's own rules come first.
|
||||
let mut d = titled("iso-a3");
|
||||
d.sheets.clear();
|
||||
assert!(check(&d).unwrap_err().to_string().contains("a drawing has 1 to 1000 sheets"));
|
||||
}
|
||||
|
||||
/// Applying a template gives each sheet the template's paper, border and block (its one sheet's to
|
||||
/// both of a drawing's two), keeping the text typed over a cell the new block still has; nothing
|
||||
/// changes for the same template; a drawing with a model is no template.
|
||||
#[test]
|
||||
fn a_template_applied_gives_each_sheet_its_format() {
|
||||
let mut d = instantiate(&titled("iso-a3"), "Bracket drawing", &BTreeSet::new(), &mut IdSource::seeded(1)).unwrap();
|
||||
let id = d.allocate_id(&mut IdSource::seeded(2)).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet { id, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }));
|
||||
for (cell, text) in [("material", "See note 3"), ("finish", "Black oxide")] {
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block_overrides.insert(cell.into(), text.into());
|
||||
}
|
||||
assert_eq!(apply(&d, &titled("iso-a3")).unwrap(), (d.clone(), Vec::new()), "the same template changes nothing");
|
||||
let (applied, changed) = apply(&d, &shop("iso-a2", "ISO 13920-B")).unwrap();
|
||||
let ids: Vec<u64> = d.sheets.iter().map(|s| s.id).collect();
|
||||
let restyled = |sheet| Restyled { sheet, paper: true, border: false, title_block: true, lost: Vec::new() };
|
||||
assert_eq!(changed, ids.iter().map(|id| restyled(*id)).collect::<Vec<_>>());
|
||||
let a2 = shop("iso-a2", "ISO 13920-B");
|
||||
for sheet in &applied.sheets {
|
||||
assert_eq!((sheet.sheet, &sheet.title_block), (a2.sheets[0].sheet, &a2.sheets[0].title_block));
|
||||
}
|
||||
assert_eq!(applied.sheets[0].title_block_overrides, d.sheets[0].title_block_overrides, "every cell is still there");
|
||||
assert_eq!((applied.id, applied.name.as_str(), &applied.sources), (d.id, "Bracket drawing", &d.sources));
|
||||
// A block without a finish cell: its typed text is lost, and said so.
|
||||
let mut renamed = titled("iso-a3");
|
||||
let block = Arc::make_mut(&mut renamed.sheets[0]).title_block.as_mut().unwrap();
|
||||
block.cells.iter_mut().find(|cell| cell.name == "finish").unwrap().name = "surface".into();
|
||||
let (applied, changed) = apply(&d, &renamed).unwrap();
|
||||
assert_eq!(changed[0], Restyled { sheet: ids[0], paper: false, border: false, title_block: true, lost: vec!["finish".into()] });
|
||||
let kept: Vec<&str> = applied.sheets[0].title_block_overrides.keys().map(String::as_str).collect();
|
||||
assert_eq!(kept, ["material"]);
|
||||
assert!(apply(&d, &plate()).unwrap_err().to_string().starts_with("a template draws no design"));
|
||||
}
|
||||
|
||||
/// A drawing saved as a template keeps its sheets and leaves its model behind, and reads back as
|
||||
/// one; a drawing's own file is no template.
|
||||
#[test]
|
||||
fn a_drawing_saved_as_a_template_keeps_only_its_format() {
|
||||
let mut d = plate();
|
||||
d.properties = Arc::new(serde_json::from_value(serde_json::json!({"part_number": "D-1234", "project": "Rig"})).unwrap());
|
||||
let template = from_drawing(&d).unwrap();
|
||||
check(&template).unwrap();
|
||||
assert!(template.sources.is_empty() && template.views.is_empty() && template.dimensions.is_empty() && template.balloons.is_empty());
|
||||
assert!(template.sheets.iter().all(|sheet| sheet.documents.is_none()));
|
||||
let format = |sheet: &Sheet| (sheet.id, sheet.name.clone(), sheet.sheet, sheet.border, sheet.title_block.clone(), sheet.dpi);
|
||||
assert_eq!(template.sheets.iter().map(|s| format(s)).collect::<Vec<_>>(), d.sheets.iter().map(|s| format(s)).collect::<Vec<_>>());
|
||||
assert_eq!((template.projection, &template.units), (d.projection, &d.units));
|
||||
// The drawing's number is its own; its other properties come along.
|
||||
assert_eq!((template.properties.part_number(), template.properties.project()), (None, Some("Rig")));
|
||||
assert_eq!(read(&template.to_json().unwrap()).unwrap(), template);
|
||||
// Text typed over a cell of plain text stays; over a cell that shows a field, it goes with the
|
||||
// design that field reads.
|
||||
let sheet = Arc::make_mut(&mut d.sheets[0]);
|
||||
sheet.set_title_block(Some(title_block::default_block()));
|
||||
for (cell, text) in [("general_tolerances", "ISO 2768-m"), ("material", "See note 3"), ("title", "Plate, left")] {
|
||||
sheet.title_block_overrides.insert(cell.into(), text.into());
|
||||
}
|
||||
let kept: Vec<String> = from_drawing(&d).unwrap().sheets[0].title_block_overrides.keys().cloned().collect();
|
||||
assert_eq!(kept, ["general_tolerances"]);
|
||||
let refusal = read(&d.to_json().unwrap()).unwrap_err().to_string();
|
||||
assert!(refusal.starts_with("a template draws no design"), "{refusal}");
|
||||
}
|
||||
|
||||
/// A damaged template file, cut short or with a byte changed anywhere, is read or refused, never
|
||||
/// a panic.
|
||||
#[test]
|
||||
fn a_damaged_template_file_never_panics() {
|
||||
let text = titled("iso-a3").to_json().unwrap();
|
||||
let mut read_back = 0;
|
||||
for at in (0..text.len()).step_by(7) {
|
||||
let _ = text.get(..at).map(read);
|
||||
for byte in *b"\"9{-x" {
|
||||
let mut bytes = text.clone().into_bytes();
|
||||
bytes[at] = byte;
|
||||
if let Ok(changed) = String::from_utf8(bytes) {
|
||||
read_back += usize::from(read(&changed).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(read_back > 0, "some changes leave a template that still reads (in a name or a number)");
|
||||
}
|
||||
|
||||
/// A drawing from a template has its own ids, none of the template's or of `taken`, the same ones
|
||||
/// again from the same seed; the name it is given; and everything else of the template, the text
|
||||
/// typed over its cells too.
|
||||
#[test]
|
||||
fn a_drawing_from_a_template_has_ids_of_its_own() {
|
||||
let mut template = titled("iso-a1");
|
||||
let first = (*template.sheets[0]).clone();
|
||||
template.sheets.push(Arc::new(Sheet { id: 5, name: "Sheet 2".into(), ..first }));
|
||||
Arc::make_mut(&mut template.sheets[1]).title_block_overrides.insert("material".into(), "See note 3".into());
|
||||
check(&template).unwrap();
|
||||
let taken = BTreeSet::from([template.id]);
|
||||
let d = instantiate(&template, "Bracket drawing", &taken, &mut IdSource::seeded(9)).unwrap();
|
||||
let ids = |d: &Drawing| std::iter::once(d.id).chain(d.sheets.iter().map(|s| s.id)).collect::<Vec<_>>();
|
||||
assert!(ids(&d).iter().all(|id| !ids(&template).contains(id) && !taken.contains(id)), "{:?} {:?}", ids(&d), ids(&template));
|
||||
assert_eq!(ids(&d), ids(&instantiate(&template, "Bracket drawing", &taken, &mut IdSource::seeded(9)).unwrap()));
|
||||
assert_ne!(ids(&d), ids(&instantiate(&template, "Bracket drawing", &taken, &mut IdSource::seeded(10)).unwrap()));
|
||||
assert_eq!(d.name, "Bracket drawing");
|
||||
let same = |d: &Drawing| {
|
||||
let sheets: Vec<Sheet> = d.sheets.iter().map(|s| Sheet { id: 0, ..(**s).clone() }).collect();
|
||||
(sheets, d.projection, d.units.clone(), d.language.clone(), d.properties.clone())
|
||||
};
|
||||
assert_eq!(same(&d), same(&template));
|
||||
check(&d).unwrap();
|
||||
// A drawing with a model makes none.
|
||||
let refusal = instantiate(&plate(), "Copy", &taken, &mut IdSource::seeded(9)).unwrap_err().to_string();
|
||||
assert!(refusal.starts_with("a template draws no design"), "{refusal}");
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! A drawing's views (W17a, #4816): each a view of one of the drawing's designs, on one of its
|
||||
//! sheets. A **base view** draws a source from a direction ([`ViewDirection`]). A **projected
|
||||
//! view** sits on one side of its parent and looks from that side, or from the side away from it
|
||||
//! in first angle ([`Drawing::projection`], [`projected_basis`]); lined up with its parent, it saves
|
||||
//! only its place along the free axis, as the other comes from the parent ([`Drawing::view_centre`]).
|
||||
//!
|
||||
//! What a view sets itself is in [`ViewOverrides`]: each setting it leaves out follows its parent,
|
||||
//! then the default, and [`Drawing::resolve_view`] is that one rule for every reader. A projected
|
||||
//! view's source and basis come from its chain of parents, so neither is saved.
|
||||
//!
|
||||
//! The rules, beside the record's: a view is on one of the drawing's sheets; a base view draws one
|
||||
//! of its sources, has a scale and looks from a direction that makes a view; every parent is a view
|
||||
//! of the drawing, no chain of parents loops, and a chain is at most [`MAX_VIEW_DEPTH`] views; a
|
||||
//! view lined up with its parent is on the parent's sheet; a view's place is on paper
|
||||
//! ([`is_paper_coordinate`]); at most [`MAX_VIEWS`] views, listed by id ([`Drawing::add_views`]).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::drawing_view::{ProjectedSide, Scale, is_paper_coordinate, projected_basis};
|
||||
use solidcraft_geom::paper::MAX_SIDE_MM;
|
||||
use solidcraft_geom::{Plane, Vec2};
|
||||
use solidcraft_render::VisualStyle;
|
||||
|
||||
use super::{Drawing, add_by_id, in_id_order, require};
|
||||
use crate::drawing_view::{MAX_REFERENCE_SHOWN_PERCENT, TangentEdges, ViewDirection, ViewOverrides, ViewSettings};
|
||||
use crate::{DocError, Result, ids};
|
||||
|
||||
/// The most views a drawing may have.
|
||||
pub const MAX_VIEWS: usize = 4096;
|
||||
/// The most views in a chain, from a base view down through the views projected from it.
|
||||
pub const MAX_VIEW_DEPTH: usize = 32;
|
||||
|
||||
/// One view of a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct View {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// The sheet it is on.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub sheet: u64,
|
||||
pub kind: ViewKind,
|
||||
/// Where its centre sits on its sheet.
|
||||
pub place: ViewPlace,
|
||||
/// What it sets itself; each setting left out follows its parent, then the default.
|
||||
#[serde(default, skip_serializing_if = "ViewOverrides::is_empty")]
|
||||
pub settings: ViewOverrides,
|
||||
/// How far its boundary grows from the model's box towards the box that also holds its reference
|
||||
/// parts, each side on its own: 0 to [`MAX_REFERENCE_SHOWN_PERCENT`], each view its own.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub reference_shown_percent: f64,
|
||||
}
|
||||
|
||||
/// What a view draws, and from where.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ViewKind {
|
||||
/// The design of `source`, seen from `view`.
|
||||
Base {
|
||||
#[serde(with = "crate::ids")]
|
||||
source: u64,
|
||||
view: ViewDirection,
|
||||
},
|
||||
/// Projected from `parent`, on `side` of it.
|
||||
Projected {
|
||||
#[serde(with = "crate::ids")]
|
||||
parent: u64,
|
||||
side: ProjectedSide,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where a view's centre sits on its sheet, in paper millimetres.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ViewPlace {
|
||||
/// At this point of the sheet.
|
||||
At([f64; 2]),
|
||||
/// Lined up with its parent, this far along the free axis: across for a view to the left or
|
||||
/// right of it, up for one above or below. A projected view only.
|
||||
Aligned(f64),
|
||||
}
|
||||
|
||||
/// Who gave a resolved setting its value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SetBy {
|
||||
/// The view itself.
|
||||
This,
|
||||
/// The parent of this id, the nearest that sets it.
|
||||
Parent(u64),
|
||||
/// No view of the chain: the default.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// A setting's value and who gave it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Setting<T> {
|
||||
pub value: T,
|
||||
pub set_by: SetBy,
|
||||
}
|
||||
|
||||
/// A view as it draws: the source it draws, which way it looks, and each setting with who gave it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ResolvedView {
|
||||
pub source: u64,
|
||||
pub basis: Plane,
|
||||
pub scale: Setting<Scale>,
|
||||
pub display_style: Setting<VisualStyle>,
|
||||
pub reference_style: Setting<VisualStyle>,
|
||||
pub reference_hides_model: Setting<bool>,
|
||||
pub tangent_edges: Setting<TangentEdges>,
|
||||
pub reference_shown_percent: f64,
|
||||
}
|
||||
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
/// The views from `id` up to its base view, nearest first, each found by `find`: refused when a
|
||||
/// parent isn't one, the chain loops, or it is longer than [`MAX_VIEW_DEPTH`].
|
||||
fn chain<'a>(id: u64, find: impl Fn(u64) -> Option<&'a View>) -> Result<Vec<&'a View>> {
|
||||
let mut chain: Vec<&View> = Vec::new();
|
||||
let mut at = id;
|
||||
loop {
|
||||
let view = find(at).ok_or_else(|| match chain.as_slice() {
|
||||
[] => DocError::Invalid(format!("the drawing has no view {}", ids::encode(at))),
|
||||
[_] => DocError::Invalid(format!("its parent {} isn't one of the drawing's views", ids::encode(at))),
|
||||
// Further up, the view whose parent is missing is named: it is that view's parent.
|
||||
[.., child] => DocError::Invalid(format!(
|
||||
"its chain of parents reaches view {}, whose parent {} isn't one of the drawing's views",
|
||||
ids::encode(child.id),
|
||||
ids::encode(at)
|
||||
)),
|
||||
})?;
|
||||
require(chain.iter().all(|v| v.id != at), || "its chain of parents loops".into())?;
|
||||
chain.push(view);
|
||||
match view.kind {
|
||||
ViewKind::Base { .. } => return Ok(chain),
|
||||
ViewKind::Projected { parent, .. } => at = parent,
|
||||
}
|
||||
require(chain.len() < MAX_VIEW_DEPTH, || format!("a chain of views is at most {MAX_VIEW_DEPTH} views"))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest view of `chain` (the view first) that sets a setting `field` reads, with its value.
|
||||
fn inherited<T: Copy>(chain: &[&View], field: impl Fn(&ViewOverrides) -> Option<T>) -> Option<Setting<T>> {
|
||||
chain.iter().enumerate().find_map(|(i, view)| {
|
||||
let set_by = if i == 0 { SetBy::This } else { SetBy::Parent(view.id) };
|
||||
field(&view.settings).map(|value| Setting { value, set_by })
|
||||
})
|
||||
}
|
||||
|
||||
/// [`inherited`], or the default `value` when no view of the chain sets it.
|
||||
fn or_default<T: Copy>(chain: &[&View], field: impl Fn(&ViewOverrides) -> Option<T>, value: T) -> Setting<T> {
|
||||
inherited(chain, field).unwrap_or(Setting { value, set_by: SetBy::Default })
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// Adds `views`, each where its id puts it: a drawing lists its views by id, never in the order
|
||||
/// they were added in, so the same views always save as the same bytes. Every edit keeps the
|
||||
/// order ([`Drawing::validate`]), and reading puts a file's views in it.
|
||||
pub fn add_views(&mut self, views: impl IntoIterator<Item = Arc<View>>) {
|
||||
add_by_id(&mut self.views, views, |v| v.id);
|
||||
}
|
||||
|
||||
/// View `id`, found by halves in the views' id order.
|
||||
pub fn view(&self, id: u64) -> Option<&View> {
|
||||
self.view_index(id).and_then(|i| self.views.get(i)).map(|v| &**v)
|
||||
}
|
||||
|
||||
/// `views` and every view projected from them, directly or through others. A parent can sort
|
||||
/// after its child, so each pass over the views finds at least one more level of a chain, which
|
||||
/// is at most [`MAX_VIEW_DEPTH`] views.
|
||||
pub fn with_projected(&self, views: &BTreeSet<u64>) -> BTreeSet<u64> {
|
||||
let mut all = views.clone();
|
||||
for _ in 0..MAX_VIEW_DEPTH {
|
||||
let before = all.len();
|
||||
for view in &self.views {
|
||||
if matches!(view.kind, ViewKind::Projected { parent, .. } if all.contains(&parent)) {
|
||||
all.insert(view.id);
|
||||
}
|
||||
}
|
||||
if all.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Where view `id` is in [`Drawing::views`].
|
||||
pub fn view_index(&self, id: u64) -> Option<usize> {
|
||||
self.views.binary_search_by_key(&id, |v| v.id).ok()
|
||||
}
|
||||
|
||||
/// The views' rules (the module's notes).
|
||||
pub(super) fn check_views(&self) -> Result<()> {
|
||||
require(self.views.len() <= MAX_VIEWS, || format!("a drawing has at most {MAX_VIEWS} views"))?;
|
||||
// In id order, so each view after this is found by halves.
|
||||
require(in_id_order(&self.views, |v| v.id), || "a drawing lists its views by id".into())?;
|
||||
for view in &self.views {
|
||||
self.check_view(view).map_err(|e| DocError::Invalid(format!("view {}: {e}", ids::encode(view.id))))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_view(&self, view: &View) -> Result<()> {
|
||||
let on = |sheet: u64| format!("it is on sheet {}, which the drawing doesn't have", ids::encode(sheet));
|
||||
require(self.sheets.iter().any(|s| s.id == view.sheet), || on(view.sheet))?;
|
||||
let percent = view.reference_shown_percent;
|
||||
require(percent.is_finite() && (0.0..=MAX_REFERENCE_SHOWN_PERCENT).contains(&percent), || {
|
||||
format!("its reference_shown_percent is 0 to {MAX_REFERENCE_SHOWN_PERCENT}")
|
||||
})?;
|
||||
let place = match view.place {
|
||||
ViewPlace::At([x, y]) => is_paper_coordinate(x) && is_paper_coordinate(y),
|
||||
ViewPlace::Aligned(along) => is_paper_coordinate(along),
|
||||
};
|
||||
require(place, || format!("its place is at most {MAX_SIDE_MM} mm from the sheet's origin"))?;
|
||||
match view.kind {
|
||||
ViewKind::Base { source, view: direction } => {
|
||||
let drawn = || format!("it draws source {}, which the drawing doesn't have", ids::encode(source));
|
||||
require(self.sources.iter().any(|s| s.id == source), drawn)?;
|
||||
require(view.settings.scale.is_some(), || "a base view has a scale".into())?;
|
||||
require(matches!(view.place, ViewPlace::At(_)), || "a base view is placed at a point: it has no parent to line up with".into())?;
|
||||
direction.basis().map(|_| ()).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
ViewKind::Projected { parent, .. } => {
|
||||
self.chain_of(view.id)?;
|
||||
let parent_sheet = self.view(parent).map(|p| p.sheet);
|
||||
let lined_up = matches!(view.place, ViewPlace::Aligned(_));
|
||||
require(!lined_up || parent_sheet == Some(view.sheet), || "a view lined up with its parent is on its parent's sheet".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View `id` as it draws: its base view's source, its basis turned down its chain of parents
|
||||
/// ([`projected_basis`], in the drawing's projection), and each setting from the nearest view
|
||||
/// of the chain that sets it, then the default. One rule for every reader of a view.
|
||||
pub fn resolve_view(&self, id: u64) -> Result<ResolvedView> {
|
||||
let chain = self.chain_of(id)?;
|
||||
let Some(ViewKind::Base { source, view: direction }) = chain.last().map(|base| base.kind) else {
|
||||
return Err(DocError::Invalid(format!("view {} has no base view", ids::encode(id))));
|
||||
};
|
||||
let mut basis = direction.basis().map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
for view in chain.iter().rev() {
|
||||
if let ViewKind::Projected { side, .. } = view.kind {
|
||||
basis = projected_basis(&basis, side, self.projection).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let scale = inherited(&chain, |s| s.scale).ok_or_else(|| DocError::Invalid(format!("view {} has no scale", ids::encode(id))))?;
|
||||
let defaults = ViewSettings::default();
|
||||
Ok(ResolvedView {
|
||||
source,
|
||||
basis,
|
||||
scale,
|
||||
display_style: or_default(&chain, |s| s.display_style, defaults.display_style),
|
||||
reference_style: or_default(&chain, |s| s.reference_style, defaults.reference_style),
|
||||
reference_hides_model: or_default(&chain, |s| s.reference_hides_model, defaults.reference_hides_model),
|
||||
tangent_edges: or_default(&chain, |s| s.tangent_edges, defaults.tangent_edges),
|
||||
reference_shown_percent: chain.first().map_or(0.0, |view| view.reference_shown_percent),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where view `id`'s centre sits on its sheet, in paper millimetres. A view placed at a point
|
||||
/// is there. One lined up with its parent takes its own place along the free axis and its
|
||||
/// parent's centre across it ([`ProjectedSide::on_free_axis`]): both draw the same model, so
|
||||
/// their extents share that axis's middle, and they line up whatever their scales.
|
||||
pub fn view_centre(&self, id: u64) -> Result<Vec2> {
|
||||
let mut centre = Vec2::ZERO;
|
||||
// From the base view down to this one, each lined up with the one before or placed apart.
|
||||
for view in self.chain_of(id)?.iter().rev() {
|
||||
centre = match (view.place, view.kind) {
|
||||
(ViewPlace::At([x, y]), _) => Vec2::new(x, y),
|
||||
(ViewPlace::Aligned(along), ViewKind::Projected { side, .. }) => side.on_free_axis(centre, along),
|
||||
(ViewPlace::Aligned(_), ViewKind::Base { .. }) => {
|
||||
return Err(DocError::Invalid(format!("base view {} isn't placed at a point", ids::encode(view.id))));
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(centre)
|
||||
}
|
||||
|
||||
/// The views from `id` up to its base view, nearest first ([`chain`]).
|
||||
fn chain_of(&self, id: u64) -> Result<Vec<&View>> {
|
||||
chain(id, |id| self.view(id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::cell::Projection;
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::{Excluded, Sheet, Source};
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const SEED: u64 = 4816;
|
||||
|
||||
/// A drawing of one design on one A3 sheet, its front view at 1:2 centred at (150, 150), and the
|
||||
/// id source that made it.
|
||||
fn front() -> (Drawing, IdSource, u64) {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Plate", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let source = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id: source });
|
||||
let front = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
let id = add(&mut d, &mut id_source, front, ViewPlace::At([150.0, 150.0]), scaled("1:2"));
|
||||
(d, id_source, id)
|
||||
}
|
||||
|
||||
/// A view on the drawing's first sheet, showing no more for its reference parts.
|
||||
fn add(d: &mut Drawing, id_source: &mut IdSource, kind: ViewKind, place: ViewPlace, settings: ViewOverrides) -> u64 {
|
||||
let id = d.allocate_id(id_source).unwrap();
|
||||
let sheet = d.sheets[0].id;
|
||||
d.add_views([Arc::new(View { id, sheet, kind, place, settings, reference_shown_percent: 0.0 })]);
|
||||
id
|
||||
}
|
||||
|
||||
/// View `id` of `d`, to change.
|
||||
fn view_mut(d: &mut Drawing, id: u64) -> &mut View {
|
||||
d.views.iter_mut().find(|v| v.id == id).map(Arc::make_mut).unwrap()
|
||||
}
|
||||
|
||||
fn projected(parent: u64, side: ProjectedSide) -> ViewKind {
|
||||
ViewKind::Projected { parent, side }
|
||||
}
|
||||
|
||||
fn scaled(scale: &str) -> ViewOverrides {
|
||||
ViewOverrides { scale: Some(scale.parse().unwrap()), ..ViewOverrides::default() }
|
||||
}
|
||||
|
||||
fn styled(style: VisualStyle) -> ViewOverrides {
|
||||
ViewOverrides { display_style: Some(style), ..ViewOverrides::default() }
|
||||
}
|
||||
|
||||
/// The front view, its right view lined up with it and drawing hidden lines too, and the right
|
||||
/// view's top view placed apart.
|
||||
fn three() -> (Drawing, IdSource, [u64; 3]) {
|
||||
let (mut d, mut id_source, front) = front();
|
||||
let hidden = styled(VisualStyle::VisibleHidden);
|
||||
let right = add(&mut d, &mut id_source, projected(front, ProjectedSide::Right), ViewPlace::Aligned(320.0), hidden);
|
||||
let top = add(&mut d, &mut id_source, projected(right, ProjectedSide::Above), ViewPlace::At([320.0, 250.0]), ViewOverrides::default());
|
||||
d.validate().unwrap();
|
||||
(d, id_source, [front, right, top])
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Why `d`'s file, changed by `edit`, is refused, or "read".
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
/// A view saves its kind, its place and what it sets itself, and nothing it leaves out; the views
|
||||
/// save by id, and the drawing saves its projection. It reads back as saved, the same bytes again,
|
||||
/// and an older drawing reads with no views, in third angle.
|
||||
#[test]
|
||||
fn views_save_what_they_set_and_read_back_as_saved() {
|
||||
let (d, _, [front, right, top]) = three();
|
||||
let v = file(&d);
|
||||
let [front_id, right_id, top_id, sheet, source] = [front, right, top, d.sheets[0].id, d.sources[0].id].map(ids::encode);
|
||||
assert_eq!(v["projection"], json!("third"));
|
||||
assert_eq!(
|
||||
v["views"],
|
||||
json!([
|
||||
{"id": front_id, "sheet": sheet, "kind": {"base": {"source": source, "view": "front"}}, "place": {"at": [150.0, 150.0]}, "settings": {"scale": "1:2"}},
|
||||
{"id": top_id, "sheet": sheet, "kind": {"projected": {"parent": right_id, "side": "above"}}, "place": {"at": [320.0, 250.0]}},
|
||||
{"id": right_id, "sheet": sheet, "kind": {"projected": {"parent": front_id, "side": "right"}}, "place": {"aligned": 320.0}, "settings": {"display_style": "visible_hidden"}},
|
||||
])
|
||||
);
|
||||
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!(reopened.to_json().unwrap(), d.to_json().unwrap());
|
||||
let mut first = v.clone();
|
||||
first["projection"] = json!("first");
|
||||
assert_eq!(Drawing::from_json(&first.to_string()).unwrap().projection, Projection::First);
|
||||
let older = json!({
|
||||
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
|
||||
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
|
||||
});
|
||||
let old = Drawing::from_json(&older.to_string()).unwrap();
|
||||
assert!(old.views.is_empty() && old.projection == Projection::Third);
|
||||
assert_eq!(file(&old).get("views"), None, "no views, none written");
|
||||
}
|
||||
|
||||
/// A view takes the views projected from it, through others and whichever way their ids sort, and
|
||||
/// nothing above it or beside it; none given, none taken.
|
||||
#[test]
|
||||
fn a_views_projected_views_are_found_down_its_chains() {
|
||||
let (mut d, mut id_source, [front, right, top]) = three();
|
||||
let left = add(&mut d, &mut id_source, projected(front, ProjectedSide::Left), ViewPlace::Aligned(20.0), ViewOverrides::default());
|
||||
let set = |ids: &[u64]| ids.iter().copied().collect::<BTreeSet<u64>>();
|
||||
assert_eq!(d.with_projected(&set(&[front])), set(&[front, right, top, left]));
|
||||
assert_eq!(d.with_projected(&set(&[right])), set(&[right, top]));
|
||||
assert_eq!(d.with_projected(&set(&[top, left])), set(&[top, left]));
|
||||
assert_eq!(d.with_projected(&set(&[])), set(&[]));
|
||||
assert_eq!(d.with_projected(&set(&[99])), set(&[99]));
|
||||
}
|
||||
|
||||
/// A drawing lists its views by id, whatever order they were added in: the same views added the
|
||||
/// other way round make the same drawing and save as the same bytes. A file that lists them in
|
||||
/// another order (saved before views went by id, or edited by hand) reads by id and saves as those
|
||||
/// bytes. An edit that leaves them in another order breaks the record's rule.
|
||||
#[test]
|
||||
fn views_are_listed_by_id_whatever_order_they_were_added_in() {
|
||||
let (d, _, added) = three();
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_ne!(by_id, added, "the seed's ids aren't in the order the views were added in");
|
||||
let listed = |d: &Drawing| d.views.iter().map(|v| v.id).collect::<Vec<_>>();
|
||||
let saved: Vec<u64> = file(&d)["views"].as_array().unwrap().iter().map(|v| ids::decode(v["id"].as_str().unwrap()).unwrap()).collect();
|
||||
assert_eq!((listed(&d), saved), (by_id.clone(), by_id.clone()));
|
||||
let mut reversed = d.clone();
|
||||
reversed.views.clear();
|
||||
reversed.add_views(d.views.iter().rev().cloned());
|
||||
assert_eq!(reversed, d);
|
||||
let bytes = d.to_json().unwrap();
|
||||
assert_eq!(reversed.to_json().unwrap(), bytes);
|
||||
let mut other_order = file(&d);
|
||||
other_order["views"].as_array_mut().unwrap().reverse();
|
||||
let reopened = Drawing::from_json(&other_order.to_string()).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!((listed(&reopened), reopened.to_json().unwrap()), (by_id, bytes));
|
||||
let mut unsorted = d.clone();
|
||||
unsorted.views.reverse();
|
||||
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its views by id");
|
||||
}
|
||||
|
||||
/// What reading refuses, each rule by name: where a view is and what it draws, its parents, its
|
||||
/// place and margin, its settings, its id, and how many views there are.
|
||||
#[test]
|
||||
fn the_views_rules_are_kept_on_reading() {
|
||||
let (mut d, mut id_source, [front, right, top]) = three();
|
||||
let [front_id, right_id, top_id] = [front, right, top].map(ids::encode);
|
||||
let nothing = ids::encode(99);
|
||||
let rule = |id: &str, rule: &str| format!("damaged drawing: view {id}: {rule}");
|
||||
// The file lists the views by id: front ([0]), top ([1]), then right ([2]).
|
||||
// Where it is and what it draws.
|
||||
let on = format!("it is on sheet {nothing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| v["views"][2]["sheet"] = json!(nothing)), rule(&right_id, &on));
|
||||
let draws = format!("it draws source {nothing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| v["views"][0]["kind"]["base"]["source"] = json!(nothing)), rule(&front_id, &draws));
|
||||
let no_scale = refused(&d, |v| {
|
||||
v["views"][0].as_object_mut().unwrap().remove("settings");
|
||||
});
|
||||
assert_eq!(no_scale, rule(&front_id, "a base view has a scale"));
|
||||
let lined_up = refused(&d, |v| v["views"][0]["place"] = json!({"aligned": 10.0}));
|
||||
assert_eq!(lined_up, rule(&front_id, "a base view is placed at a point: it has no parent to line up with"));
|
||||
let looking = |direction: Value| refused(&d, |v| v["views"][0]["kind"]["base"]["view"] = json!({"direction": direction, "up": [0.0, 0.0, 1.0]}));
|
||||
assert_eq!(looking(json!([0.0, 1.0, 0.0])), "read");
|
||||
assert_eq!(looking(json!([0.0, 0.0, 0.0])), rule(&front_id, "drawing view: the view direction is zero or not finite"));
|
||||
assert_eq!(looking(json!([0.0, 0.0, -1.0])), rule(&front_id, "drawing view: `up` lies along the view direction: give another up"));
|
||||
// Its parents: one that isn't a view, itself, and loops of two and three views.
|
||||
let parent = |view: usize, parent: &str| {
|
||||
let parent = parent.to_string();
|
||||
move |v: &mut Value| v["views"][view]["kind"] = json!({"projected": {"parent": parent, "side": "left"}})
|
||||
};
|
||||
let missing = format!("its parent {nothing} isn't one of the drawing's views");
|
||||
assert_eq!(refused(&d, parent(1, ¬hing)), rule(&top_id, &missing));
|
||||
// Further up the chain, the view whose parent is missing is named. The top view is read
|
||||
// before the right view, so its chain finds the right view's missing parent.
|
||||
let further = format!("its chain of parents reaches view {right_id}, whose parent {nothing} isn't one of the drawing's views");
|
||||
assert_eq!(refused(&d, parent(2, ¬hing)), rule(&top_id, &further));
|
||||
assert_eq!(refused(&d, parent(1, &front_id)), "read");
|
||||
assert_eq!(refused(&d, parent(1, &top_id)), rule(&top_id, "its chain of parents loops"));
|
||||
assert_eq!(refused(&d, parent(0, &right_id)), rule(&front_id, "its chain of parents loops"), "two views");
|
||||
assert_eq!(refused(&d, parent(0, &top_id)), rule(&front_id, "its chain of parents loops"), "three views");
|
||||
// A view lined up with its parent is on its parent's sheet; one placed apart may be on any.
|
||||
let other = d.allocate_id(&mut id_source).unwrap();
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
d.sheets.push(Arc::new(Sheet {
|
||||
id: other,
|
||||
name: "Other".into(),
|
||||
sheet: a4,
|
||||
border: None,
|
||||
title_block: None,
|
||||
title_block_overrides: std::collections::BTreeMap::new(),
|
||||
dpi: 300.0,
|
||||
documents: None,
|
||||
excluded: Excluded::default(),
|
||||
}));
|
||||
let other_id = ids::encode(other);
|
||||
assert_eq!(
|
||||
refused(&d, |v| v["views"][2]["sheet"] = json!(other_id)),
|
||||
rule(&right_id, "a view lined up with its parent is on its parent's sheet")
|
||||
);
|
||||
assert_eq!(refused(&d, |v| v["views"][1]["sheet"] = json!(other_id)), "read");
|
||||
// Its place and its reference_shown_percent.
|
||||
let off_paper = "its place is at most 5000 mm from the sheet's origin";
|
||||
assert_eq!(refused(&d, |v| v["views"][1]["place"] = json!({"at": [5000.0, -5000.0]})), "read");
|
||||
assert_eq!(refused(&d, |v| v["views"][1]["place"] = json!({"at": [5000.001, 0.0]})), rule(&top_id, off_paper));
|
||||
assert_eq!(refused(&d, |v| v["views"][2]["place"] = json!({"aligned": -5000.001})), rule(&right_id, off_paper));
|
||||
for (percent, read) in [(0.0, true), (100.0, true), (-0.001, false), (100.001, false)] {
|
||||
let why = refused(&d, |v| v["views"][1]["reference_shown_percent"] = json!(percent));
|
||||
let expected = if read { "read".to_string() } else { rule(&top_id, "its reference_shown_percent is 0 to 100") };
|
||||
assert_eq!(why, expected, "{percent}");
|
||||
}
|
||||
// Its settings and its fields, only those it knows; a resolution is the sheet's, not a view's.
|
||||
let unknown: [fn(&mut Value); 5] = [
|
||||
|v| v["views"][0]["label"] = json!("A"),
|
||||
|v| v["views"][0]["settings"]["dpi"] = json!(300.0),
|
||||
|v| v["views"][0]["kind"]["base"]["scale"] = json!("1:1"),
|
||||
|v| v["views"][1]["kind"]["projected"]["aligned"] = json!(true),
|
||||
|v| v["views"][0]["place"] = json!({"at": [1.0, 2.0], "aligned": 3.0}),
|
||||
];
|
||||
for (i, edit) in unknown.into_iter().enumerate() {
|
||||
assert!(refused(&d, edit).starts_with("damaged drawing: "), "{i}");
|
||||
}
|
||||
assert!(refused(&d, |v| v["views"][0]["settings"]["scale"] = json!("1:0")).starts_with("damaged drawing: "));
|
||||
// Its id is the drawing's: never another object's.
|
||||
let sheet_id = ids::encode(d.sheets[0].id);
|
||||
assert_eq!(refused(&d, |v| v["views"][2]["id"] = json!(front_id)), format!("damaged drawing: two objects have the id {front_id}"));
|
||||
assert_eq!(refused(&d, |v| v["views"][2]["id"] = json!(sheet_id)), format!("damaged drawing: two objects have the id {sheet_id}"));
|
||||
// At most MAX_VIEWS views.
|
||||
let many = |n: usize| {
|
||||
let mut v = file(&d);
|
||||
let one = v["views"][0].clone();
|
||||
v["views"] = Value::Array(
|
||||
(1..=n as u64)
|
||||
.map(|i| {
|
||||
let mut view = one.clone();
|
||||
view["id"] = json!(ids::encode(i));
|
||||
view
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Drawing::from_json(&v.to_string()).map(|d| d.views.len()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(many(MAX_VIEWS), Ok(MAX_VIEWS));
|
||||
assert_eq!(many(MAX_VIEWS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_VIEWS} views")));
|
||||
}
|
||||
|
||||
/// A chain of views is at most MAX_VIEW_DEPTH views, the base view one of them.
|
||||
#[test]
|
||||
fn a_chain_of_views_is_at_most_its_depth() {
|
||||
let (mut d, mut id_source, front) = front();
|
||||
let mut last = front;
|
||||
for _ in 1..MAX_VIEW_DEPTH {
|
||||
last = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
|
||||
}
|
||||
assert_eq!(d.views.len(), MAX_VIEW_DEPTH);
|
||||
d.validate().unwrap();
|
||||
let deepest = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
|
||||
let why = d.validate().unwrap_err().to_string();
|
||||
assert_eq!(why, format!("view {}: a chain of views is at most {MAX_VIEW_DEPTH} views", ids::encode(deepest)));
|
||||
}
|
||||
|
||||
/// A view resolves through its chain of parents: its basis turned down the chain in the drawing's
|
||||
/// projection, each setting from the nearest view that sets it, then the default, saying who gave
|
||||
/// it, and its own reference_shown_percent.
|
||||
#[test]
|
||||
fn a_view_resolves_through_its_chain() {
|
||||
let (mut d, _, [front, right, top]) = three();
|
||||
let view = view_mut(&mut d, top);
|
||||
view.settings.tangent_edges = Some(TangentEdges::Show);
|
||||
view.reference_shown_percent = 2.0;
|
||||
let resolved = d.resolve_view(top).unwrap();
|
||||
assert_eq!(resolved.source, d.sources[0].id);
|
||||
let right_basis = projected_basis(&NamedView::Front.basis(), ProjectedSide::Right, Projection::Third).unwrap();
|
||||
assert_eq!(resolved.basis, projected_basis(&right_basis, ProjectedSide::Above, Projection::Third).unwrap());
|
||||
assert_eq!(resolved.scale, Setting { value: "1:2".parse().unwrap(), set_by: SetBy::Parent(front) });
|
||||
assert_eq!(resolved.display_style, Setting { value: VisualStyle::VisibleHidden, set_by: SetBy::Parent(right) });
|
||||
assert_eq!(resolved.tangent_edges, Setting { value: TangentEdges::Show, set_by: SetBy::This });
|
||||
assert_eq!(resolved.reference_style, Setting { value: VisualStyle::Visible, set_by: SetBy::Default });
|
||||
assert_eq!(resolved.reference_hides_model, Setting { value: false, set_by: SetBy::Default });
|
||||
assert_eq!(resolved.reference_shown_percent, 2.0);
|
||||
// The base view gives its own; its projected views follow the drawing's projection.
|
||||
let base = d.resolve_view(front).unwrap();
|
||||
assert_eq!((base.basis, base.scale.set_by, base.display_style.set_by), (NamedView::Front.basis(), SetBy::This, SetBy::Default));
|
||||
assert_eq!(d.resolve_view(right).unwrap().basis, NamedView::Right.basis());
|
||||
d.projection = Projection::First;
|
||||
assert_eq!(d.resolve_view(right).unwrap().basis, NamedView::Left.basis());
|
||||
// A view's own setting wins over its parent's.
|
||||
view_mut(&mut d, top).settings.display_style = Some(VisualStyle::Visible);
|
||||
assert_eq!(d.resolve_view(top).unwrap().display_style, Setting { value: VisualStyle::Visible, set_by: SetBy::This });
|
||||
// Four quarter turns the same way bring the front view back.
|
||||
let (mut d, mut id_source, front) = self::front();
|
||||
let mut last = front;
|
||||
for _ in 0..4 {
|
||||
last = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
|
||||
}
|
||||
let turned = d.resolve_view(last).unwrap().basis;
|
||||
let close = |a: Vec3, b: Vec3| (a - b).len() < 1e-12;
|
||||
assert!(close(turned.x, Vec3::X) && close(turned.y, Vec3::Z), "{turned:?}");
|
||||
assert_eq!(d.resolve_view(99).unwrap_err().to_string(), format!("the drawing has no view {}", ids::encode(99)));
|
||||
}
|
||||
|
||||
/// A view placed at a point is there; one lined up with its parent takes its own place along the
|
||||
/// free axis and its parent's centre across it, down a chain, and follows when the parent moves;
|
||||
/// one placed apart stays. A base view lined up with nothing has no place, nor its views.
|
||||
#[test]
|
||||
fn a_views_centre_is_its_point_or_lined_up_with_its_parent() {
|
||||
let (mut d, mut id_source, [front, right, top]) = three();
|
||||
let back = add(&mut d, &mut id_source, projected(right, ProjectedSide::Right), ViewPlace::Aligned(450.0), ViewOverrides::default());
|
||||
let below = add(&mut d, &mut id_source, projected(right, ProjectedSide::Below), ViewPlace::Aligned(60.0), ViewOverrides::default());
|
||||
d.validate().unwrap();
|
||||
let centres = |d: &Drawing| [front, right, top, back, below].map(|id| d.view_centre(id).unwrap());
|
||||
let at = |x, y| Vec2::new(x, y);
|
||||
assert_eq!(centres(&d), [at(150.0, 150.0), at(320.0, 150.0), at(320.0, 250.0), at(450.0, 150.0), at(320.0, 60.0)]);
|
||||
view_mut(&mut d, front).place = ViewPlace::At([140.0, 130.0]);
|
||||
assert_eq!(centres(&d), [at(140.0, 130.0), at(320.0, 130.0), at(320.0, 250.0), at(450.0, 130.0), at(320.0, 60.0)]);
|
||||
view_mut(&mut d, front).place = ViewPlace::Aligned(10.0);
|
||||
for id in [front, right, top, back, below] {
|
||||
assert_eq!(d.view_centre(id).unwrap_err().to_string(), format!("base view {} isn't placed at a point", ids::encode(front)));
|
||||
}
|
||||
assert_eq!(d.view_centre(99).unwrap_err().to_string(), format!("the drawing has no view {}", ids::encode(99)));
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::drawing_measure::{LinearDirection, Reading};
|
||||
use solidcraft_geom::drawing_sheet::{Margins, Orientation, PaperSize, SheetSize};
|
||||
use solidcraft_geom::drawing_view::{NamedView, ProjectedSide};
|
||||
use solidcraft_geom::title_block::{Cell, CellKind, TextCell};
|
||||
|
||||
use solidcraft_render::VisualStyle;
|
||||
|
||||
use crate::drawing_view::{TangentEdges, ViewDirection, ViewOverrides};
|
||||
use balloon::Balloon;
|
||||
use dimension::{DimensionKind, ModelEdge, ModelPoint, TextPlace};
|
||||
use view::{ViewKind, ViewPlace};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -11,31 +21,77 @@ fn own_border() -> DrawingBorderProfile {
|
||||
DrawingBorderProfile { filing_margin: true, trimming_mark_mm: Some([10.0, 5.0]), ..DrawingBorderProfile::iso() }
|
||||
}
|
||||
|
||||
/// Three designs on two sheets: the first sheet (A3, the ISO border) documents component 7 of the
|
||||
/// first design; the second (A4 portrait, a border of its own, 600 dpi, left out of printing)
|
||||
/// documents the second design. The third design is on no sheet yet.
|
||||
fn plate() -> Drawing {
|
||||
/// The design it is in, on two sheets: the first (A3, the ISO border) documents its component 7;
|
||||
/// the second (A4 portrait, a border of its own, 600 dpi, left out of printing) the design itself.
|
||||
/// On the first sheet, the front view at 1:2, its right view lined up with it and drawing hidden
|
||||
/// lines too, and the right view's top view placed apart; on the second, an iso view at 1:1,
|
||||
/// tangent edges shown, with reference_shown_percent 5. On the front view, a dimension of each kind:
|
||||
/// between two corners, a placed copy's edge length along a direction, and an angle with its text
|
||||
/// given; and a balloon on the placed copy's top edge.
|
||||
pub(crate) fn plate() -> Drawing {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut id_source).unwrap();
|
||||
for path in ["plate.sc", "../parts/bolt.sc", "bracket.sc"] {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id, path: path.into() });
|
||||
}
|
||||
let (plate_id, bolt_id) = (d.sources[0].id, d.sources[1].id);
|
||||
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id });
|
||||
let plate_id = d.sources[0].id;
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet {
|
||||
id,
|
||||
name: "Bolt".into(),
|
||||
sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait),
|
||||
border: Some(own_border()),
|
||||
title_block: None,
|
||||
title_block_overrides: BTreeMap::new(),
|
||||
dpi: 600.0,
|
||||
documents: Some(SheetComponent { source: bolt_id, component: 0 }),
|
||||
documents: Some(SheetComponent { source: plate_id, component: 0 }),
|
||||
excluded: Excluded { count: false, print: true },
|
||||
}));
|
||||
Arc::make_mut(&mut d.sheets[0]).documents = Some(SheetComponent { source: plate_id, component: 7 });
|
||||
d.properties.set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
|
||||
Arc::make_mut(&mut d.properties).set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
|
||||
d.units = Units::Fixed { unit: "in".into() };
|
||||
let (assembly, bolt_sheet) = (d.sheets[0].id, d.sheets[1].id);
|
||||
let mut add = |d: &mut Drawing, sheet, kind, place, settings, reference_shown_percent| {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.add_views([Arc::new(View { id, sheet, kind, place, settings, reference_shown_percent })]);
|
||||
id
|
||||
};
|
||||
let half = ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() };
|
||||
let front = ViewKind::Base { source: plate_id, view: ViewDirection::Named(NamedView::Front) };
|
||||
let front = add(&mut d, assembly, front, ViewPlace::At([150.0, 150.0]), half, 0.0);
|
||||
let hidden = ViewOverrides { display_style: Some(VisualStyle::VisibleHidden), ..ViewOverrides::default() };
|
||||
let right = ViewKind::Projected { parent: front, side: ProjectedSide::Right };
|
||||
let right = add(&mut d, assembly, right, ViewPlace::Aligned(320.0), hidden, 0.0);
|
||||
let top = ViewKind::Projected { parent: right, side: ProjectedSide::Above };
|
||||
add(&mut d, assembly, top, ViewPlace::At([320.0, 250.0]), ViewOverrides::default(), 0.0);
|
||||
let iso = ViewKind::Base { source: plate_id, view: ViewDirection::Named(NamedView::IsoFrontRight) };
|
||||
let full_size = ViewOverrides { scale: Some("1:1".parse().unwrap()), tangent_edges: Some(TangentEdges::Show), ..ViewOverrides::default() };
|
||||
add(&mut d, bolt_sheet, iso, ViewPlace::At([105.0, 148.5]), full_size, 5.0);
|
||||
let corner = |edges: [&str; 2]| ModelPoint {
|
||||
body: "Plate".into(),
|
||||
origin_feature: 1,
|
||||
edges: edges.map(String::from).to_vec(),
|
||||
centre: false,
|
||||
path: Vec::new(),
|
||||
};
|
||||
let edge = |name: &str| ModelEdge { body: "Plate".into(), origin_feature: 1, name: name.into(), path: vec![3, 7] };
|
||||
let measures = [
|
||||
DimensionKind::Linear {
|
||||
a: corner(["F1:front|F1:bottom", "F1:front|F1:left"]),
|
||||
b: corner(["F1:front|F1:bottom", "F1:front|F1:right"]),
|
||||
direction: LinearDirection::Horizontal,
|
||||
reading: Reading::Projected,
|
||||
},
|
||||
DimensionKind::Length { edge: edge("F1:front|F1:top"), direction: LinearDirection::Along(Vec2::new(1.0, 1.0)), reading: Reading::Projected },
|
||||
DimensionKind::Angle { a: edge("F1:front|F1:top"), b: edge("F2:chamfer|F1:front"), reading: Reading::True, other_side: true },
|
||||
];
|
||||
let texts = [(TextPlace::Centred, None), (TextPlace::At([0.0, -20.0]), None), (TextPlace::At([12.5, 30.0]), Some("≈45°".to_string()))];
|
||||
for (kind, (text, override_text)) in measures.into_iter().zip(texts) {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.add_dimensions([Arc::new(Dimension { id, view: front, kind, offset_mm: -10.0, text, decimals: 1, override_text, last_good: None })]);
|
||||
}
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.balloons.add([Arc::new(Balloon { id, view: front, edge: edge("F1:front|F1:top"), at: [60.0, 45.0] })]);
|
||||
d.validate().unwrap();
|
||||
d
|
||||
}
|
||||
@@ -43,7 +99,7 @@ fn plate() -> Drawing {
|
||||
/// One A4 sheet that documents nothing, no sources: the base the caps grow from.
|
||||
fn bare() -> Drawing {
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
Drawing::new("Bare", "en", "Sheet", a4, None, &mut IdSource::seeded(SEED)).unwrap()
|
||||
Drawing::new("Bare", "en", "Sheet", a4, None, &BTreeSet::new(), &mut IdSource::seeded(SEED)).unwrap()
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
@@ -78,17 +134,14 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
|
||||
assert_eq!(reopened.to_json().unwrap(), saved);
|
||||
assert_eq!(plate().to_json().unwrap(), saved, "a seeded run makes the same drawing");
|
||||
let v = file(&d);
|
||||
let [plate_id, bolt_id, bracket_id] = [0, 1, 2].map(|i| ids::encode(d.sources[i].id));
|
||||
let plate_id = ids::encode(d.sources[0].id);
|
||||
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
|
||||
assert_eq!(assembly_id, "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
|
||||
assert_eq!(v["id"], "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
|
||||
assert_eq!(v["format"], DRAWING.format_string(DRAWING.current()));
|
||||
assert_eq!(v.get("format_pending").is_some(), !DRAWING.pending_names().is_empty());
|
||||
assert_eq!(v.get("next_id"), None);
|
||||
assert_eq!((&v["name"], &v["language"]), (&json!("Plate assembly"), &json!("en-GB")));
|
||||
assert_eq!(
|
||||
v["sources"],
|
||||
json!([{"id": plate_id, "path": "plate.sc"}, {"id": bolt_id, "path": "../parts/bolt.sc"}, {"id": bracket_id, "path": "bracket.sc"}])
|
||||
);
|
||||
assert_eq!(v["sources"], json!([{"id": plate_id}]));
|
||||
assert_eq!(v["properties"], json!({"author": "Jane Doe"}));
|
||||
assert_eq!(v["units"], json!({"fixed": {"unit": "in"}}));
|
||||
assert_eq!((&v["sheets"][0]["id"], &v["sheets"][0]["dpi"]), (&json!(assembly_id), &json!(300.0)));
|
||||
@@ -100,7 +153,7 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
|
||||
json!({
|
||||
"id": bolt_sheet_id, "name": "Bolt", "sheet": {"size": {"standard": "a4"}, "orientation": "portrait"},
|
||||
"border": serde_json::to_value(own_border()).unwrap(), "dpi": 600.0,
|
||||
"documents": {"source": bolt_id, "component": 0}, "excluded": {"count": false, "print": true}
|
||||
"documents": {"source": plate_id, "component": 0}, "excluded": {"count": false, "print": true}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -111,6 +164,7 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
|
||||
fn a_file_may_leave_out_what_has_a_default() {
|
||||
let minimal = json!({
|
||||
"format": DRAWING.format_string(1),
|
||||
"id": "0000000000002",
|
||||
"name": "Bracket",
|
||||
"sources": [],
|
||||
"language": "de",
|
||||
@@ -125,7 +179,7 @@ fn a_file_may_leave_out_what_has_a_default() {
|
||||
let mut spelt = minimal.clone();
|
||||
spelt["format"] = json!("solidcraft-drawing/+1");
|
||||
assert_eq!(read(&spelt).unwrap().format, DRAWING.format_string(DRAWING.current()));
|
||||
for key in ["name", "sources", "language", "sheets"] {
|
||||
for key in ["id", "name", "sources", "language", "sheets"] {
|
||||
let mut v = minimal.clone();
|
||||
v.as_object_mut().unwrap().remove(key);
|
||||
let why = refusal(&v);
|
||||
@@ -133,7 +187,7 @@ fn a_file_may_leave_out_what_has_a_default() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cap on both sides: sheets, sources, the bytes of paths and names (two-byte characters too).
|
||||
/// Every cap on both sides: sheets, sources, the bytes of names (two-byte characters too).
|
||||
#[test]
|
||||
fn every_cap_holds_on_both_sides() {
|
||||
let base = file(&bare());
|
||||
@@ -155,18 +209,15 @@ fn every_cap_holds_on_both_sides() {
|
||||
for n in [0, 1001] {
|
||||
assert_eq!(refusal(&with("sheets", sheets(n))), "damaged drawing: a drawing has 1 to 1000 sheets", "{n} sheets");
|
||||
}
|
||||
// Sources from id 1: the sheet's id is the seed's, far from them.
|
||||
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id), "path": format!("part{id}.sc")})).collect());
|
||||
assert_eq!(read(&with("sources", sources(64))).unwrap().sources.len(), 64);
|
||||
assert_eq!(refusal(&with("sources", sources(65))), "damaged drawing: a drawing draws at most 64 designs");
|
||||
let path = |p: &str| refusal(&with("sources", json!([{"id": ids::encode(1), "path": p}])));
|
||||
for fits in ["a".repeat(4096), "é".repeat(2048), "/".into()] {
|
||||
assert_eq!(path(fits.as_str()), "read", "{} bytes", fits.len());
|
||||
}
|
||||
let rule = "damaged drawing: source 0000000000001: a design's path is 1 to 4096 bytes, without NUL";
|
||||
for broken in ["a".repeat(4097), "é".repeat(2048) + "a", String::new(), "plate\0.sc".into()] {
|
||||
assert_eq!(path(broken.as_str()), rule, "{} bytes", broken.len());
|
||||
// Sources from id 1: the drawing's and the sheet's ids are the seed's, far from them. A drawing
|
||||
// draws the design it is in, by an id alone: a path is a field this build doesn't know.
|
||||
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id)})).collect());
|
||||
for n in [0, 1] {
|
||||
assert_eq!(read(&with("sources", sources(n))).unwrap().sources.len(), n as usize);
|
||||
}
|
||||
assert_eq!(refusal(&with("sources", sources(2))), "damaged drawing: a drawing draws only the design it is in");
|
||||
let with_path = refusal(&with("sources", json!([{"id": ids::encode(1), "path": "plate.solidcraft"}])));
|
||||
assert!(with_path.starts_with("damaged drawing: unknown field `path`"), "{with_path}");
|
||||
let names = |name: &str| {
|
||||
let mut sheet_named = base.clone();
|
||||
sheet_named["sheets"][0]["name"] = json!(name);
|
||||
@@ -206,6 +257,23 @@ fn the_language_is_a_bcp_47_tag_of_2_to_35_characters() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing names its font by family: a file is refused when the drawing is read, as it is when a
|
||||
/// command sets it.
|
||||
#[test]
|
||||
fn a_font_file_in_a_drawing_is_refused_when_read() {
|
||||
let base = file(&bare());
|
||||
let why = |font: &str| {
|
||||
let mut v = base.clone();
|
||||
v["font"] = json!(font);
|
||||
refusal(&v)
|
||||
};
|
||||
assert_eq!(why("IBM Plex Mono"), "read");
|
||||
for path in ["C:/Fonts/arial.ttf", "/usr/share/fonts/a", "Futura.otf"] {
|
||||
let e = why(path);
|
||||
assert!(e.contains("the drawing's font") && e.contains("drawings name fonts by family"), "{path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed unit is a length unit of the expression unit table, written as the table writes it.
|
||||
#[test]
|
||||
fn a_fixed_unit_is_a_length_unit_of_the_expression_table() {
|
||||
@@ -248,29 +316,119 @@ fn a_border_is_saved_as_a_copy_of_its_profile() {
|
||||
assert_eq!(read(&edited).unwrap().sheets[0].border, None, "no drawing border");
|
||||
}
|
||||
|
||||
/// A sheet's drawing goes inside its drawing border: 10 mm in from each edge with the ISO border,
|
||||
/// 20 mm on the left with a filing margin, and the whole paper with no border.
|
||||
#[test]
|
||||
fn a_sheets_drawing_space_is_inside_its_border_or_the_whole_paper() {
|
||||
let d = plate();
|
||||
let space = |sheet: &Sheet| sheet.drawing_space().map(|b| (b.min, b.max)).unwrap();
|
||||
assert_eq!(space(&d.sheets[0]), (Vec2::new(10.0, 10.0), Vec2::new(410.0, 287.0)), "A3 landscape, ISO");
|
||||
assert_eq!(space(&d.sheets[1]), (Vec2::new(20.0, 10.0), Vec2::new(200.0, 287.0)), "A4 portrait, filing margin");
|
||||
let bare = Sheet { border: None, ..(*d.sheets[0]).clone() };
|
||||
assert_eq!(space(&bare), (Vec2::ZERO, Vec2::new(420.0, 297.0)), "no border");
|
||||
let low = drawing_sheet::Sheet { size: SheetSize::Custom { sides_mm: [20.5, 297.0] }, ..d.sheets[0].sheet };
|
||||
let narrow = Sheet { sheet: low, ..(*d.sheets[0]).clone() };
|
||||
assert!(narrow.drawing_space().is_err(), "margins that leave no drawing space");
|
||||
}
|
||||
|
||||
/// The most cells a block may have: 8 by 8 unlabelled cells of 20 x 6.75 mm, 160 x 54 mm in all.
|
||||
fn full_block() -> TitleBlock {
|
||||
use solidcraft_geom::cell::{Content, Fit, HAlign, Part};
|
||||
let cell = |i: usize| {
|
||||
let (x, y) = ((i % 8) as f64 * 20.0 - 160.0, (i / 8) as f64 * 6.75);
|
||||
let value = TextCell { content: Content(vec![Part::Text("-".into())]), cap_mm: 2.5, align: HAlign::Left, fit: Fit::default() };
|
||||
Cell { name: format!("cell_{i}"), rect_mm: [x, y, x + 20.0, y + 6.75], label: None, kind: CellKind::Text(value) }
|
||||
};
|
||||
TitleBlock { size_mm: Vec2::new(160.0, 54.0), outline_line_mm: 0.7, cell_line_mm: 0.35, cells: (0..64).map(cell).collect() }
|
||||
}
|
||||
|
||||
/// A sheet's title block is a copy saved with it, read back as it was and saved as the same bytes.
|
||||
/// A sheet without one saves as before, with no `title_block`, so an older drawing reads as one
|
||||
/// with none. A block needs a drawing border that holds it, and an unknown field is refused.
|
||||
#[test]
|
||||
fn a_sheets_title_block_is_a_copy_on_its_border() {
|
||||
let mut d = plate();
|
||||
assert!(!d.to_json().unwrap().contains("title_block"), "nothing saved for no block");
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block = Some(full_block());
|
||||
let saved = d.to_json().unwrap();
|
||||
let read = Drawing::from_json(&saved).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert_eq!(read.to_json().unwrap(), saved, "the same bytes");
|
||||
let sheet = ids::encode(d.sheets[0].id);
|
||||
let no_border = refused(&d, |v| v["sheets"][0]["border"] = Value::Null);
|
||||
assert_eq!(no_border, format!("damaged drawing: sheet {sheet}: its title block sits on a drawing border, and it has none"));
|
||||
// The A3 sheet's ISO border is 400 mm wide: a block as wide fits, one wider doesn't.
|
||||
let wide = |width: f64| refused(&d, |v| v["sheets"][0]["title_block"]["size_mm"] = json!([width, 54.0]));
|
||||
assert_eq!(wide(400.0), "read");
|
||||
assert_eq!(wide(400.5), format!("damaged drawing: sheet {sheet}: title block: a title block fits inside the drawing border"));
|
||||
let unknown = refused(&d, |v| v["sheets"][0]["title_block"]["colour"] = json!("red"));
|
||||
assert!(unknown.starts_with("damaged drawing: unknown field `colour`"), "{unknown}");
|
||||
}
|
||||
|
||||
/// A sheet's title block sits on its drawing border's lower-right corner: on A3 with the ISO
|
||||
/// border, (230, 10) to (410, 59); none without a block.
|
||||
#[test]
|
||||
fn a_sheets_title_block_outline() {
|
||||
let mut d = Drawing::new(
|
||||
"Outline",
|
||||
"en",
|
||||
"Sheet",
|
||||
drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape),
|
||||
Some(DrawingBorderProfile::iso()),
|
||||
&BTreeSet::new(),
|
||||
&mut IdSource::seeded(SEED),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.sheets[0].title_block_outline().unwrap(), None);
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block = Some(solidcraft_geom::title_block::default_block());
|
||||
let outline = d.sheets[0].title_block_outline().unwrap().unwrap();
|
||||
assert_eq!((outline.min, outline.max), (Vec2::new(230.0, 10.0), Vec2::new(410.0, 59.0)));
|
||||
}
|
||||
|
||||
/// The most a drawing's blocks can ask of every edit's check: 1000 sheets, each with a 64-cell block.
|
||||
#[test]
|
||||
fn a_thousand_sheets_of_full_blocks_are_checked() {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Blocks", "en", "Sheet", a3, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut id_source).unwrap();
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block = Some(full_block());
|
||||
while d.sheets.len() < MAX_SHEETS {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet { id, ..(*d.sheets[0]).clone() }));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
d.validate().unwrap();
|
||||
eprintln!("1000 sheets of 64-cell blocks checked in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// What reading refuses: each damage the rules name, refused rather than patched, and files this
|
||||
/// build can't read.
|
||||
#[test]
|
||||
fn damaged_files_are_refused_and_never_patched() {
|
||||
let d = plate();
|
||||
let plate_id = ids::encode(d.sources[0].id);
|
||||
let (drawing_id, plate_id) = (ids::encode(d.id), ids::encode(d.sources[0].id));
|
||||
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
|
||||
// Two objects with one id, of one kind and of two kinds.
|
||||
assert_eq!(refused(&d, |v| v["sources"][1]["id"] = json!(plate_id)), format!("damaged drawing: two objects have the id {plate_id}"));
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), format!("damaged drawing: two objects have the id {assembly_id}"));
|
||||
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(bolt_sheet_id)), format!("damaged drawing: two objects have the id {bolt_sheet_id}"));
|
||||
// Two objects with one id, of one kind and of two kinds, the drawing itself among them.
|
||||
let two = |id: &str| format!("damaged drawing: two objects have the id {id}");
|
||||
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(drawing_id)), two(&drawing_id));
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), two(&assembly_id));
|
||||
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(bolt_sheet_id)), two(&bolt_sheet_id));
|
||||
assert_eq!(refused(&d, |v| v["id"] = json!(assembly_id)), two(&assembly_id));
|
||||
// An id that isn't one, where an object has it and where a reference names it; no number form,
|
||||
// and no counter.
|
||||
let not_an_id = "damaged drawing: an id is 13 characters of lowercase Crockford base32, not all 0";
|
||||
for text in ["0000000000000", "FZZZZZZZZZZZZ", "000000000001", "0000000000001 ", "000000000000o", "g000000000000"] {
|
||||
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(text)), not_an_id, "{text:?}");
|
||||
assert_eq!(refused(&d, |v| v["id"] = json!(text)), not_an_id, "{text:?}");
|
||||
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(text)), not_an_id, "{text:?}");
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(text)), not_an_id, "{text:?}");
|
||||
}
|
||||
assert!(refused(&d, |v| v["sources"][2]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
|
||||
assert!(refused(&d, |v| v["sources"][0]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
|
||||
assert!(refused(&d, |v| v["next_id"] = json!(6)).contains("unknown field `next_id`"));
|
||||
let mut in_memory = d.clone();
|
||||
in_memory.sources[2].id = 0;
|
||||
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
|
||||
for zero in [|d: &mut Drawing| d.id = 0, |d: &mut Drawing| d.sources[0].id = 0] {
|
||||
let mut in_memory = d.clone();
|
||||
zero(&mut in_memory);
|
||||
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
|
||||
}
|
||||
// A reference to nothing, or to an object of another kind; a source a sheet documents removed.
|
||||
for missing in [ids::encode(99), assembly_id.clone()] {
|
||||
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: it documents source {missing}, which the drawing doesn't have");
|
||||
@@ -282,7 +440,15 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
assert_eq!(without_plate, format!("damaged drawing: sheet {assembly_id}: it documents source {plate_id}, which the drawing doesn't have"));
|
||||
// The paper, its border and its resolution, through the sheet's own rules.
|
||||
let custom = |sides: Value| refused(&d, |v| v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": sides}}));
|
||||
assert_eq!(custom(json!([5000.0, 0.5])), "read");
|
||||
assert_eq!(custom(json!([5000.0, 100.0])), "read");
|
||||
// A side the border's margins fill leaves no drawing space; without a border it is paper.
|
||||
let no_space = format!("damaged drawing: sheet {assembly_id}: drawing sheet: the margins leave no drawing space");
|
||||
assert_eq!(custom(json!([5000.0, 0.5])), no_space);
|
||||
let bare = refused(&d, |v| {
|
||||
v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": [5000.0, 0.5]}});
|
||||
v["sheets"][0]["border"] = Value::Null;
|
||||
});
|
||||
assert_eq!(bare, "read");
|
||||
for sides in [json!([5000.1, 100.0]), json!([0.0, 100.0]), json!([-1.0, 100.0])] {
|
||||
let expected = format!("damaged drawing: sheet {assembly_id}: drawing sheet: a custom side is finite, above 0 and at most 5000 mm");
|
||||
assert_eq!(custom(sides), expected);
|
||||
@@ -312,7 +478,7 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
assert!(in_memory.validate().unwrap_err().to_string().starts_with(&format!("sheet {bolt_sheet_id}: drawing sheet: a custom side")));
|
||||
// An unknown field anywhere.
|
||||
let unknown: [fn(&mut Value); 8] = [
|
||||
|v| v["views"] = json!([]),
|
||||
|v| v["annotations"] = json!([]),
|
||||
|v| v["sources"][0]["name"] = json!("Plate"),
|
||||
|v| v["sheets"][0]["scale"] = json!(1),
|
||||
|v| v["sheets"][0]["sheet"]["colour"] = json!("white"),
|
||||
@@ -330,7 +496,7 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
|
||||
let why = refused(&d, |v| {
|
||||
v["format"] = json!(DRAWING.format_string(newer));
|
||||
v["views"] = json!([]);
|
||||
v["annotations"] = json!([]);
|
||||
v["sheets"][0]["dpi"] = json!(1e10);
|
||||
});
|
||||
assert_eq!(
|
||||
@@ -360,8 +526,8 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
assert_eq!(Drawing::from_json("[]").unwrap_err().to_string(), "not a SolidCraft drawing (expected a JSON object)");
|
||||
assert!(Drawing::from_json("").unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
|
||||
let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap();
|
||||
assert_eq!(refused(&d, |v| v["views"] = deep), "damaged drawing: nested too deeply");
|
||||
let deeper = format!("{{\"format\": \"{drawing_format}\", \"views\": {}1{}}}", "[".repeat(200), "]".repeat(200));
|
||||
assert_eq!(refused(&d, |v| v["annotations"] = deep), "damaged drawing: nested too deeply");
|
||||
let deeper = format!("{{\"format\": \"{drawing_format}\", \"annotations\": {}1{}}}", "[".repeat(200), "]".repeat(200));
|
||||
assert!(Drawing::from_json(&deeper).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
|
||||
let text = d.to_json().unwrap();
|
||||
let at_cap = text.clone() + &" ".repeat(MAX_DRAWING_BYTES - text.len());
|
||||
@@ -371,18 +537,48 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
|
||||
/// A run never gives an id twice: not one in use, and not a removed object's. Only another run from
|
||||
/// the same seed, which tests and replays start from the same file, can give a removed id again.
|
||||
/// What is on a sheet is its views and the dimensions in them, and taking the sheet out takes
|
||||
/// them too. A view elsewhere projected from one of them is left without its parent, which the
|
||||
/// rules refuse.
|
||||
#[test]
|
||||
fn a_sheet_goes_with_everything_on_it() {
|
||||
let d = plate();
|
||||
let (assembly, bolt) = (d.sheets[0].id, d.sheets[1].id);
|
||||
let on = d.on_sheet(assembly);
|
||||
assert_eq!((on.views.len(), on.dimensions.len(), on.balloons.len()), (3, 3, 1), "{on:?}");
|
||||
assert_eq!(on.dimensions, d.dimensions.iter().map(|x| x.id).collect::<Vec<_>>());
|
||||
let bolt_on = d.on_sheet(bolt);
|
||||
assert_eq!((bolt_on.views.len(), bolt_on.dimensions.len(), bolt_on.balloons.len()), (1, 0, 0), "{bolt_on:?}");
|
||||
assert!(d.on_sheet(d.id).is_empty() && !on.is_empty(), "not a sheet: nothing on it");
|
||||
let mut without = d.clone();
|
||||
assert_eq!(without.remove_sheet(bolt), bolt_on);
|
||||
without.validate().unwrap();
|
||||
assert_eq!((without.sheets.len(), without.views.len(), without.dimensions.len(), without.balloons.len()), (1, 3, 3, 1));
|
||||
let mut without = d.clone();
|
||||
assert_eq!(without.remove_sheet(assembly), on);
|
||||
without.validate().unwrap();
|
||||
assert_eq!((without.sheets.len(), without.views.len(), without.dimensions.len(), without.balloons.len()), (1, 1, 0, 0));
|
||||
// The top view, placed apart, moved to the bolt's sheet: its parent is on the assembly's.
|
||||
let mut moved = d.clone();
|
||||
let top = moved.views.iter().position(|v| v.sheet == assembly && matches!((v.kind, v.place), (ViewKind::Projected { .. }, ViewPlace::At(_))));
|
||||
Arc::make_mut(&mut moved.views[top.unwrap()]).sheet = bolt;
|
||||
moved.validate().unwrap();
|
||||
moved.remove_sheet(assembly);
|
||||
assert!(moved.validate().unwrap_err().to_string().contains("parent"), "{:?}", moved.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_never_gives_an_id_twice() {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &mut id_source).unwrap();
|
||||
let mut given = BTreeSet::from([d.sheets[0].id]);
|
||||
for _ in 0..MAX_SOURCES {
|
||||
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let mut given = BTreeSet::from([d.id, d.sheets[0].id]);
|
||||
for _ in 0..64 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id, path: "part.sc".into() });
|
||||
d.sheets.push(Arc::new(Sheet { id, ..(*d.sheets[0]).clone() }));
|
||||
assert!(given.insert(id), "{id:#x} given twice");
|
||||
}
|
||||
let removed = d.sources.pop().unwrap().id;
|
||||
let removed = d.sheets.pop().map_or(0, |s| s.id);
|
||||
for _ in 0..10_000 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
assert!(id != removed && given.insert(id), "{id:#x} given twice");
|
||||
@@ -420,7 +616,7 @@ fn a_drawing_too_large_to_reopen_is_not_saved() {
|
||||
let sheet = r#"{"id":"0000000000001","name":"S","sheet":{"size":{"standard":"a4"}}}"#;
|
||||
let format = DRAWING.format_string(1);
|
||||
format!(
|
||||
r#"{{"format":"{format}","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
|
||||
r#"{{"format":"{format}","id":"0000000000002","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
|
||||
)
|
||||
};
|
||||
// The case found in review (#3891): 2,000,000 zeros read in at about 4 MB and indent past 16 MiB.
|
||||
@@ -430,19 +626,24 @@ fn a_drawing_too_large_to_reopen_is_not_saved() {
|
||||
assert_eq!(Drawing::from_json(&small.to_json().unwrap()).unwrap(), small);
|
||||
}
|
||||
|
||||
/// A new drawing has one sheet, its id the source's first, follows its designs' units and renders
|
||||
/// pictures at 300 dpi; a name, language or border that breaks the rules is refused.
|
||||
/// A new drawing has one sheet, the drawing's id the source's first and the sheet's the next,
|
||||
/// follows its designs' units and renders pictures at 300 dpi; its id is none of its design's other
|
||||
/// drawings'; a name, language or border that breaks the rules is refused.
|
||||
#[test]
|
||||
fn a_new_drawing_has_one_sheet_and_keeps_the_rules() {
|
||||
let d = bare();
|
||||
let first = IdSource::seeded(SEED).next(&BTreeSet::new()).unwrap();
|
||||
assert_eq!((d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (1, first, DEFAULT_DPI, &Units::FollowModel));
|
||||
let mut seeded = IdSource::seeded(SEED);
|
||||
let first = seeded.next(&BTreeSet::new()).unwrap();
|
||||
let second = seeded.next(&BTreeSet::new()).unwrap();
|
||||
assert_eq!((d.id, d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (first, 1, second, DEFAULT_DPI, &Units::FollowModel));
|
||||
assert_eq!((d.format, d.format_pending), (DRAWING.format_string(DRAWING.current()), DRAWING.pending_names()));
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
let beside = Drawing::new("Beside", "en", "Sheet", a4, None, &BTreeSet::from([first]), &mut IdSource::seeded(SEED)).unwrap();
|
||||
assert!(beside.id != first && beside.sheets[0].id != beside.id, "the design's drawing keeps its id");
|
||||
let thin = DrawingBorderProfile { drawing_border_line_mm: 0.0, ..DrawingBorderProfile::iso() };
|
||||
let broken = [("", "en", "Sheet", None), ("Bare", "en_GB", "Sheet", None), ("Bare", "en", "\n", None), ("Bare", "en", "Sheet", Some(thin))];
|
||||
for (name, language, sheet, border) in broken {
|
||||
let made = Drawing::new(name, language, sheet, a4, border, &mut IdSource::seeded(SEED));
|
||||
let made = Drawing::new(name, language, sheet, a4, border, &BTreeSet::new(), &mut IdSource::seeded(SEED));
|
||||
assert!(made.is_err(), "{name:?} {language:?} {sheet:?} {border:?}");
|
||||
}
|
||||
}
|
||||
@@ -518,6 +719,109 @@ fn mutated_files_give_an_error_or_a_valid_drawing_never_a_panic() {
|
||||
assert!(read_back > 50, "only {read_back} mutated files read: the mutations hardly reach the record's rules");
|
||||
}
|
||||
|
||||
/// A design holding drawings, as plain JSON: each drawing inline under `drawings`, as its own
|
||||
/// record with its own format. While the change is pending the design names `drawings` in its
|
||||
/// `format_pending`, so a build without it refuses the design (`format::tests`) rather than open it
|
||||
/// and save it without them. They read back in their order, by name, then id, and save as the same
|
||||
/// bytes; a design without drawings writes no `drawings`.
|
||||
#[test]
|
||||
fn a_design_keeps_its_drawings_inline_in_plain_json() {
|
||||
let named = |name: &str, id: u64| Arc::new(Drawing { name: name.into(), id, ..plate() });
|
||||
let mut doc = crate::Document::new("Plate");
|
||||
doc.drawings = vec![named("Details", 7), Arc::new(plate()), named("Details", 3), named("Assembly", u64::MAX)];
|
||||
let read = crate::Document::from_json(&doc.to_json()).unwrap();
|
||||
let order: Vec<_> = read.drawings.iter().map(|d| (d.name.as_str(), d.id)).collect();
|
||||
assert_eq!(order, [("Assembly", u64::MAX), ("Details", 3), ("Details", 7), ("Plate assembly", plate().id)]);
|
||||
let saved = read.to_json();
|
||||
assert_eq!(crate::Document::from_json(&saved).unwrap().to_json(), saved, "save, open, save: the same bytes");
|
||||
let v: Value = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(v["drawings"][3], file(&plate()));
|
||||
assert!(v["format_pending"].as_array().is_some_and(|names| names.contains(&json!("drawings"))), "{}", v["format_pending"]);
|
||||
let empty: Value = serde_json::from_str(&crate::Document::new("Plate").to_json()).unwrap();
|
||||
assert_eq!(empty.get("drawings"), None);
|
||||
}
|
||||
|
||||
/// What a design refuses in its drawings, each time the whole design, never by dropping a drawing:
|
||||
/// one a newer build saved (a newer number, or a change this build doesn't know), a damaged one,
|
||||
/// `drawings` that isn't a list, two with one id, and more than a design may hold, counted before
|
||||
/// any is read. A drawing's numbers are its own family's to check: other tools' data in its
|
||||
/// properties is kept, a sheet's number out of range refused.
|
||||
#[test]
|
||||
fn a_drawing_a_design_cant_read_refuses_the_design() {
|
||||
let mut doc = crate::Document::new("Plate");
|
||||
doc.drawings = vec![Arc::new(plate())];
|
||||
let design: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
let refused = |edit: &dyn Fn(&mut Value)| {
|
||||
let mut v = design.clone();
|
||||
edit(&mut v);
|
||||
crate::Document::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |d| format!("read {}", d.drawings.len()))
|
||||
};
|
||||
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
|
||||
assert_eq!(
|
||||
refused(&|v| v["drawings"][0]["format"] = json!(DRAWING.format_string(newer))),
|
||||
format!(
|
||||
"drawing 1: this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
|
||||
)
|
||||
);
|
||||
let later = refused(&|v| v["drawings"][0]["format_pending"] = json!(["a-later-change"]));
|
||||
assert!(if DRAWING.pending_names().is_empty() { later == "read 1" } else { later.contains("newer SolidCraft") }, "{later}");
|
||||
let damaged = refused(&|v| v["drawings"][0]["language"] = json!("e"));
|
||||
assert!(damaged.starts_with("drawing 1: damaged drawing: the language"), "{damaged}");
|
||||
assert_eq!(refused(&|v| v["drawings"] = json!({})), "damaged design: `drawings` is not a list");
|
||||
let twice = refused(&|v| {
|
||||
let again = v["drawings"][0].clone();
|
||||
v["drawings"].as_array_mut().unwrap().push(again);
|
||||
});
|
||||
assert_eq!(twice, format!("damaged design: two drawings have the id {}", ids::encode(plate().id)));
|
||||
let at_cap: Vec<Value> = (1..=MAX_DRAWINGS as u64)
|
||||
.map(|id| {
|
||||
let mut d = file(&plate());
|
||||
d["id"] = json!(ids::encode(id));
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refused(&|v| v["drawings"] = Value::Array(at_cap.clone())), format!("read {MAX_DRAWINGS}"));
|
||||
let over = refused(&|v| v["drawings"] = Value::Array(vec![json!({}); MAX_DRAWINGS + 1]));
|
||||
assert_eq!(over, format!("damaged design: a design holds at most {MAX_DRAWINGS} drawings"));
|
||||
assert_eq!(refused(&|v| v["drawings"][0]["properties"]["com.example"] = json!({"huge": 1e300})), "read 1");
|
||||
let out_of_range = refused(&|v| v["drawings"][0]["sheets"][0]["dpi"] = json!(1e300));
|
||||
assert!(out_of_range.starts_with("drawing 1: damaged drawing: the number "), "{out_of_range}");
|
||||
}
|
||||
|
||||
/// A copy that must open again (an autosave, a crash report's design) keeps every drawing a read
|
||||
/// takes: one breaking its own rules, the later of two with one id and those past the cap are left
|
||||
/// out, each named with its rule.
|
||||
#[test]
|
||||
fn a_copy_that_must_open_leaves_out_only_the_drawings_a_read_refuses() {
|
||||
let numbered = |id: u64| Arc::new(Drawing { id, name: format!("Plate {id}"), ..plate() });
|
||||
let mut doc = crate::Document::new("Plate");
|
||||
doc.drawings = vec![numbered(1), numbered(2)];
|
||||
assert!(matches!(openable(&doc), (Cow::Borrowed(_), ref left_out) if left_out.is_empty()));
|
||||
|
||||
let broken = Arc::new(Drawing { language: "?".into(), ..(*numbered(3)).clone() });
|
||||
let again = Arc::new(Drawing { name: "Plate 1 again".into(), ..(*numbered(1)).clone() });
|
||||
doc.drawings = vec![numbered(1), broken, again, numbered(4)];
|
||||
let (kept, left_out) = openable(&doc);
|
||||
assert_eq!(kept.drawings, [numbered(1), numbered(4)]);
|
||||
assert_eq!(left_out.len(), 2);
|
||||
let (three, one) = (ids::encode(3), ids::encode(1));
|
||||
assert!(left_out[0].starts_with(&format!(r#"drawing "Plate 3" ({three}) left out: the language is not"#)), "{left_out:?}");
|
||||
assert_eq!(left_out[1], format!(r#"drawing "Plate 1 again" ({one}) left out: two drawings have the id {one}"#));
|
||||
|
||||
// A drawing left out takes no id from a later one.
|
||||
let broken_first = Arc::new(Drawing { language: "?".into(), ..(*numbered(1)).clone() });
|
||||
doc.drawings = vec![broken_first, numbered(1)];
|
||||
let (kept, left_out) = openable(&doc);
|
||||
assert_eq!(kept.drawings, [numbered(1)]);
|
||||
assert_eq!(left_out.len(), 1, "{left_out:?}");
|
||||
|
||||
doc.drawings = (1..=MAX_DRAWINGS as u64 + 2).map(numbered).collect();
|
||||
let (kept, left_out) = openable(&doc);
|
||||
assert_eq!(kept.drawings, doc.drawings[..MAX_DRAWINGS]);
|
||||
let over = |id: u64| format!(r#"drawing "Plate {id}" ({}) left out: a design holds at most {MAX_DRAWINGS} drawings"#, ids::encode(id));
|
||||
assert_eq!(left_out, [over(MAX_DRAWINGS as u64 + 1), over(MAX_DRAWINGS as u64 + 2)]);
|
||||
}
|
||||
|
||||
/// The number of values in `v`, itself included.
|
||||
fn count(v: &Value) -> usize {
|
||||
1 + match v {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A drawing view's settings (W07, #5866): which way it looks, its scale and how it draws.
|
||||
//! `drawing.view.snapshot` takes them whole; a saved view (W12) keeps the same fields, each one
|
||||
//! optional so that a projected view can follow its parent.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use solidcraft_geom::Plane;
|
||||
use solidcraft_geom::drawing_view::{Invalid, NamedView, Scale, basis_looking};
|
||||
use solidcraft_render::VisualStyle;
|
||||
|
||||
/// The most of its reference parts a view shows, as a percent.
|
||||
pub const MAX_REFERENCE_SHOWN_PERCENT: f64 = 100.0;
|
||||
|
||||
/// Which way a view looks: by name, or along any direction with an up.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ViewDirection {
|
||||
Named(NamedView),
|
||||
Looking(Looking),
|
||||
}
|
||||
|
||||
/// A view along `direction` with `up` toward the top of the paper, through `origin` (the world
|
||||
/// origin when not given).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Looking {
|
||||
pub direction: [f64; 3],
|
||||
pub up: [f64; 3],
|
||||
#[serde(default)]
|
||||
pub origin: [f64; 3],
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewDirection {
|
||||
/// One message for anything that is neither, rather than serde's "did not match any variant".
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<ViewDirection, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Either {
|
||||
Named(NamedView),
|
||||
Looking(Looking),
|
||||
}
|
||||
let message = "view is front, back, top, bottom, left, right, iso, iso_front_right, iso_front_left, iso_back_right, iso_back_left, or {direction, up, origin?}";
|
||||
Ok(match Either::deserialize(d).map_err(|_| serde::de::Error::custom(message))? {
|
||||
Either::Named(view) => ViewDirection::Named(view),
|
||||
Either::Looking(looking) => ViewDirection::Looking(looking),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewDirection {
|
||||
fn default() -> Self {
|
||||
ViewDirection::Named(NamedView::Front)
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewDirection {
|
||||
/// The view's basis: a named view's exact one, or one built from the direction and up.
|
||||
pub fn basis(&self) -> Result<Plane, Invalid> {
|
||||
match *self {
|
||||
ViewDirection::Named(view) => Ok(view.basis()),
|
||||
ViewDirection::Looking(Looking { direction, up, origin }) => basis_looking(direction.into(), up.into(), origin.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tangent edges, where faces meet smoothly: left out (the default) or drawn.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TangentEdges {
|
||||
#[default]
|
||||
Hide,
|
||||
Show,
|
||||
}
|
||||
|
||||
/// What a view shows and how. Every field has a default: the front view at the scale that fits
|
||||
/// its page, visible lines only, tangent edges hidden.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ViewSettings {
|
||||
pub view: ViewDirection,
|
||||
/// None: the largest preset scale at which the view fits its page ([`Scale::fit`]). Not
|
||||
/// written when None: a snapshot refuses a null scale.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<Scale>,
|
||||
pub display_style: VisualStyle,
|
||||
/// How reference parts draw (bill of materials type `reference`).
|
||||
pub reference_style: VisualStyle,
|
||||
/// Whether reference parts hide the model's lines behind them.
|
||||
pub reference_hides_model: bool,
|
||||
/// How far the view's boundary grows from the model's box towards the box that also holds its
|
||||
/// reference parts, each side on its own: 0 to [`MAX_REFERENCE_SHOWN_PERCENT`].
|
||||
pub reference_shown_percent: f64,
|
||||
pub tangent_edges: TangentEdges,
|
||||
}
|
||||
|
||||
/// The settings a saved view sets itself, each on its own: one left out follows the view's parent,
|
||||
/// then [`ViewSettings`]'s default ([`crate::drawing::Drawing::resolve_view`]). None is written.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ViewOverrides {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<Scale>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_style: Option<VisualStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_style: Option<VisualStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_hides_model: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tangent_edges: Option<TangentEdges>,
|
||||
}
|
||||
|
||||
impl ViewOverrides {
|
||||
/// Sets nothing: every setting follows.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == ViewOverrides::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewSettings {
|
||||
/// What reading alone can't check: the percent's range and the view's basis.
|
||||
pub fn validate(&self) -> Result<Plane, Invalid> {
|
||||
let percent = self.reference_shown_percent;
|
||||
if !(percent.is_finite() && (0.0..=MAX_REFERENCE_SHOWN_PERCENT).contains(&percent)) {
|
||||
return Err(Invalid("reference_shown_percent is a number from 0 to 100"));
|
||||
}
|
||||
self.view.basis()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_view_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every field has its default, and the settings read and write back the same.
|
||||
#[test]
|
||||
fn view_settings_default_to_a_front_view_that_fits_its_page() {
|
||||
let settings: ViewSettings = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(settings, ViewSettings::default());
|
||||
assert_eq!(settings.view, ViewDirection::Named(NamedView::Front));
|
||||
assert_eq!((settings.scale, settings.display_style, settings.tangent_edges), (None, VisualStyle::Visible, TangentEdges::Hide));
|
||||
assert_eq!(settings.validate().unwrap(), NamedView::Front.basis());
|
||||
assert_eq!(serde_json::to_value(settings).unwrap().get("scale"), None, "no scale is written, not null");
|
||||
let full = json!({
|
||||
"view": {"direction": [0.0, 1.0, 0.0], "up": [0.0, 0.0, 1.0], "origin": [1.0, 2.0, 3.0]},
|
||||
"scale": "1:2", "display_style": "visible_hidden", "reference_style": "shaded",
|
||||
"reference_hides_model": true, "reference_shown_percent": 5.0, "tangent_edges": "show",
|
||||
});
|
||||
let read: ViewSettings = serde_json::from_value(full.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(read).unwrap(), full);
|
||||
assert_eq!(read.validate().unwrap().origin, Vec3::new(1.0, 2.0, 3.0));
|
||||
let iso: ViewSettings = serde_json::from_value(json!({"view": "iso"})).unwrap();
|
||||
assert_eq!(iso.view, ViewDirection::Named(NamedView::IsoFrontRight));
|
||||
}
|
||||
|
||||
/// What the settings refuse: unknown fields, unknown names, a basis that can't be built and a
|
||||
/// margin out of range.
|
||||
#[test]
|
||||
fn view_settings_refuse_what_they_cant_draw() {
|
||||
for not_settings in [
|
||||
json!({"scael": "1:2"}),
|
||||
json!({"view": 3}),
|
||||
json!({"view": "Front"}),
|
||||
json!({"view": {"direction": [0, 1, 0]}}),
|
||||
json!({"view": {"direction": [0, 1, 0], "up": [0, 0, 1], "roll": 1}}),
|
||||
json!({"scale": 0.5}),
|
||||
json!({"display_style": "hidden"}),
|
||||
json!({"tangent_edges": "thin"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<ViewSettings>(not_settings.clone()).is_err(), "{not_settings}");
|
||||
}
|
||||
let refused = [
|
||||
json!({"view": {"direction": [0, 0, 0], "up": [0, 0, 1]}}),
|
||||
json!({"view": {"direction": [0, 0, 1], "up": [0, 0, -1]}}),
|
||||
json!({"reference_shown_percent": -0.001}),
|
||||
json!({"reference_shown_percent": 100.001}),
|
||||
];
|
||||
for settings in refused {
|
||||
let read: ViewSettings = serde_json::from_value(settings.clone()).unwrap();
|
||||
assert!(read.validate().is_err(), "{settings}");
|
||||
}
|
||||
let edge: ViewSettings = serde_json::from_value(json!({"reference_shown_percent": 100.0})).unwrap();
|
||||
assert!(edge.validate().is_ok());
|
||||
}
|
||||
@@ -67,6 +67,8 @@ pub struct ModelBody {
|
||||
/// [`Body::outline_bounds`] of `body` (with the body it was measured on), worked out once
|
||||
/// and shared by clones.
|
||||
outline: Arc<OnceLock<(Body, solidcraft_geom::Aabb3)>>,
|
||||
/// [`ModelBody::volume`] (with the body it was measured on), measured once and shared by clones.
|
||||
volume: Arc<OnceLock<(Body, Option<f64>)>>,
|
||||
/// [`kernel::measure`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones: inspecting a design again measures only the bodies that changed.
|
||||
measured: Arc<OnceLock<(Body, kernel::Result<kernel::BodyMeasure>)>>,
|
||||
@@ -97,11 +99,24 @@ impl ModelBody {
|
||||
bytes: 0,
|
||||
source_key: 0,
|
||||
outline: Arc::new(OnceLock::new()),
|
||||
volume: Arc::new(OnceLock::new()),
|
||||
measured: Arc::new(OnceLock::new()),
|
||||
surface: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body's volume in mm³ ([`kernel::measure_mass`]), measured once and shared by clones: a
|
||||
/// body's measure depends on nothing else, so the bill of materials and the physical
|
||||
/// properties weigh it once however often they are asked. `None` when it can't be measured.
|
||||
pub fn volume(&self) -> Option<f64> {
|
||||
let measure = || kernel::measure_mass(&self.body).ok().map(|m| m.volume);
|
||||
match self.volume.get_or_init(|| (self.body.clone(), measure())) {
|
||||
(measured, v) if measured.same(&self.body) => *v,
|
||||
// The body was replaced in this cell's holder since: measure it again.
|
||||
_ => measure(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A surface body: some shell does not close ([`Body::is_surface`]), worked out once.
|
||||
pub fn is_surface(&self) -> bool {
|
||||
match self.surface.get_or_init(|| (self.body.clone(), self.body.is_surface())) {
|
||||
@@ -252,6 +267,23 @@ impl ModelBody {
|
||||
})
|
||||
}
|
||||
|
||||
/// The box of [`ModelBody::mesh`] seen on a plane, in the plane's own coordinates
|
||||
/// ([`Plane::to_local`]): a drawing view's extents (W07), again without making a placement's
|
||||
/// moved copy.
|
||||
pub fn mesh_bounds_in(&self, plane: &Plane) -> solidcraft_geom::Aabb2 {
|
||||
let (mesh, m) = match self.cached_mesh() {
|
||||
Some(mesh) => (mesh, crate::IDENTITY),
|
||||
None => self.mesh_in_place(),
|
||||
};
|
||||
// The placement folded into each of the plane's axes: a mesh point p lands at
|
||||
// p · axis + offset, with no moved copy of the point made first.
|
||||
let [c0, c1, c2, t] = m.map(|r| Vec3::new(r[0], r[1], r[2]));
|
||||
let offset = t - plane.origin;
|
||||
let axis = |a: Vec3| (Vec3::new(c0.dot(a), c1.dot(a), c2.dot(a)), offset.dot(a));
|
||||
let ((ax, ox), (ay, oy)) = (axis(plane.x), axis(plane.y));
|
||||
mesh.positions.iter().map(|p| Vec2::new(p.dot(ax) + ox, p.dot(ay) + oy)).collect()
|
||||
}
|
||||
|
||||
/// [`ModelBody::plan_mesh_at`] as a shared mesh and the move that places it, as
|
||||
/// [`ModelBody::mesh_in_place`] does for the reference mesh.
|
||||
pub fn plan_mesh_in_place_at(&self, q: crate::display_quality::DisplayQuality) -> Option<(Arc<Mesh>, crate::Mat)> {
|
||||
|
||||
@@ -157,8 +157,7 @@ pub enum FaceAttachmentStatus {
|
||||
}
|
||||
|
||||
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
|
||||
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
|
||||
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
|
||||
crate::map_plane(matrix, plane).ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
|
||||
@@ -677,6 +677,8 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
|
||||
pub fn read(s: &str) -> Result<Document> {
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
|
||||
let (version, applied) = DESIGN.take_version(&mut v)?;
|
||||
// Each drawing is a record of its own family, read by its own rules, numbers and all.
|
||||
let drawings = take_drawings(&mut v)?;
|
||||
check_design_numbers(&v)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
@@ -798,11 +800,22 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
// These lists used to allocate independently. Keep their existing identities, but never
|
||||
// let a later deletion make them available again after loading an older design.
|
||||
d.next_id = d.next_item_id()?;
|
||||
d.drawings = crate::drawing::in_design(drawings).map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
d.format = format_string(FORMAT);
|
||||
d.format_pending = pending_names();
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Take a plain JSON design's drawings out of its JSON and read each, before the design's own
|
||||
/// rules see anything: a drawing this build can't read refuses the design, never dropped.
|
||||
fn take_drawings(v: &mut Value) -> Result<Vec<crate::drawing::Drawing>> {
|
||||
let Some(list) = v.as_object_mut().and_then(|o| o.remove("drawings")) else { return Ok(Vec::new()) };
|
||||
let Value::Array(list) = list else { return Err(DocError::Invalid("damaged design: `drawings` is not a list".into())) };
|
||||
crate::drawing::check_count(list.len()).map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
let read = |(i, d): (usize, Value)| crate::drawing::Drawing::from_value(d).map_err(|e| DocError::Invalid(format!("drawing {}: {e}", i + 1)));
|
||||
list.into_iter().enumerate().map(read).collect()
|
||||
}
|
||||
|
||||
fn all_unique(ids: impl Iterator<Item = u64>) -> bool {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
ids.into_iter().all(|id| seen.insert(id))
|
||||
@@ -1155,7 +1168,7 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
|
||||
/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
pub(crate) const MAX_REAL: f64 = 1e9;
|
||||
|
||||
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
|
||||
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A bill of materials view can be numbered (`assembly.bom.renumber`, `assembly.bom.item.set`):
|
||||
//! its rows' item numbers are saved in `assembly.item_numbers`, one entry for each view numbered
|
||||
//! (its assembly, view and list options) with each row's number and lock under the row's key.
|
||||
//! Older designs need no transformation: a design without it has no view numbered, and every
|
||||
//! number is provisional as before. The last release asks for an update instead of dropping it on
|
||||
//! save.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A design holds its drawings (#3890): `drawings`, each a drawing record of its own family
|
||||
//! (`solidcraft-drawing/N`, `crate::drawing`). A design container keeps each drawing in its own
|
||||
//! entry, `drawings/<id>.json`, beside `document.json`; a plain JSON design keeps them inline.
|
||||
//! Older designs need no transformation: they hold no drawings. The last release refuses a design
|
||||
//! saved with this (the newer format number), so it never opens one and saves it without them.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An occurrence can count in the bill of materials as another type than its component
|
||||
//! (`bom_type` on the occurrence: `normal`, `phantom`, `reference`, `purchased` or `inseparable`),
|
||||
//! for that occurrence only; `occurrence.bom_type` sets it. Older designs need no transformation:
|
||||
//! an occurrence without it counts as its component. The last release asks for an update instead
|
||||
//! of dropping it on save.
|
||||
@@ -9,24 +9,19 @@
|
||||
//! use and drawn again when it is one. [`IdSource::seeded`] gives the same ids for the same
|
||||
//! seed, for tests and replays, and still a new one in a file that seed made;
|
||||
//! [`IdSource::from_entropy`] is what a session uses.
|
||||
//! - In a file an id is 13 characters of Crockford's base32, lowercase: digits and letters without
|
||||
//! i, l, o and u, the first `0` to `f` (`0000000000001`, `fzzzzzzzzzzzz`). [`encode`] writes it
|
||||
//! and [`decode`] reads only that spelling, so an id has one form in a file's text. A field
|
||||
//! holding an id says `#[serde(with = "crate::ids")]`.
|
||||
//! - In a file an id is 13 characters of Crockford's base32, lowercase: [`encode`] writes it and
|
||||
//! [`decode`] reads only that spelling ([`solidcraft_geom::ids`], where data the geometry crate
|
||||
//! defines can use it too). A field holding an id says `#[serde(with = "crate::ids")]`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::BuildHasher;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
pub use solidcraft_geom::ids::{ID_CHARS, decode, deserialize, encode, serialize};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// The characters of an id in a file: 64 bits in 5-bit digits, the first holding the top four.
|
||||
pub const ID_CHARS: usize = 13;
|
||||
/// Crockford's base32 digits, lowercase.
|
||||
const DIGITS: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
/// splitmix64's step: odd, so the state passes every `u64` before it comes round again.
|
||||
const GAMMA: u64 = 0x9e37_79b9_7f4a_7c15;
|
||||
|
||||
@@ -51,6 +46,12 @@ impl IdSource {
|
||||
IdSource::seeded(RandomState::new().hash_one((nanos, MADE.fetch_add(1, Ordering::Relaxed))))
|
||||
}
|
||||
|
||||
/// Where the source stands: [`IdSource::seeded`] with it gives the ids this one gives next
|
||||
/// (a replay's start).
|
||||
pub fn seed(&self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// The next id that isn't 0 and isn't `in_use`. A run never draws a number twice, so at most
|
||||
/// one draw per id in use, and one 0, come before a free one.
|
||||
pub fn next(&mut self, in_use: &BTreeSet<u64>) -> Result<u64> {
|
||||
@@ -73,32 +74,6 @@ impl IdSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// `id` as a file writes it: [`ID_CHARS`] base32 digits, the most significant first.
|
||||
pub fn encode(id: u64) -> String {
|
||||
(0..ID_CHARS).rev().map(|k| DIGITS.get(((id >> (5 * k)) & 31) as usize).map_or('0', |&d| char::from(d))).collect()
|
||||
}
|
||||
|
||||
/// The id `text` writes, if it is one in [`encode`]'s spelling: anything else, 0 included, is not.
|
||||
pub fn decode(text: &str) -> Option<u64> {
|
||||
if text.len() != ID_CHARS {
|
||||
return None;
|
||||
}
|
||||
// A first digit above `f` overflows the 64 bits.
|
||||
let id = text.bytes().try_fold(0u64, |id, b| id.checked_mul(32)?.checked_add(DIGITS.iter().position(|&d| d == b)? as u64))?;
|
||||
(id != 0).then_some(id)
|
||||
}
|
||||
|
||||
/// serde's `with` for an id field: written as [`encode`] writes it, read as [`decode`] reads it.
|
||||
pub fn serialize<S: Serializer>(id: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&encode(*id))
|
||||
}
|
||||
|
||||
/// See [`serialize`].
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<u64, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
decode(&text).ok_or_else(|| serde::de::Error::custom(format!("an id is {ID_CHARS} characters of lowercase Crockford base32, not all 0")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,21 +82,9 @@ mod tests {
|
||||
BTreeSet::new()
|
||||
}
|
||||
|
||||
/// Every boundary of the spelling: 1, the last value of one digit and the first of two, the
|
||||
/// top digit alone and the largest id, and splitmix64's first output for seed 0.
|
||||
/// Every id a source gives is written and read back as itself.
|
||||
#[test]
|
||||
fn an_id_is_written_as_13_digits_and_read_back() {
|
||||
let pinned = [
|
||||
(1, "0000000000001"),
|
||||
(31, "000000000000z"),
|
||||
(32, "0000000000010"),
|
||||
(1 << 60, "1000000000000"),
|
||||
(u64::MAX, "fzzzzzzzzzzzz"),
|
||||
(0xe220_a839_7b1d_cdaf, "e485875xhvkdf"),
|
||||
];
|
||||
for (id, text) in pinned {
|
||||
assert_eq!((encode(id), decode(text)), (text.to_string(), Some(id)), "{id:#x}");
|
||||
}
|
||||
fn a_sources_ids_are_written_and_read_back() {
|
||||
let mut source = IdSource::seeded(1);
|
||||
for _ in 0..10_000 {
|
||||
let id = source.next(&none()).unwrap();
|
||||
@@ -129,34 +92,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only [`encode`]'s spelling is an id: the wrong length, 0, a first digit past `f`, capitals
|
||||
/// (case-sensitive, so an id has one spelling to search for), the letters Crockford leaves
|
||||
/// out, and anything not ASCII.
|
||||
#[test]
|
||||
fn only_the_written_spelling_is_an_id() {
|
||||
let not_ids = [
|
||||
"",
|
||||
"000000000001",
|
||||
"00000000000001",
|
||||
"0000000000000",
|
||||
"g000000000000",
|
||||
"zzzzzzzzzzzzz",
|
||||
"Fzzzzzzzzzzzz",
|
||||
"000000000000A",
|
||||
"000000000000i",
|
||||
"000000000000l",
|
||||
"000000000000o",
|
||||
"000000000000u",
|
||||
"00000000000é",
|
||||
"000000000000-",
|
||||
" 000000000001",
|
||||
"000000000001\n",
|
||||
];
|
||||
for text in not_ids {
|
||||
assert_eq!(decode(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A seed gives the same ids every time (splitmix64's published first outputs for seed 0), and
|
||||
/// a run never gives one twice.
|
||||
#[test]
|
||||
|
||||
@@ -624,6 +624,9 @@ pub struct Assembly {
|
||||
pub traces: Vec<crate::motion::MotionTrace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exploded: Vec<crate::motion::ExplodedView>,
|
||||
/// The bill of materials views numbered, with their saved item numbers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub item_numbers: Vec<crate::bom::ItemNumbers>,
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
@@ -698,6 +701,7 @@ impl Assembly {
|
||||
&& self.studies.is_empty()
|
||||
&& self.traces.is_empty()
|
||||
&& self.exploded.is_empty()
|
||||
&& self.item_numbers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2111,7 +2115,7 @@ impl Document {
|
||||
pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub(crate) mod edge;
|
||||
pub use edge::{edge_axis, edge_snap};
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
@@ -2450,8 +2454,15 @@ mod tests {
|
||||
/// values land on their joints; a value for a missing joint is skipped.
|
||||
#[test]
|
||||
fn a_solution_places_occurrences_in_their_moved_parents() {
|
||||
let occurrence =
|
||||
|id, component, parent| crate::Occurrence { id, component, parent, name: format!("O{id}"), transform: IDENTITY, grounded: false };
|
||||
let occurrence = |id, component, parent| crate::Occurrence {
|
||||
id,
|
||||
component,
|
||||
parent,
|
||||
name: format!("O{id}"),
|
||||
transform: IDENTITY,
|
||||
grounded: false,
|
||||
bom_type: None,
|
||||
};
|
||||
// Component 1 sits at the root twice (its first occurrence is the frame its children sit
|
||||
// in), component 2 inside 1 and component 3 inside 2.
|
||||
let mut doc = Document {
|
||||
|
||||
@@ -941,7 +941,15 @@ mod tests {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence {
|
||||
id,
|
||||
component: id,
|
||||
parent: 0,
|
||||
name: format!("O{id}"),
|
||||
transform: IDENTITY,
|
||||
grounded,
|
||||
bom_type: None,
|
||||
};
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
let mut doc = Document { occurrences: (1..=10).map(|id| occ(id, id == 1)).collect(), ..Document::default() };
|
||||
let (r, v, s) = (JointKind::Rigid, JointKind::Revolute, JointKind::Slider);
|
||||
|
||||
@@ -67,7 +67,7 @@ fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(u
|
||||
}
|
||||
|
||||
/// Edge `i`'s middle (along its polyline) and the direction it runs there.
|
||||
fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod appearance_pack;
|
||||
mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
pub mod bom;
|
||||
pub mod pbr_appearance;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
@@ -24,6 +25,7 @@ pub mod display_quality;
|
||||
pub mod display_units;
|
||||
mod document;
|
||||
pub mod drawing;
|
||||
pub mod drawing_view;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod face_anchor;
|
||||
@@ -75,8 +77,8 @@ pub mod sketch_equation;
|
||||
pub mod text_params;
|
||||
|
||||
pub use assembly::{
|
||||
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr,
|
||||
map_vector_expr, mat_inverse, mat_mul, rigid,
|
||||
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_plane,
|
||||
map_point_expr, map_vector_expr, mat_inverse, mat_mul, mat_volume_scale, rigid,
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! each may be set (the design, a component or both), how its value is checked and its label.
|
||||
//! It keeps every other key exactly as it was through open, edit and save. Other tools keep
|
||||
//! their own data under one reverse-DNS key each (`com.example.tool`), holding an object.
|
||||
//! A component's part properties have one more key: its bill of materials type (`bom_type`).
|
||||
//! A component's part properties have four more keys ([`PART_ONLY`]): its bill of materials type
|
||||
//! (`bom_type`), its static material (`material`), its quantity (`bom_quantity`) and whether it
|
||||
//! is virtual (`virtual`).
|
||||
//!
|
||||
//! Reading never validates: a well-known key holding something other than text is kept as it
|
||||
//! is and reads as absent, so a file another tool wrote always opens. Writing through
|
||||
@@ -98,6 +100,25 @@ pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION];
|
||||
/// doesn't list it and a design refuses it.
|
||||
pub const BOM_TYPE: &str = "bom_type";
|
||||
|
||||
/// A component's static material: what its bill of materials rows say it is made of, typed in by
|
||||
/// hand in place of the materials worked out from its bodies (any text). It names, it doesn't
|
||||
/// weigh: mass still comes from the bodies' materials.
|
||||
pub const MATERIAL: &str = "material";
|
||||
|
||||
/// What one placement of a component means in a bill of materials: an expression evaluated with
|
||||
/// the design's parameters, a length or (without a unit) a number of pieces. Without it, a
|
||||
/// placement is one piece. The bill of materials evaluates it (`bom::Amount`).
|
||||
pub const BOM_QUANTITY: &str = "bom_quantity";
|
||||
|
||||
/// A virtual component: one with no geometry (grease, paint, a label), listed in the bill of
|
||||
/// materials with its quantity. It holds `true`; it never holds bodies, sketches or placed
|
||||
/// components.
|
||||
pub const VIRTUAL: &str = "virtual";
|
||||
|
||||
/// The part properties only a component holds, none of them a row of [`KEYS`]: the Design
|
||||
/// Properties dialog doesn't list them and `document.metadata.set` refuses them.
|
||||
pub const PART_ONLY: [&str; 4] = [BOM_TYPE, MATERIAL, BOM_QUANTITY, VIRTUAL];
|
||||
|
||||
/// Longest key, in bytes.
|
||||
pub const MAX_KEY_BYTES: usize = 128;
|
||||
/// Longest well-known text value, in bytes.
|
||||
@@ -120,10 +141,10 @@ pub fn keys(owner: Owner) -> impl Iterator<Item = &'static Key> {
|
||||
KEYS.iter().filter(move |k| k.scope.covers(owner))
|
||||
}
|
||||
|
||||
/// The names of [`keys`]`(owner)`, and a component's [`BOM_TYPE`], as refusals list them.
|
||||
/// The names of [`keys`]`(owner)`, and a component's [`PART_ONLY`] keys, as refusals list them.
|
||||
pub fn key_list(owner: Owner) -> String {
|
||||
let bom_type = (owner == Owner::Component).then_some(BOM_TYPE);
|
||||
keys(owner).map(|k| k.name).chain(bom_type).collect::<Vec<_>>().join(", ")
|
||||
let part_only = PART_ONLY.iter().copied().filter(|_| owner == Owner::Component);
|
||||
keys(owner).map(|k| k.name).chain(part_only).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// One of SolidCraft's keys, by name.
|
||||
@@ -133,7 +154,7 @@ pub fn key(name: &str) -> Option<&'static Key> {
|
||||
|
||||
/// A key SolidCraft reads as a design or part property.
|
||||
pub fn is_well_known(name: &str) -> bool {
|
||||
key(name).is_some() || name == BOM_TYPE
|
||||
key(name).is_some() || PART_ONLY.contains(&name)
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
@@ -248,6 +269,18 @@ impl Metadata {
|
||||
pub fn bom_type(&self) -> BomType {
|
||||
self.text(BOM_TYPE).and_then(BomType::parse).unwrap_or_default()
|
||||
}
|
||||
/// The component's static material, when one is typed in.
|
||||
pub fn material(&self) -> Option<&str> {
|
||||
self.text(MATERIAL)
|
||||
}
|
||||
/// The component's quantity expression, when it has one.
|
||||
pub fn bom_quantity(&self) -> Option<&str> {
|
||||
self.text(BOM_QUANTITY)
|
||||
}
|
||||
/// The component is virtual: anything but `true` reads as not.
|
||||
pub fn is_virtual(&self) -> bool {
|
||||
self.0.get(VIRTUAL) == Some(&Value::Bool(true))
|
||||
}
|
||||
|
||||
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
|
||||
/// kept as they are.
|
||||
@@ -285,8 +318,19 @@ impl Metadata {
|
||||
|
||||
/// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't.
|
||||
fn check(owner: Owner, key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if key == BOM_TYPE && owner == Owner::Component {
|
||||
return bom_type(value);
|
||||
if owner == Owner::Component {
|
||||
match key {
|
||||
BOM_TYPE => return bom_type(value),
|
||||
VIRTUAL => {
|
||||
return match value {
|
||||
Value::Bool(true) => Ok(Some(value)),
|
||||
Value::Bool(false) | Value::Null => Ok(None),
|
||||
_ => Err(invalid(format!("`{VIRTUAL}` is true or false"))),
|
||||
};
|
||||
}
|
||||
MATERIAL | BOM_QUANTITY => return text(key, value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) {
|
||||
return (k.check)(key, value);
|
||||
@@ -385,6 +429,57 @@ mod tests {
|
||||
assert!(Metadata::default().set(Design, BOM_TYPE, json!("phantom")).is_err(), "a design has no bill of materials type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_material_takes_any_text_and_reads_as_absent_otherwise() {
|
||||
let mut m = Metadata::default();
|
||||
assert_eq!(m.material(), None);
|
||||
let longest = "é".repeat(MAX_TEXT_BYTES / 2);
|
||||
for text in ["Stainless steel 1.4301", "Mixed: see drawing", "鋼", longest.as_str()] {
|
||||
assert!(m.set(Component, MATERIAL, json!(text)).unwrap(), "{text}");
|
||||
assert_eq!(m.material(), Some(text));
|
||||
}
|
||||
for bad in
|
||||
[json!("x".repeat(MAX_TEXT_BYTES + 1)), json!(7.85), json!({"name": "Steel"}), json!(["Steel"]), json!(format!("a{}b", char::from(7)))]
|
||||
{
|
||||
assert!(m.set(Component, MATERIAL, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(m.material(), Some(longest.as_str()), "a refused value leaves the old one");
|
||||
assert!(m.set(Component, MATERIAL, Value::Null).unwrap(), "null removes it");
|
||||
assert_eq!(m.material(), None);
|
||||
// Another tool's number is kept as it is and reads as no static material.
|
||||
let other: Metadata = serde_json::from_value(json!({"material": 42})).unwrap();
|
||||
assert_eq!((other.material(), other.get(MATERIAL)), (None, Some(&json!(42))));
|
||||
assert!(other.others().next().is_none(), "SolidCraft reads it, so it isn't another tool's key");
|
||||
assert!(!keys(Design).any(|k| k.name == MATERIAL), "the Design Properties dialog doesn't list it");
|
||||
assert!(Metadata::default().set(Design, MATERIAL, json!("Steel")).is_err(), "a design has no static material");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_takes_true_or_false_and_a_quantity_takes_text() {
|
||||
let mut m = Metadata::default();
|
||||
assert!(!m.is_virtual());
|
||||
assert!(m.set(Component, VIRTUAL, json!(true)).unwrap());
|
||||
assert!(m.is_virtual());
|
||||
assert_eq!(m.get(VIRTUAL), Some(&json!(true)));
|
||||
for bad in [json!("true"), json!(1), json!({}), json!([true])] {
|
||||
assert!(m.set(Component, VIRTUAL, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(m.is_virtual(), "a refused value leaves the old one");
|
||||
assert!(m.set(Component, VIRTUAL, json!(false)).unwrap(), "false removes it");
|
||||
assert!(!m.set(Component, VIRTUAL, Value::Null).unwrap());
|
||||
assert!(m.get(VIRTUAL).is_none());
|
||||
// Another tool's text is kept and reads as not virtual.
|
||||
let other: Metadata = serde_json::from_value(json!({"virtual": "yes"})).unwrap();
|
||||
assert!(!other.is_virtual());
|
||||
assert!(m.set(Component, BOM_QUANTITY, json!("2 * span + 50 mm")).unwrap());
|
||||
assert_eq!(m.bom_quantity(), Some("2 * span + 50 mm"));
|
||||
assert!(m.set(Component, BOM_QUANTITY, json!(2.5)).is_err(), "an expression is text");
|
||||
assert!(m.set(Component, BOM_QUANTITY, json!("")).unwrap());
|
||||
assert_eq!(m.bom_quantity(), None);
|
||||
assert!(m.others().next().is_none(), "SolidCraft reads both");
|
||||
assert!(!keys(Design).any(|k| k.name == VIRTUAL || k.name == BOM_QUANTITY), "the Design Properties dialog lists neither");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
@@ -422,7 +517,11 @@ mod tests {
|
||||
}
|
||||
// Every key SolidCraft took on the design and on a component before the table still is.
|
||||
assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license");
|
||||
assert_eq!(key_list(Component), format!("{}, {BOM_TYPE}", key_list(Design)), "a component also takes its bill of materials type");
|
||||
assert_eq!(
|
||||
key_list(Component),
|
||||
format!("{}, {BOM_TYPE}, {MATERIAL}, {BOM_QUANTITY}, {VIRTUAL}", key_list(Design)),
|
||||
"a component also takes its bill of materials type, static material, quantity and virtual flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -454,7 +553,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
refusal(Component, "colour"),
|
||||
"`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \
|
||||
description, license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
description, license, bom_type, material, bom_quantity, virtual; another tool's data goes under a reverse-DNS key \
|
||||
such as `com.example.tool`"
|
||||
);
|
||||
for owner in [Design, Component] {
|
||||
let word = owner.names().0;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
|
||||
//! only geometry decided.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::history_names::{Match, Names};
|
||||
@@ -45,49 +47,93 @@ pub struct EdgeHit {
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
|
||||
/// between the faces it names (followed through splits and merges of those faces); else the
|
||||
/// A body's edge names and face names, worked out once for every edge name looked up on it.
|
||||
pub(crate) struct BodyEdges {
|
||||
pub(crate) mesh: Arc<Mesh>,
|
||||
names: Vec<String>,
|
||||
faces: Arc<Names>,
|
||||
}
|
||||
|
||||
/// The edges of a body a stored edge name matches, before a picked point decides between them.
|
||||
pub(crate) enum EdgeMatch {
|
||||
/// The edge with that name, or the one edge between the faces it names.
|
||||
Edge(usize),
|
||||
/// Pieces of it: the faces it names meet along several edges now, or only a piece of one of
|
||||
/// them is left.
|
||||
Pieces(Vec<usize>),
|
||||
}
|
||||
|
||||
impl BodyEdges {
|
||||
pub(crate) fn new(b: &ModelBody) -> BodyEdges {
|
||||
BodyEdges { mesh: b.mesh(), names: edge_names(b), faces: naming::names(b) }
|
||||
}
|
||||
|
||||
/// Whether edge `i`'s name follows exact history: the faces it separates do, and it isn't one
|
||||
/// of several edges between them, numbered by where they lie.
|
||||
/// Edge `i`'s persistent name.
|
||||
pub(crate) fn name(&self, i: usize) -> Option<&str> {
|
||||
self.names.get(i).map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn verified(&self, i: usize) -> bool {
|
||||
self.mesh.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| self.faces.verified(*f as usize)))
|
||||
&& self.names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
|
||||
}
|
||||
|
||||
/// What the stored edge name `name` matches: the edge with that name; else the one edge
|
||||
/// between the faces it names (followed through splits and merges of those faces); else the
|
||||
/// pieces of it. `None` when nothing does.
|
||||
pub(crate) fn matching(&self, name: &str) -> Option<EdgeMatch> {
|
||||
if let Some(i) = self.names.iter().position(|n| n == name) {
|
||||
return Some(EdgeMatch::Edge(i));
|
||||
}
|
||||
// By the faces it separates: the best way any edge matches.
|
||||
let wanted = strip_edge_number(name);
|
||||
let mut best: Option<(Match, Vec<usize>)> = None;
|
||||
for (i, fs) in self.mesh.edge_faces.iter().enumerate() {
|
||||
let [fa, fb] = fs.as_slice() else { continue };
|
||||
let Some(level) = edge_match(&self.faces, *fa as usize, *fb as usize, wanted) else { continue };
|
||||
keep_best(&mut best, level, i);
|
||||
}
|
||||
let (level, cands) = best?;
|
||||
let one = match cands.as_slice() {
|
||||
[i] if level >= Match::Piece => Some(*i),
|
||||
_ => None,
|
||||
};
|
||||
Some(one.map_or(EdgeMatch::Pieces(cands), EdgeMatch::Edge))
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge of a body a stored edge name finds: the edge [`BodyEdges::matching`] gives, else the
|
||||
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
|
||||
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
|
||||
if let Some(old) = legacy_name(name) {
|
||||
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
|
||||
}
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let faces = naming::names(b);
|
||||
let mid = |i: usize| edge_mid(&m, i);
|
||||
// Several edges between the same two faces are numbered by where they lie: not exact.
|
||||
let verified = |i: usize| {
|
||||
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
|
||||
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
|
||||
};
|
||||
let exact = |i: usize| -> Result<EdgeHit, String> {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
mid(i)
|
||||
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.ok_or_else(|| "edge".into())
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
return exact(i);
|
||||
let edges = BodyEdges::new(b);
|
||||
let m = &edges.mesh;
|
||||
let gone = || format!("edge `{name}` no longer exists");
|
||||
match edges.matching(name).ok_or_else(gone)? {
|
||||
EdgeMatch::Edge(i) => {
|
||||
let on = edge_near(b, m, i, near);
|
||||
edge_mid(m, i)
|
||||
.map(|p| EdgeHit {
|
||||
edge: i,
|
||||
point: if on { near } else { p },
|
||||
split: false,
|
||||
verified: edges.verified(i),
|
||||
on_point: on,
|
||||
legacy: false,
|
||||
})
|
||||
.ok_or_else(|| "edge".into())
|
||||
}
|
||||
EdgeMatch::Pieces(cands) => cands
|
||||
.iter()
|
||||
.filter_map(|i| edge_mid(m, *i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: edges.verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(gone),
|
||||
}
|
||||
// By the faces it separates: the best way any edge matches.
|
||||
let wanted = strip_edge_number(name);
|
||||
let mut best: Option<(Match, Vec<usize>)> = None;
|
||||
for (i, fs) in m.edge_faces.iter().enumerate() {
|
||||
let [fa, fb] = fs.as_slice() else { continue };
|
||||
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
|
||||
keep_best(&mut best, level, i);
|
||||
}
|
||||
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
|
||||
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
|
||||
return exact(*i);
|
||||
}
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
|
||||
|
||||
@@ -623,6 +623,8 @@ pub enum ParamUser {
|
||||
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
|
||||
/// says which; a link is named by the joint it drives).
|
||||
Joint { joint: u64, name: String, input: String },
|
||||
/// A component's quantity in bills of materials ([`crate::metadata::BOM_QUANTITY`]).
|
||||
Quantity { component: u64, name: String },
|
||||
/// A sheet metal or plastic rule's value (`kind` says which, `field` which value).
|
||||
Rule { kind: String, name: String, field: String },
|
||||
/// The plane a canvas lies on (`input` is its label, e.g. `Offset`).
|
||||
@@ -640,6 +642,7 @@ impl ParamUser {
|
||||
None => format!("configuration column {column}"),
|
||||
},
|
||||
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
|
||||
ParamUser::Quantity { name, .. } => format!("component {name} (quantity)"),
|
||||
ParamUser::Rule { kind, name, field } => format!("{kind} rule {name} ({field})"),
|
||||
ParamUser::Canvas { name, input, .. } => format!("canvas {name} ({input})"),
|
||||
}
|
||||
@@ -780,6 +783,11 @@ impl Document {
|
||||
out.push(ParamUser::Joint { joint, name: jname, input });
|
||||
}
|
||||
}
|
||||
for c in &self.components {
|
||||
if c.metadata.bom_quantity().is_some_and(|e| expr::references(e).iter().any(|r| r == name)) {
|
||||
out.push(ParamUser::Quantity { component: c.id, name: c.name.clone() });
|
||||
}
|
||||
}
|
||||
let uses = |e: &str| expr::references(e).iter().any(|r| r == name);
|
||||
for r in &self.sheet.rules {
|
||||
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
|
||||
@@ -946,6 +954,13 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for c in &mut self.components {
|
||||
let Some(e) = c.metadata.bom_quantity() else { continue };
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != e && c.metadata.set(crate::metadata::Owner::Component, crate::metadata::BOM_QUANTITY, r.into()).is_ok() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
// Sheet metal and plastic rules and canvas planes hold expressions too (#4420).
|
||||
let rules = self.sheet.rules.iter_mut().flat_map(|r| r.fields_mut()).chain(self.plastic.rules.iter_mut().flat_map(|r| r.fields_mut()));
|
||||
for (_, e) in rules {
|
||||
|
||||
@@ -178,6 +178,8 @@ fn proper_rigid(m: &crate::Mat) -> bool {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solidcraft_geom::{Plane, Vec2};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -246,6 +248,32 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&mesh, &nested.mesh()));
|
||||
}
|
||||
|
||||
/// A block 100 x 50 x 20 mm seen on a view's plane measures as worked by hand: its size along
|
||||
/// each principal pair of axes, and from an isometric corner (150/sqrt(2) wide, 190/sqrt(6)
|
||||
/// high; the box's extremes are its corners, so the mesh gives them exactly). A body meshed
|
||||
/// already is measured from its own mesh.
|
||||
#[test]
|
||||
fn a_bodys_bounds_on_a_plane_are_its_mesh_seen_on_it() {
|
||||
let block = ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(100., 50., 20.)).unwrap(), 1);
|
||||
let plane = |x: Vec3, y: Vec3| Plane { origin: Vec3::ZERO, x, y };
|
||||
let iso_x = Vec3::new(1., 1., 0.) * (1. / 2f64.sqrt());
|
||||
let iso_y = Vec3::new(-1., 1., 2.) * (1. / 6f64.sqrt());
|
||||
let views = [
|
||||
(plane(Vec3::X, Vec3::Z), (100., 20.)),
|
||||
(plane(Vec3::X, Vec3::Y), (100., 50.)),
|
||||
(plane(Vec3::Y, Vec3::Z), (50., 20.)),
|
||||
(plane(iso_x, iso_y), (150. / 2f64.sqrt(), 190. / 6f64.sqrt())),
|
||||
];
|
||||
for (basis, (w, h)) in views {
|
||||
let size = block.mesh_bounds_in(&basis).size();
|
||||
assert!((size.x - w).abs() < 1e-9 && (size.y - h).abs() < 1e-9, "{basis:?}: {size:?}");
|
||||
}
|
||||
assert!(block.cached_mesh().is_some(), "an unplaced body measures its own mesh");
|
||||
let moved = Plane { origin: Vec3::new(30., 0., 5.), ..plane(Vec3::X, Vec3::Z) };
|
||||
let b = block.mesh_bounds_in(&moved);
|
||||
assert!(b.min.dist(Vec2::new(-30., -5.)) < 1e-9 && b.max.dist(Vec2::new(70., 15.)) < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
/// A placement hands out its definition's own meshes and its move without making the moved
|
||||
/// copy: every placement of a part gives the same mesh, and that mesh moved is the
|
||||
/// placement's own.
|
||||
@@ -269,13 +297,23 @@ mod tests {
|
||||
let (mesh, m) = p.mesh_in_place();
|
||||
assert!(Arc::ptr_eq(&mesh, &own), "the definition's mesh, not a copy");
|
||||
let bounds = p.mesh_bounds();
|
||||
let tilted = Plane { origin: Vec3::new(1., 2., 3.), x: Vec3::new(0.6, 0.8, 0.), y: Vec3::Z };
|
||||
let seen = p.mesh_bounds_in(&tilted);
|
||||
assert!(p.cached_mesh().is_none(), "no moved copy is made");
|
||||
let (plan_mesh, pm) = p.plan_mesh_in_place_at(q).unwrap();
|
||||
assert!(Arc::ptr_eq(&plan_mesh, &source.plan_mesh_at(q).unwrap()));
|
||||
assert_eq!(pm, m);
|
||||
let moved = p.mesh();
|
||||
assert!(p.cached_mesh().is_some());
|
||||
let cached = p.mesh_bounds_in(&tilted);
|
||||
assert!(cached.min.dist(seen.min) < 1e-9 && cached.max.dist(seen.max) < 1e-9, "a meshed placement measures its own mesh the same");
|
||||
let exact = moved.bounds();
|
||||
assert!(bounds.min.dist(exact.min) < 1e-9 && bounds.max.dist(exact.max) < 1e-9, "{bounds:?} vs {exact:?}");
|
||||
let on_plane = moved.positions.iter().fold(solidcraft_geom::Aabb2::EMPTY, |mut b, q| {
|
||||
b.add(tilted.to_local(*q));
|
||||
b
|
||||
});
|
||||
assert!(seen.min.dist(on_plane.min) < 1e-9 && seen.max.dist(on_plane.max) < 1e-9, "{seen:?} vs {on_plane:?}");
|
||||
for (a, b) in mesh.positions.iter().zip(&moved.positions) {
|
||||
assert!(crate::apply_point(&m, *a).dist(*b) < 1e-9);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
components,
|
||||
occurrences: _,
|
||||
assembly: _,
|
||||
// Drawings draw the model; they feed none of it.
|
||||
drawings: _,
|
||||
sheet,
|
||||
plastic,
|
||||
parts,
|
||||
|
||||
@@ -1,56 +1,108 @@
|
||||
//! Part properties and the bill of materials. `component.metadata` and
|
||||
//! `component.metadata.set` read and change a component definition's part number, description,
|
||||
//! revision and free properties (shared by every occurrence, saved with the component; the keys
|
||||
//! and their checks are `solidcraft_doc::metadata`'s `KEYS`, the rows a component takes);
|
||||
//! `assembly.bom` counts the placed components of the design, or of one component, and lists
|
||||
//! them as rows, optionally written to a CSV or JSON file. With `group_children`, a part with a
|
||||
//! part number keeps the parts placed inside it (a bolt's washers and nut) in its own row.
|
||||
//! and their checks are `solidcraft_doc::metadata`'s `KEYS` and `PART_ONLY`, the rows a component
|
||||
//! takes); `occurrence.bom_type` lets one occurrence count as another bill of materials type.
|
||||
//!
|
||||
//! A component's bill of materials type (`bom_type`, a part property) decides how it counts: a
|
||||
//! phantom has no row and what it holds counts in its parent; a reference counts nowhere, and
|
||||
//! nothing inside it does; a purchased or inseparable component is one row, and of what an
|
||||
//! inseparable one holds only its purchased parts reach the parts-only list.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
//! `assembly.bom` presents the rows of a bill of materials view of the design, or of one
|
||||
//! component, as JSON, optionally written to a CSV or JSON file. `assembly.bom.renumber` and
|
||||
//! `assembly.bom.item.set` number a view, which saves its item numbers; [`record_item_numbers`]
|
||||
//! saves a numbered view's new rows' numbers after each change. The rows and every rule behind
|
||||
//! them (bill of materials types, folding, materials, mass, item numbers) are
|
||||
//! `solidcraft_doc::bom`'s.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::BomType;
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
|
||||
use solidcraft_doc::bom::{self, Bom, Group, MAX_ITEM, Mass, Numbering, Order, Quantity, Request, Row, Tree, Uncounted, View};
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_QUANTITY, BOM_TYPE, MATERIAL, MAX_KEYS, Owner, PART_ONLY, VIRTUAL, check_text, key_list};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::bom::{OccurrenceBomTypeParams, OccurrenceBomTypeResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const OCCURRENCE_BOM_TYPE_CONTRACT: TypedContract<OccurrenceBomTypeParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
|
||||
bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}",
|
||||
bom_type, material (its static material or null), bom_quantity (or null) and virtual (components only), metadata (as saved, other tools' keys too), \
|
||||
properties: {name: text}",
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \
|
||||
(SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
|
||||
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
|
||||
metadata, properties, changed: [keys]",
|
||||
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); material?: text, a static \
|
||||
material: what its bill of materials rows say it is made of, in place of its bodies' materials (it doesn't change the \
|
||||
mass; empty or null clears it); bom_quantity?: an expression, what one placement means in a bill of materials: a \
|
||||
number of pieces (\"4\") or a length (\"2.5 m\", \"2 * span + 50 mm\"), evaluated with the design's parameters, above \
|
||||
0 (empty or null removes it: one piece); virtual?: true|false (a component with no geometry, such as grease or a \
|
||||
label: it can't be the active component or hold bodies, sketches or components); values?: {key: value} (SolidCraft's \
|
||||
keys, as document.metadata's properties lists them, bom_type, material, bom_quantity, virtual, or a reverse-DNS key \
|
||||
holding an object); properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, metadata, \
|
||||
properties, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("occurrence.bom_type", "Bill of Materials Type", crate::checked!("occurrence.bom_type", occurrence_type))
|
||||
.icon("bom")
|
||||
.doc_only()
|
||||
.contract(&OCCURRENCE_BOM_TYPE_CONTRACT)
|
||||
.output(output_schema_for::<OccurrenceBomTypeResult>)
|
||||
.params(
|
||||
"occurrence: id or name; path?: [occurrence ids] (checked when given); bom_type: normal|phantom|reference|purchased|inseparable \
|
||||
(this occurrence counts as that type in every bill of materials, in place of its component's; an occurrence inside a \
|
||||
subassembly belongs to its definition, so the type holds wherever the subassembly is placed) | same_as_component or null \
|
||||
(as its component again). One undo step → occurrence, bom_type (its own, or same_as_component), effective (the type it \
|
||||
counts as), changed",
|
||||
),
|
||||
CommandSpec::new("assembly.bom", "Bill of Materials", bom).at("SOLID", "INSPECT").icon("bom").noundo().params(
|
||||
"component?: id or name (default the design); levels?: flat (default: every part at any depth, counted through its \
|
||||
subassemblies) | top (the component's direct children, subassemblies as one row each); group_by?: [\"part_number\"] \
|
||||
subassemblies) | top (the component's direct children, subassemblies as one row each) | all (as top, each subassembly's \
|
||||
own rows listed after it, to any depth: items 1.1, 1.1.1, as in the CSV); show_purchased_contents?: true (in all, a \
|
||||
purchased part's contents listed under it too); group_by?: [\"part_number\"] \
|
||||
(default; components without a part number get a row per definition) | [\"definition\"]; group_children?: true (a component \
|
||||
with a part number is one row, the parts placed inside it listed under it as children: [{item, part_number, description, \
|
||||
revision, each, count, definition}], not as rows of their own); path?: write a .csv or .json file (children as items 1.1, \
|
||||
1.2…); format?: csv|json (default from the path). Each component's bom_type counts: phantom has no row (what it holds \
|
||||
counts here), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased \
|
||||
parts are listed too) → rows: [{item, part_number, description, revision, bom_type, count, definition, components, \
|
||||
properties, children?}] sorted by part number, total_occurrences (children included)",
|
||||
revision, each, count, definition, material, mass_g?}], not as rows of their own); mass?: true (measures each part: \
|
||||
mass_g, what one of the row's parts weighs in grams with what is placed inside it, except what the list shows as rows of \
|
||||
their own beside it (in parts only a part's contents are rows of their own, but not a folded part's or a purchased one's, \
|
||||
and of an inseparable one's only its purchased parts are), so the rows add up to the assembly; a reference placement \
|
||||
adds nothing; null when nothing in it has a body, a body can't be measured or the row's definitions differ); path?: write \
|
||||
a .csv (columns item, part_number, description, revision, count, definition, material, quantity) or .json file (children as \
|
||||
items 1.1, 1.2…); format?: csv|json (default from the path). Each placement counts as \
|
||||
its occurrence's bom_type, else its component's: phantom has no row (what it holds counts here; its own bodies, \
|
||||
if it has any, are a row of their own), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased parts are listed too); placements \
|
||||
of one component that count as different types are different rows → rows: [{item, part_number, description, revision, \
|
||||
bom_type, count, definition, material (the physical materials of the definitions' own bodies, \"; \" between them; a \
|
||||
row that weighs what is placed inside it shows the one material all of that shares, else \"\"), mass_g?, quantity? \
|
||||
(when its definitions have a bom_quantity: what its placements add up to, a number with quantity_unit pieces or mm, a \
|
||||
placement without one counting 1 piece; null with quantity_unknown mixed (pieces and a length) or invalid (a quantity \
|
||||
that no longer evaluates)), components, \
|
||||
properties, children?, static? ([\"material\"]: a value typed in for the component, shown in place of the worked-out \
|
||||
one)}] sorted by part number, total_occurrences (children included), total_mass_g (with mass: \
|
||||
what the assembly weighs, measured from the design rather than added up from rows; null when unknown), numbered \
|
||||
(the view's item numbers are saved: assembly.bom.renumber). In a numbered view each level is listed by item number \
|
||||
and a row says provisional (its number not saved yet: a row added since shows the next number at its level, saved \
|
||||
by the next change), locked and duplicate when true",
|
||||
),
|
||||
CommandSpec::new("assembly.bom.renumber", "Renumber Items", renumber).icon("bom").params(
|
||||
"component?, levels?, group_by?, group_children?, show_purchased_contents?: the view, as assembly.bom takes them; \
|
||||
start?: 1; step?: 1 (whole numbers, 1 to 999999); order?: list (default: by part number, as an unnumbered view \
|
||||
lists) | item (in item-number order, closing the gaps). Numbers every row from start by step, each level on its \
|
||||
own (in all levels, within its parent); locked rows keep their numbers and the others skip them; rows that are \
|
||||
gone are forgotten. Saves the view's numbers, with these list options: after it, a row added takes the highest \
|
||||
number at its level + 1 and a row removed leaves a gap until the next renumber. One undo step → the view as \
|
||||
assembly.bom gives it",
|
||||
),
|
||||
CommandSpec::new("assembly.bom.item.set", "Change Item Number", item_set).icon("bom").params(
|
||||
"component?, levels?, group_by?, group_children?, show_purchased_contents?: the view, as assembly.bom takes them; \
|
||||
row: the row's item as the view shows it now (\"3\", or \"1.2\" under row 1); item?: 1 to 999999 (a number \
|
||||
another row has is allowed: both are marked duplicate); locked?: true|false (a renumber keeps a locked number). \
|
||||
A view not numbered yet is numbered first, every row keeping the number it shows. One undo step → the view as \
|
||||
assembly.bom gives it, with duplicates: [item numbers used twice at the row's level]",
|
||||
),
|
||||
];
|
||||
|
||||
/// Longest free property name, in bytes.
|
||||
const MAX_PROPERTY_NAME_BYTES: usize = 128;
|
||||
/// How deep, and over how many placements, the bill of materials walks.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_PLACEMENTS: usize = 100_000;
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
@@ -81,9 +133,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in BOM_KEYS {
|
||||
out.insert(k.to_string(), json!(m.text(k)));
|
||||
}
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
// A bill of materials type, static material, quantity and virtual flag are a component's;
|
||||
// the design has none.
|
||||
if id != 0 {
|
||||
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
|
||||
out.insert(MATERIAL.into(), json!(m.material()));
|
||||
out.insert(BOM_QUANTITY.into(), json!(m.bom_quantity()));
|
||||
out.insert(VIRTUAL.into(), json!(m.is_virtual()));
|
||||
}
|
||||
out.insert("metadata".into(), json!(m));
|
||||
out.insert("properties".into(), properties);
|
||||
@@ -124,7 +180,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
|
||||
Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")),
|
||||
}
|
||||
for k in BOM_KEYS.iter().chain([&BOM_TYPE]) {
|
||||
for k in BOM_KEYS.iter().chain(PART_ONLY.iter()) {
|
||||
if let Some(v) = p.get(*k) {
|
||||
entries.push((k.to_string(), v.clone()));
|
||||
}
|
||||
@@ -136,7 +192,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) => return Err(bad(cmd, "`properties` must be an object: {name: text, …}")),
|
||||
};
|
||||
if entries.is_empty() && props.is_empty() {
|
||||
return Err(bad(cmd, format!("give {}, {BOM_TYPE}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("give {}, {}, values or properties ({})", BOM_KEYS.join(", "), PART_ONLY.join(", "), key_list(Owner::Component)),
|
||||
));
|
||||
}
|
||||
let mut changed: Vec<String> = Vec::new();
|
||||
for (k, v) in entries {
|
||||
@@ -144,6 +203,15 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
changed.push(k);
|
||||
}
|
||||
}
|
||||
if changed.iter().any(|k| k == BOM_QUANTITY)
|
||||
&& let Some(text) = metadata.bom_quantity()
|
||||
{
|
||||
bom::amount(&s.doc, text).map_err(|e| bad(cmd, format!("`{BOM_QUANTITY}`: {e}")))?;
|
||||
}
|
||||
// What a virtual component may not hold is `Session::execute`'s check, for every command.
|
||||
if metadata.is_virtual() && s.active_component == id {
|
||||
return Err(bad(cmd, "the active component can't be virtual: activate another one first"));
|
||||
}
|
||||
for (name, value) in props {
|
||||
let was = properties.get(name).cloned();
|
||||
match property(name, value, cmd)? {
|
||||
@@ -172,219 +240,149 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"component": id, "metadata": c.map(|c| &c.metadata), "properties": c.map(|c| &c.properties), "changed": changed}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Levels {
|
||||
Flat,
|
||||
Top,
|
||||
/// The word that clears an occurrence's own bill of materials type.
|
||||
pub(crate) const SAME_AS_COMPONENT: &str = "same_as_component";
|
||||
|
||||
/// `occurrence.bom_type`'s `bom_type`: a type, or `None` for the component's (`same_as_component`
|
||||
/// or null); the handler's message for anything else, or for none given.
|
||||
pub(crate) fn occurrence_bom_type(v: Option<&Value>) -> std::result::Result<Option<BomType>, String> {
|
||||
let words = || format!("{}, or {SAME_AS_COMPONENT}", BomType::ALL.map(BomType::as_str).join(", "));
|
||||
match v {
|
||||
Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(w)) if w == SAME_AS_COMPONENT => Ok(None),
|
||||
Some(Value::String(w)) => BomType::parse(w).map(Some).ok_or_else(|| format!("`bom_type` is one of {}", words())),
|
||||
_ => Err(format!("give `bom_type`: {}", words())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Group {
|
||||
PartNumber,
|
||||
Definition,
|
||||
}
|
||||
|
||||
/// One row being gathered.
|
||||
struct Row {
|
||||
part_number: String,
|
||||
description: String,
|
||||
revision: String,
|
||||
bom_type: BomType,
|
||||
count: u64,
|
||||
components: Vec<u64>,
|
||||
names: Vec<String>,
|
||||
properties: BTreeMap<String, String>,
|
||||
/// With `group_children`: what is placed inside one of this row's parts, and how often.
|
||||
kit: BTreeMap<u64, u64>,
|
||||
}
|
||||
|
||||
/// What the bill of materials lists and how.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Options {
|
||||
levels: Levels,
|
||||
group: Group,
|
||||
/// A part with a part number keeps its sub-parts in its row.
|
||||
fold: bool,
|
||||
}
|
||||
|
||||
fn options(p: &Value, cmd: &str) -> Result<Options> {
|
||||
let levels = match p.get("levels") {
|
||||
None | Some(Value::Null) => Levels::Flat,
|
||||
Some(Value::String(l)) if l == "flat" => Levels::Flat,
|
||||
Some(Value::String(l)) if l == "top" => Levels::Top,
|
||||
Some(_) => return Err(bad(cmd, "`levels` is flat or top")),
|
||||
fn occurrence_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.bom_type";
|
||||
let id = super::component::occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
super::component::occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let want = occurrence_bom_type(p.get("bom_type")).map_err(|e| bad(cmd, e))?;
|
||||
let changed = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?.bom_type != want;
|
||||
// Only a real change touches the design (an undo step, unsaved changes).
|
||||
if changed && let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.bom_type = want;
|
||||
}
|
||||
let effective = s.doc.occurrences.iter().zip(s.doc.occurrence_bom_types()).find(|(o, _)| o.id == id).map(|(_, t)| t).unwrap_or_default();
|
||||
let reply = OccurrenceBomTypeResult {
|
||||
occurrence: id,
|
||||
bom_type: want.map_or(SAME_AS_COMPONENT, BomType::as_str).to_string(),
|
||||
effective: effective.as_str().to_string(),
|
||||
changed,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// What `assembly.bom`'s parameters ask for: the design's bill of materials unless `component`
|
||||
/// names one.
|
||||
fn request(s: &Session, p: &Value, cmd: &str) -> Result<Request> {
|
||||
let source = if p.get("component").is_some_and(|v| !v.is_null()) { component_id(s, p, cmd)? } else { 0 };
|
||||
let views = || format!("`levels` is {}", View::ALL.map(View::as_str).join(" or "));
|
||||
let view = match p.get("levels") {
|
||||
None | Some(Value::Null) => View::default(),
|
||||
Some(Value::String(l)) => View::parse(l).ok_or_else(|| bad(cmd, views()))?,
|
||||
Some(_) => return Err(bad(cmd, views())),
|
||||
};
|
||||
let group_by = || bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]");
|
||||
let group = match p.get("group_by") {
|
||||
None | Some(Value::Null) => Group::PartNumber,
|
||||
None | Some(Value::Null) => Group::default(),
|
||||
Some(Value::String(g)) => group_key(g, cmd)?,
|
||||
Some(Value::Array(a)) => match a.as_slice() {
|
||||
[Value::String(g)] => group_key(g, cmd)?,
|
||||
_ => return Err(bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]")),
|
||||
_ => return Err(group_by()),
|
||||
},
|
||||
Some(_) => return Err(bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]")),
|
||||
Some(_) => return Err(group_by()),
|
||||
};
|
||||
let fold = match p.get("group_children") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`group_children` is true or false")),
|
||||
let flag = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` is true or false"))),
|
||||
};
|
||||
Ok(Options { levels, group, fold })
|
||||
}
|
||||
|
||||
/// The design's structure, looked up once per bill of materials.
|
||||
struct Index {
|
||||
/// What each component places (by its id; 0 is the root).
|
||||
children: BTreeMap<u64, Vec<u64>>,
|
||||
/// Components with bodies of their own.
|
||||
owners: HashSet<u64>,
|
||||
/// Components with a part number: with `group_children`, one row however much is inside.
|
||||
numbered: HashSet<u64>,
|
||||
/// Components whose bill of materials type isn't normal.
|
||||
types: BTreeMap<u64, BomType>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
fn new(s: &Session) -> Index {
|
||||
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
|
||||
for o in &s.doc.occurrences {
|
||||
children.entry(o.parent).or_default().push(o.component);
|
||||
}
|
||||
let numbered = s.doc.components.iter().filter(|c| c.metadata.part_number().is_some_and(|n| !n.is_empty())).map(|c| c.id).collect();
|
||||
let types = s.doc.components.iter().map(|c| (c.id, c.metadata.bom_type())).filter(|(_, t)| *t != BomType::Normal).collect();
|
||||
Index { children, owners: s.doc.body_owners(&s.model.state()), numbered, types }
|
||||
}
|
||||
|
||||
fn bom_type(&self, component: u64) -> BomType {
|
||||
self.types.get(&component).copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a walk below a component lists.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Walk {
|
||||
/// What the bill of materials lists.
|
||||
Listed,
|
||||
/// Inside an inseparable component, in parts only: just its purchased parts.
|
||||
Purchased,
|
||||
Ok(Request { source, view, group, fold: flag("group_children")?, mass: flag("mass")?, purchased_contents: flag("show_purchased_contents")? })
|
||||
}
|
||||
|
||||
fn group_key(g: &str, cmd: &str) -> Result<Group> {
|
||||
match g {
|
||||
"part_number" => Ok(Group::PartNumber),
|
||||
"definition" => Ok(Group::Definition),
|
||||
o => Err(bad(cmd, format!("can't group by `{o}` (part_number or definition)"))),
|
||||
Group::parse(g).ok_or_else(|| bad(cmd, format!("can't group by `{g}` (part_number or definition)")))
|
||||
}
|
||||
|
||||
/// A mass as `assembly.bom` gives it: grams, or null when it isn't known.
|
||||
fn grams(mass: Mass) -> Value {
|
||||
match mass {
|
||||
Mass::Grams(grams) => json!(grams),
|
||||
Mass::NotAsked | Mass::Unknown(_) => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times each component is listed below `root`, by its bill of materials type: in
|
||||
/// `Top`, the direct children; in `Flat`, the parts at any depth (a part in a subassembly placed
|
||||
/// twice counts twice), not below a purchased part, nor below a numbered part when `fold`, and of
|
||||
/// what an inseparable part holds only its purchased parts. A phantom's contents count where the
|
||||
/// phantom is placed; a reference counts nowhere, and nothing inside it does.
|
||||
fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, cmd: &str) -> Result<BTreeMap<u64, u64>> {
|
||||
let mut out: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut placed = 0usize;
|
||||
let mut stack: Vec<(u64, usize, Walk)> = vec![(root, 0, Walk::Listed)];
|
||||
while let Some((comp, depth, walk)) = stack.pop() {
|
||||
for child in ix.children.get(&comp).into_iter().flatten() {
|
||||
placed += 1;
|
||||
if depth >= MAX_DEPTH || placed > MAX_PLACEMENTS {
|
||||
return Err(bad(cmd, format!("the assembly is deeper than {MAX_DEPTH} levels or has more than {MAX_PLACEMENTS} placements")));
|
||||
}
|
||||
let inside = ix.children.contains_key(child);
|
||||
match (ix.bom_type(*child), walk) {
|
||||
(BomType::Reference, _) => {}
|
||||
(BomType::Phantom, _) => stack.push((*child, depth + 1, walk)),
|
||||
(BomType::Purchased, _) => *out.entry(*child).or_default() += 1,
|
||||
(_, Walk::Purchased) => {
|
||||
if inside {
|
||||
stack.push((*child, depth + 1, Walk::Purchased));
|
||||
}
|
||||
}
|
||||
(BomType::Inseparable, Walk::Listed) => {
|
||||
*out.entry(*child).or_default() += 1;
|
||||
if levels == Levels::Flat && inside {
|
||||
stack.push((*child, depth + 1, Walk::Purchased));
|
||||
}
|
||||
}
|
||||
(BomType::Normal, Walk::Listed) => {
|
||||
// Parts only: a subassembly is listed through its parts, unless it has bodies
|
||||
// of its own (or, folding, a part number).
|
||||
let folded = fold && ix.numbered.contains(child);
|
||||
if levels == Levels::Top || !inside || folded || ix.owners.contains(child) {
|
||||
*out.entry(*child).or_default() += 1;
|
||||
}
|
||||
if levels == Levels::Flat && inside && !folded {
|
||||
stack.push((*child, depth + 1, Walk::Listed));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The rows as `assembly.bom` gives them. In all levels they come one after another, each followed
|
||||
/// by the rows under it (items 1, 1.1, 1.1.1), as the CSV lists them: nested, 64 levels would be
|
||||
/// two containers a level, deeper than JSON readers take (serde_json stops at 128). Otherwise a
|
||||
/// row's children (a folded part's contents, one level) are listed in it.
|
||||
fn rows_json(list: &Bom, view: View, mass: bool) -> Vec<Value> {
|
||||
if view == View::AllLevels {
|
||||
list.rows_deep().iter().map(|(item, row)| row_json(row, item, mass, list.numbered, false)).collect()
|
||||
} else {
|
||||
list.rows.iter().map(|row| row_json(row, &[row.item], mass, list.numbered, true)).collect()
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How a kit reads when rows are compared: its parts by part number (else definition), bill of
|
||||
/// materials type and count, so a kit holding a bought bolt isn't the kit holding a made one.
|
||||
fn kit_key(s: &Session, ix: &Index, kit: &BTreeMap<u64, u64>) -> String {
|
||||
let mut parts: Vec<String> = kit
|
||||
.iter()
|
||||
.map(|(id, n)| {
|
||||
let pn = s.doc.components.iter().find(|c| c.id == *id).and_then(|c| c.metadata.part_number()).unwrap_or_default();
|
||||
let bom_type = ix.bom_type(*id).as_str();
|
||||
if pn.is_empty() { format!("#{id} {bom_type}×{n}") } else { format!("{pn} {bom_type}×{n}") }
|
||||
})
|
||||
.collect();
|
||||
parts.sort();
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits: &BTreeMap<u64, BTreeMap<u64, u64>>) -> Vec<Row> {
|
||||
let mut grouped: BTreeMap<(String, String, u64, BomType), Row> = BTreeMap::new();
|
||||
for (id, n) in counts {
|
||||
let Some(c) = s.doc.components.iter().find(|c| c.id == *id) else { continue };
|
||||
let m = &c.metadata;
|
||||
let part_number = m.part_number().unwrap_or_default().to_string();
|
||||
let kit = kits.get(id).cloned().unwrap_or_default();
|
||||
let bom_type = ix.bom_type(*id);
|
||||
// Without a part number a definition is its own row; parts with different contents
|
||||
// (a bolt with or without its nut) or different bill of materials types are different rows.
|
||||
let k = match group {
|
||||
Group::PartNumber if !part_number.is_empty() => (part_number.clone(), kit_key(s, ix, &kit), 0, bom_type),
|
||||
_ => (String::new(), String::new(), *id, bom_type),
|
||||
};
|
||||
let row = grouped.entry(k).or_insert_with(|| Row {
|
||||
part_number: part_number.clone(),
|
||||
description: String::new(),
|
||||
revision: String::new(),
|
||||
bom_type,
|
||||
count: 0,
|
||||
components: Vec::new(),
|
||||
names: Vec::new(),
|
||||
properties: BTreeMap::new(),
|
||||
kit,
|
||||
});
|
||||
row.count += n;
|
||||
row.components.push(*id);
|
||||
if !row.names.contains(&c.name) {
|
||||
row.names.push(c.name.clone());
|
||||
}
|
||||
if row.description.is_empty() {
|
||||
row.description = m.description().unwrap_or_default().to_string();
|
||||
}
|
||||
if row.revision.is_empty() {
|
||||
row.revision = m.revision().unwrap_or_default().to_string();
|
||||
}
|
||||
for (k, v) in &c.properties {
|
||||
row.properties.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
/// One row, `item` its item numbers from the top (`[2, 1]` reads 2.1), with its children in it when
|
||||
/// `nested`. A row listed under another reads its item as "2.1" and gives `each`, and leaves out
|
||||
/// its components and properties (the output #1888 set). Its numbering flags are given when true;
|
||||
/// provisional only in a `numbered` view (in one never numbered every number is).
|
||||
fn row_json(row: &Row, item: &[u32], mass: bool, numbered: bool, nested: bool) -> Value {
|
||||
let mut v = json!({"part_number": row.part_number, "description": row.description, "revision": row.revision,
|
||||
"bom_type": row.bom_type.as_str(), "count": row.count, "definition": row.names.join("; "), "material": row.materials.join("; ")});
|
||||
if item.len() <= 1 {
|
||||
v["item"] = json!(row.item);
|
||||
v["components"] = json!(row.components);
|
||||
v["properties"] = json!(row.properties);
|
||||
} else {
|
||||
v["item"] = json!(bom::item_text(item));
|
||||
v["each"] = json!(row.each);
|
||||
}
|
||||
if mass {
|
||||
v["mass_g"] = grams(row.mass);
|
||||
}
|
||||
match row.quantity {
|
||||
Quantity::Count => {}
|
||||
Quantity::Pieces(n) => (v["quantity"], v["quantity_unit"]) = (json!(n), json!("pieces")),
|
||||
Quantity::Length(mm) => (v["quantity"], v["quantity_unit"]) = (json!(mm), json!("mm")),
|
||||
Quantity::Unknown(why) => {
|
||||
v["quantity"] = Value::Null;
|
||||
v["quantity_unknown"] = json!(match why {
|
||||
Uncounted::Mixed => "mixed",
|
||||
Uncounted::Invalid => "invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Row> = grouped.into_values().collect();
|
||||
// By part number; rows without one after them, by name.
|
||||
out.sort_by(|a, b| {
|
||||
(a.part_number.is_empty(), &a.part_number, &a.names, &a.components).cmp(&(b.part_number.is_empty(), &b.part_number, &b.names, &b.components))
|
||||
});
|
||||
out
|
||||
for (flag, on) in [("provisional", numbered && row.provisional), ("locked", row.locked), ("duplicate", row.duplicate)] {
|
||||
if on {
|
||||
v[flag] = json!(true);
|
||||
}
|
||||
}
|
||||
if !row.statics.is_empty() {
|
||||
v["static"] = json!(row.statics.iter().map(|s| s.key()).collect::<Vec<_>>());
|
||||
}
|
||||
if nested && !row.children.is_empty() {
|
||||
let mut under = item.to_vec();
|
||||
under.push(0);
|
||||
v["children"] = row
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if let Some(last) = under.last_mut() {
|
||||
*last = c.item;
|
||||
}
|
||||
row_json(c, &under, mass, numbered, true)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A CSV field, always quoted.
|
||||
@@ -392,19 +390,20 @@ fn quote(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// The bill of materials as CSV: fixed columns, every field quoted.
|
||||
fn csv(rows: &[Value]) -> String {
|
||||
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
|
||||
/// The bill of materials as CSV: fixed columns, every field quoted, each row followed by the rows
|
||||
/// listed under it. A column added later goes at the end, so the others keep their places.
|
||||
fn csv(list: &Bom) -> String {
|
||||
let mut out = ["item", "part_number", "description", "revision", "count", "definition", "material", "quantity"].map(quote).join(",");
|
||||
out.push('\n');
|
||||
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
|
||||
for r in lines {
|
||||
let text = |k: &str| match &r[k] {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Null => String::new(),
|
||||
v => v.to_string(),
|
||||
for (item, r) in list.rows_deep() {
|
||||
let (item, count, definition, material) = (bom::item_text(&item), r.count.to_string(), r.names.join("; "), r.materials.join("; "));
|
||||
let quantity = match r.quantity {
|
||||
Quantity::Pieces(n) => n.to_string(),
|
||||
Quantity::Length(mm) => format!("{mm} mm"),
|
||||
Quantity::Count | Quantity::Unknown(_) => String::new(),
|
||||
};
|
||||
let fields = ["item", "part_number", "description", "revision", "count", "definition"].map(|k| quote(&text(k)));
|
||||
out.push_str(&fields.join(","));
|
||||
let fields: [&str; 8] = [&item, &r.part_number, &r.description, &r.revision, &count, &definition, &material, &quantity];
|
||||
out.push_str(&fields.map(quote).join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
@@ -412,52 +411,17 @@ fn csv(rows: &[Value]) -> String {
|
||||
|
||||
fn bom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.bom";
|
||||
let root = if p.get("component").is_some_and(|v| !v.is_null()) { component_id(s, p, cmd)? } else { 0 };
|
||||
let o = options(p, cmd)?;
|
||||
let ix = Index::new(s);
|
||||
let counts = counts(&ix, root, o.levels, o.fold, cmd)?;
|
||||
// Folding: what one of each numbered part holds, as parts at any depth. A purchased or
|
||||
// inseparable part already keeps what it holds out of the list.
|
||||
let mut kits: BTreeMap<u64, BTreeMap<u64, u64>> = BTreeMap::new();
|
||||
if o.fold {
|
||||
for id in counts.keys().filter(|id| ix.numbered.contains(id) && ix.bom_type(**id) == BomType::Normal) {
|
||||
let kit = self::counts(&ix, *id, Levels::Flat, false, cmd)?;
|
||||
if !kit.is_empty() {
|
||||
kits.insert(*id, kit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
let rows: Vec<Value> = rows(s, &ix, &counts, o.group, &kits)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
total += r.count;
|
||||
let mut v = json!({"item": i + 1, "part_number": r.part_number, "description": r.description, "revision": r.revision,
|
||||
"bom_type": r.bom_type.as_str(), "count": r.count, "definition": r.names.join("; "), "components": r.components,
|
||||
"properties": r.properties});
|
||||
if !r.kit.is_empty() {
|
||||
let children: Vec<Value> = self::rows(s, &ix, &r.kit, Group::PartNumber, &BTreeMap::new())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(j, c)| {
|
||||
let count = c.count.saturating_mul(r.count);
|
||||
total += count;
|
||||
json!({"item": format!("{}.{}", i + 1, j + 1), "part_number": c.part_number, "description": c.description,
|
||||
"revision": c.revision, "bom_type": c.bom_type.as_str(), "each": c.count, "count": count,
|
||||
"definition": c.names.join("; ")})
|
||||
})
|
||||
.collect();
|
||||
v["children"] = Value::Array(children);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let request = request(s, p, cmd)?;
|
||||
let state = s.model.state();
|
||||
let list = bom::list(&mut Tree::new(&s.doc, &state), &request).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let rows = rows_json(&list, request.view, request.mass);
|
||||
let mut out = Map::new();
|
||||
out.insert("component".into(), json!(root));
|
||||
out.insert("levels".into(), json!(if o.levels == Levels::Top { "top" } else { "flat" }));
|
||||
out.insert("group_by".into(), json!([if o.group == Group::Definition { "definition" } else { "part_number" }]));
|
||||
out.insert("group_children".into(), json!(o.fold));
|
||||
out.insert("component".into(), json!(request.source));
|
||||
out.insert("levels".into(), json!(request.view.as_str()));
|
||||
out.insert("group_by".into(), json!([request.group.as_str()]));
|
||||
out.insert("group_children".into(), json!(request.fold));
|
||||
out.insert("show_purchased_contents".into(), json!(request.purchased_contents));
|
||||
out.insert("mass".into(), json!(request.mass));
|
||||
if let Some(path) = str_(p, "path") {
|
||||
let format = match str_(p, "format").map(str::to_ascii_lowercase) {
|
||||
Some(f) => f,
|
||||
@@ -466,8 +430,14 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => return Err(bad(cmd, "`path` must end in .csv or .json, or give `format`")),
|
||||
};
|
||||
let text = match format.as_str() {
|
||||
"csv" => csv(&rows),
|
||||
"json" => serde_json::to_string_pretty(&json!({"rows": rows, "total_occurrences": total})).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
"csv" => csv(&list),
|
||||
"json" => {
|
||||
let mut file = json!({"rows": rows, "total_occurrences": list.total.occurrences});
|
||||
if request.mass {
|
||||
file["total_mass_g"] = grams(list.total.mass);
|
||||
}
|
||||
serde_json::to_string_pretty(&file).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
|
||||
};
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
@@ -475,10 +445,104 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.insert("format".into(), json!(format));
|
||||
}
|
||||
out.insert("rows".into(), Value::Array(rows));
|
||||
out.insert("total_occurrences".into(), json!(total));
|
||||
out.insert("total_occurrences".into(), json!(list.total.occurrences));
|
||||
if request.mass {
|
||||
out.insert("total_mass_g".into(), grams(list.total.mass));
|
||||
}
|
||||
out.insert("numbered".into(), json!(list.numbered));
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// A whole number from 1 to [`MAX_ITEM`] (`default` when not given).
|
||||
fn item_number(p: &Value, k: &str, default: Option<u32>, cmd: &str) -> Result<Option<u32>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.filter(|n| (1..=MAX_ITEM).contains(n))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is a whole number from 1 to {MAX_ITEM}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view numbered as `how` says, its numbers saved (only a real change touches the design), and
|
||||
/// the view as `assembly.bom` gives it.
|
||||
fn renumbered(s: &mut Session, p: &Value, how: Numbering, cmd: &str) -> Result<(Value, Bom)> {
|
||||
let request = Request { mass: false, ..request(s, p, cmd)? };
|
||||
let state = s.model.state();
|
||||
let numbers = bom::number(&mut Tree::new(&s.doc, &state), &request, how).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(numbers) = numbers {
|
||||
let saved = &mut s.doc_mut().assembly.item_numbers;
|
||||
match saved.iter_mut().find(|n| n.source == numbers.source && n.view == numbers.view) {
|
||||
Some(old) => *old = numbers,
|
||||
None => saved.push(numbers),
|
||||
}
|
||||
}
|
||||
let list = bom::list(&mut Tree::new(&s.doc, &state), &request).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let reply = json!({"component": request.source, "levels": request.view.as_str(), "numbered": list.numbered,
|
||||
"rows": rows_json(&list, request.view, false), "total_occurrences": list.total.occurrences});
|
||||
Ok((reply, list))
|
||||
}
|
||||
|
||||
fn renumber(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.bom.renumber";
|
||||
let start = item_number(p, "start", Some(1), cmd)?.unwrap_or(1);
|
||||
let step = item_number(p, "step", Some(1), cmd)?.unwrap_or(1);
|
||||
let order = match p.get("order") {
|
||||
None | Some(Value::Null) => Order::List,
|
||||
Some(Value::String(o)) if o == "list" => Order::List,
|
||||
Some(Value::String(o)) if o == "item" => Order::Item,
|
||||
Some(_) => return Err(bad(cmd, "`order` is list or item")),
|
||||
};
|
||||
Ok(renumbered(s, p, Numbering::Renumber { start, step, order }, cmd)?.0)
|
||||
}
|
||||
|
||||
fn item_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.bom.item.set";
|
||||
let shown = || bad(cmd, "`row` is the row's item as the view shows it: \"3\", or \"1.2\" under row 1");
|
||||
let row: Vec<u32> = match p.get("row") {
|
||||
Some(Value::Number(n)) => vec![n.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(shown)?],
|
||||
Some(Value::String(text)) if text.len() <= 1024 => {
|
||||
text.split('.').map(|n| n.trim().parse::<u32>().map_err(|_| shown())).collect::<Result<_>>()?
|
||||
}
|
||||
_ => return Err(shown()),
|
||||
};
|
||||
let item = item_number(p, "item", None, cmd)?;
|
||||
let locked = match p.get("locked") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(_) => return Err(bad(cmd, "`locked` is true or false")),
|
||||
};
|
||||
if item.is_none() && locked.is_none() {
|
||||
return Err(bad(cmd, "give `item`, `locked` or both"));
|
||||
}
|
||||
let (mut reply, list) = renumbered(s, p, Numbering::Set { row: row.clone(), item, locked }, cmd)?;
|
||||
// The rows listed with it: under the row above it (the top level for a row of its own).
|
||||
let above = row.split_last().map_or(&[][..], |(_, above)| above);
|
||||
let mut level: &[Row] = &list.rows;
|
||||
for &i in above {
|
||||
level = level.iter().find(|r| r.item == i).map_or(&[][..], |r| r.children.as_slice());
|
||||
}
|
||||
let mut duplicates: Vec<u32> = level.iter().filter(|r| r.duplicate).map(|r| r.item).collect();
|
||||
duplicates.dedup();
|
||||
reply["duplicates"] = json!(duplicates);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// After a change, each numbered view's new rows get their numbers in the design ([`bom::record`]),
|
||||
/// so they are saved in the change's undo step. A design with no view numbered lists nothing.
|
||||
/// Whether the design changed.
|
||||
pub(crate) fn record_item_numbers(s: &mut Session) -> bool {
|
||||
if s.doc.assembly.item_numbers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let state = s.model.state();
|
||||
let Some(numbers) = bom::record(&mut Tree::new(&s.doc, &state)) else { return false };
|
||||
s.doc_mut().assembly.item_numbers = numbers;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bom_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
|
||||
use solidcraft_doc::bom;
|
||||
use solidcraft_doc::metadata::{BOM_QUANTITY, VIRTUAL};
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -16,7 +18,7 @@ use crate::schema::component::{
|
||||
OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
|
||||
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
|
||||
@@ -56,7 +58,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
|
||||
.params(
|
||||
"name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool \
|
||||
(default true); grounded?: bool (default: true for the design's first part, false otherwise); bom_quantity?: what \
|
||||
one placement means in a bill of materials (component.metadata.set's); virtual?: true, a component with no geometry \
|
||||
(grease, paint, a label): listed in bills of materials with its quantity, never active (default activate false; true \
|
||||
is refused), and nothing can be put in it",
|
||||
),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
@@ -91,10 +99,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}. A placement inside it that counts as a reference (its occurrence's bom_type, else its component's) adds nothing, nor does anything inside that; the placement measured counts whatever its type"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties. A placement inside it that counts as a reference (its occurrence's bom_type, else its component's) adds nothing, nor does anything inside that; this placement counts whatever its type"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
|
||||
@@ -188,7 +196,7 @@ fn component(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<u64> {
|
||||
s.doc.find_component(&kk).ok_or_else(|| bad(cmd, format!("no component `{kk}`")))
|
||||
}
|
||||
|
||||
fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
pub(super) fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let kk = key(p.get("occurrence")).ok_or_else(|| bad(cmd, "`occurrence` must be an id or name"))?;
|
||||
s.doc.occurrences.iter().find(|o| o.id.to_string() == kk || o.name == kk).map(|o| o.id).ok_or_else(|| bad(cmd, format!("no occurrence `{kk}`")))
|
||||
}
|
||||
@@ -208,6 +216,26 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str)
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn set_part_property(s: &mut Session, id: u64, key: &str, value: Value) -> Result<()> {
|
||||
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) {
|
||||
c.metadata.set(solidcraft_doc::metadata::Owner::Component, key, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse a change that puts something into a virtual component (`Document::virtual_holding`),
|
||||
/// naming it; one a damaged file already filled is left as it is.
|
||||
pub(crate) fn refuse_filled_virtual(before: &Document, after: &Document) -> Result<()> {
|
||||
let held = after.virtual_holding();
|
||||
if held.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let was = before.virtual_holding();
|
||||
let Some(id) = held.into_iter().find(|id| !was.contains(id)) else { return Ok(()) };
|
||||
let name = after.components.iter().find(|c| c.id == id).map_or("", |c| c.name.as_str());
|
||||
Err(EngineError::Other(format!("`{name}` is a virtual component: it can't hold bodies, sketches or components")))
|
||||
}
|
||||
|
||||
pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
@@ -225,10 +253,15 @@ pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
if let Some(params) = p.as_object()
|
||||
&& let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate" | "grounded"))
|
||||
&& let Some(key) = params
|
||||
.keys()
|
||||
.find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate" | "grounded" | "bom_quantity" | "virtual"))
|
||||
{
|
||||
let hint = if params.contains_key("bodies") { "; use component.move_bodies or component.from_bodies to move bodies" } else { "" };
|
||||
return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate, grounded{hint}")));
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate, grounded, bom_quantity, virtual{hint}"),
|
||||
));
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
@@ -250,6 +283,11 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if parent_path.len() >= 64 {
|
||||
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
|
||||
}
|
||||
let made_virtual = p.get("virtual").and_then(Value::as_bool) == Some(true);
|
||||
let activate = p.get("activate").and_then(Value::as_bool);
|
||||
if made_virtual && activate == Some(true) {
|
||||
return Err(bad(cmd, "a virtual component can't be the active component"));
|
||||
}
|
||||
let grounded = match p.get("grounded") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "grounded must be true or false"))?),
|
||||
@@ -260,7 +298,15 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
o.grounded = grounded;
|
||||
}
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
if let Some(text) = p.get(BOM_QUANTITY).filter(|q| !q.is_null()) {
|
||||
let text = text.as_str().ok_or_else(|| bad(cmd, format!("`{BOM_QUANTITY}` is an expression, as text")))?;
|
||||
bom::amount(&s.doc, text).map_err(|e| bad(cmd, format!("`{BOM_QUANTITY}`: {e}")))?;
|
||||
set_part_property(s, id, BOM_QUANTITY, json!(text))?;
|
||||
}
|
||||
if made_virtual {
|
||||
set_part_property(s, id, VIRTUAL, json!(true))?;
|
||||
}
|
||||
if activate.unwrap_or(!made_virtual) {
|
||||
let mut path = parent_path;
|
||||
if let Some(o) = s.doc.occurrence_of(id) {
|
||||
path.push(o.id);
|
||||
@@ -303,6 +349,9 @@ fn finish_edit(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
if s.doc.components.iter().any(|c| c.id == id && c.metadata.is_virtual()) {
|
||||
return Err(bad("component.activate", "a virtual component can't be the active component: it holds no geometry"));
|
||||
}
|
||||
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
|
||||
let occurrence = occurrence(s, p, "component.activate")?;
|
||||
let path = occurrence_path(s, p, occurrence, "component.activate")?;
|
||||
@@ -331,6 +380,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return json!(o);
|
||||
}
|
||||
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
|
||||
if let Some(t) = o.bom_type {
|
||||
v["bom_type"] = json!(t);
|
||||
}
|
||||
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
|
||||
v["transform"] = json!(o.transform);
|
||||
}
|
||||
@@ -600,13 +652,18 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::component_clone::paste(s, comp, occ, transform, parent, p)
|
||||
}
|
||||
|
||||
/// An occurrence path as a command takes it: 1 to 64 occurrence ids.
|
||||
pub(crate) fn path_ids(value: &Value) -> std::result::Result<Vec<u64>, &'static str> {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= solidcraft_doc::MAX_PLACEMENT_DEPTH)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or("`path` must list 1..64 occurrence ids")
|
||||
}
|
||||
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
let path = if let Some(value) = p.get("path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))?
|
||||
path_ids(value).map_err(|e| bad(cmd, e))?
|
||||
} else {
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
let mut path = s.doc.first_path(o.parent);
|
||||
|
||||
@@ -323,6 +323,7 @@ pub(super) fn build(
|
||||
o.transform = transform;
|
||||
// A copy starts free, even of a grounded part (#2804); the parts inside it keep theirs.
|
||||
o.grounded = false;
|
||||
o.bom_type = source_occurrence.as_ref().and_then(|o| o.bom_type);
|
||||
}
|
||||
if let Some(o) = &source_occurrence {
|
||||
occurrences.insert(o.id, root_occurrence);
|
||||
@@ -337,6 +338,7 @@ pub(super) fn build(
|
||||
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.grounded = old.grounded;
|
||||
o.bom_type = old.bom_type;
|
||||
}
|
||||
occurrences.insert(old.id, id);
|
||||
}
|
||||
|
||||