Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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",
|
||||
@@ -4357,6 +4358,7 @@ dependencies = [
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4410,6 +4412,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
|
||||
|
||||
@@ -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_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,924 @@
|
||||
//! 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))
|
||||
}
|
||||
|
||||
/// 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,11 +1,14 @@
|
||||
//! 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.
|
||||
//! - 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, 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
|
||||
@@ -13,6 +16,13 @@
|
||||
//! - **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.
|
||||
//! - **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`]).
|
||||
//! - **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 +30,47 @@
|
||||
//! 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 and balloons by id, then [`Drawing::validate`]; [`Drawing::from_value`] the same from the
|
||||
//! format check on.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::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::{Aabb2, Vec2};
|
||||
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::{self, IdSource};
|
||||
use crate::{DocError, Metadata, Result, expr};
|
||||
|
||||
pub mod balloon;
|
||||
pub mod dimension;
|
||||
pub mod view;
|
||||
|
||||
use balloon::Balloon;
|
||||
use dimension::Dimension;
|
||||
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 +94,46 @@ 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,
|
||||
/// 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>>,
|
||||
/// By id ([`Drawing::add_balloons`]), as the dimensions are.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub balloons: Vec<Arc<Balloon>>,
|
||||
/// 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.
|
||||
@@ -149,48 +188,97 @@ 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)? }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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, 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(),
|
||||
sheets: vec![Arc::new(first)],
|
||||
views: Vec::new(),
|
||||
dimensions: Vec::new(),
|
||||
balloons: Vec::new(),
|
||||
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, balloons) = (std::mem::take(&mut d.views), std::mem::take(&mut d.dimensions), std::mem::take(&mut d.balloons));
|
||||
d.add_views(views);
|
||||
d.add_dimensions(dimensions);
|
||||
d.add_balloons(balloons);
|
||||
d.validate().map_err(damaged)?;
|
||||
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
|
||||
}
|
||||
@@ -211,17 +299,58 @@ 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}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is on sheet `id`: its views and the dimensions and balloons in them. 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 {
|
||||
let views: Vec<u64> = self.views.iter().filter(|v| v.sheet == id).map(|v| v.id).collect();
|
||||
// The views are listed by id, so the sheet's are too.
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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.views.retain(|v| v.sheet != id);
|
||||
self.dimensions.retain(|d| on.dimensions.binary_search(&d.id).is_err());
|
||||
self.balloons.retain(|b| on.balloons.binary_search(&b.id).is_err());
|
||||
on
|
||||
}
|
||||
|
||||
/// 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.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`)")
|
||||
@@ -236,24 +365,25 @@ 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()
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
// The border must lay out on the sheet: the one rule, as drawing it uses it. A few small
|
||||
// lists per sheet, so every edit's check stays cheap.
|
||||
if let Some(border) = &sheet.border {
|
||||
border.validate().map_err(from_sheet)?;
|
||||
border.layout(&sheet.sheet).map_err(from_sheet)?;
|
||||
}
|
||||
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
|
||||
match sheet.documents {
|
||||
@@ -264,6 +394,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,133 @@
|
||||
//! 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 std::sync::Arc;
|
||||
|
||||
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::dimension::{Lookup, ModelEdge, Status, check_edge, edge_candidates};
|
||||
use super::{Drawing, add_by_id, in_id_order, 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 Drawing {
|
||||
/// Adds `balloons`, each where its id puts it, as [`Drawing::add_dimensions`] adds dimensions.
|
||||
pub fn add_balloons(&mut self, balloons: impl IntoIterator<Item = Arc<Balloon>>) {
|
||||
add_by_id(&mut self.balloons, balloons, |b| b.id);
|
||||
}
|
||||
|
||||
/// Balloon `id`, found by halves in the balloons' id order.
|
||||
pub fn balloon(&self, id: u64) -> Option<&Balloon> {
|
||||
self.balloon_index(id).and_then(|i| self.balloons.get(i)).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Where balloon `id` is in [`Drawing::balloons`].
|
||||
pub fn balloon_index(&self, id: u64) -> Option<usize> {
|
||||
self.balloons.binary_search_by_key(&id, |b| b.id).ok()
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
require(self.balloons.len() <= MAX_BALLOONS, || format!("a drawing has at most {MAX_BALLOONS} balloons"))?;
|
||||
require(in_id_order(&self.balloons, |b| b.id), || "a drawing lists its balloons by id".into())?;
|
||||
for balloon in &self.balloons {
|
||||
self.check_balloon(balloon).map_err(|e| DocError::Invalid(format!("balloon {}: {e}", ids::encode(balloon.id))))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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.balloon(*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,194 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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.add_balloons([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.balloon(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,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}");
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,271 @@
|
||||
//! 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::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)
|
||||
}
|
||||
|
||||
/// 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,314 @@
|
||||
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 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,
|
||||
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,14 @@
|
||||
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_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,18 +20,20 @@ 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.
|
||||
/// 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.
|
||||
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,
|
||||
@@ -30,12 +41,54 @@ fn plate() -> Drawing {
|
||||
sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait),
|
||||
border: Some(own_border()),
|
||||
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.add_balloons([Arc::new(Balloon { id, view: front, edge: edge("F1:front|F1:top"), at: [60.0, 45.0] })]);
|
||||
d.validate().unwrap();
|
||||
d
|
||||
}
|
||||
@@ -43,7 +96,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 +131,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 +150,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 +161,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 +176,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 +184,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 +206,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);
|
||||
@@ -248,29 +296,49 @@ 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");
|
||||
}
|
||||
|
||||
/// 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 +350,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 +388,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 +406,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 +436,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 +447,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 +526,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 +536,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 +629,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);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
}
|
||||
|
||||
fn occurrence(id: u64) -> Occurrence {
|
||||
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false }
|
||||
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false, bom_type: None }
|
||||
}
|
||||
|
||||
fn documents() -> (Document, Document) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Physical properties of an assembly placement, with original body material assignments.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, Mat};
|
||||
use solidcraft_doc::{BomType, Document, Mat};
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
@@ -47,25 +46,7 @@ fn placed_doc(s: &Session) -> Document {
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solidcraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solidcraft_doc::IDENTITY)];
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let world = solidcraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child_path.clone(), world));
|
||||
stack.push((occurrence.component, child_path, world));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
doc.placements_below(0, &[], solidcraft_doc::IDENTITY).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -118,22 +99,16 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solidcraft_doc::IDENTITY)] }
|
||||
} else {
|
||||
let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = vec![(placement.component, selected.to_vec(), placement.world)];
|
||||
let mut stack = if descendants { out.clone() } else { Vec::new() };
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
let world = solidcraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child.clone(), world));
|
||||
stack.push((occurrence.component, child, world));
|
||||
}
|
||||
if descendants {
|
||||
doc.placements_below(placement.component, selected, placement.world).map_err(|e| bad(cmd, e.to_string()))?
|
||||
} else {
|
||||
vec![(placement.component, selected.to_vec(), placement.world)]
|
||||
}
|
||||
out
|
||||
};
|
||||
// As in the bill of materials, a placement that counts as a reference adds nothing, nor does
|
||||
// anything inside it; the one measured counts whatever its type.
|
||||
let reference: HashSet<u64> =
|
||||
doc.occurrences.iter().zip(doc.occurrence_bom_types()).filter(|(_, t)| *t == BomType::Reference).map(|(o, _)| o.id).collect();
|
||||
// How each closed body's volume spreads about its centre of mass (#8095): one tessellation
|
||||
// however often it is placed, which each placement then turns.
|
||||
let components: BTreeSet<u64> = selected_placements.iter().map(|(component, _, _)| *component).collect();
|
||||
@@ -149,13 +124,19 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
// Closed bodies' (mass, centre of mass, inertia about it), all in world axes.
|
||||
let mut parts: Vec<(f64, Vec3, Option<Tensor3>)> = Vec::new();
|
||||
for (component, path, transform) in selected_placements {
|
||||
if path.iter().skip(selected.len()).any(|id| reference.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
placement_count += 1;
|
||||
for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solidcraft_kernel::measure(&placed)?;
|
||||
// The volume the bill of materials weighs too, measured once for the model; the area,
|
||||
// the centre, the box and the counts are the placed body's.
|
||||
let body_volume = body.volume().map_or(measured.volume, |v| v * solidcraft_doc::mat_volume_scale(&transform));
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
let body_mass = body_volume * density / 1000.0;
|
||||
let closed = crate::cmd::inspect::encloses_volume(&placed);
|
||||
// The mesh's spread with the exact mass, so the tensor agrees with the mass shown.
|
||||
let body_inertia = spread
|
||||
@@ -164,7 +145,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
.map(|g| inertia::from_gyration(&inertia::transformed(g, &linear(&transform)), body_mass))
|
||||
.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
|
||||
if closed {
|
||||
volume += measured.volume;
|
||||
volume += body_volume;
|
||||
mass += body_mass;
|
||||
moment += measured.centroid * body_mass;
|
||||
parts.push((body_mass, measured.centroid, body_inertia));
|
||||
@@ -179,7 +160,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.body_material(&body.name),
|
||||
"closed": closed, "density_g_cm3": closed.then_some(density),
|
||||
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
|
||||
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(body_volume),
|
||||
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
|
||||
"inertia_g_mm2": body_inertia,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
|
||||
@@ -8,8 +8,12 @@ fn close(actual: &Value, expected: f64) {
|
||||
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
|
||||
}
|
||||
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
|
||||
box_at(s, name, 0.0)
|
||||
}
|
||||
/// A new component in the active one, holding a 10 mm cube with its corner at `x` on the X axis.
|
||||
fn box_at(s: &mut Session, name: &str, x: f64) -> (u64, u64, String) {
|
||||
let c = run(s, "component.create", json!({"name": name, "grounded": false}));
|
||||
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
let body = s.model.state().bodies.last().unwrap().name.clone();
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
|
||||
}
|
||||
@@ -76,6 +80,80 @@ fn nested_instances_require_a_path_and_can_exclude_descendants() {
|
||||
assert!(r["total"]["bbox"].is_null());
|
||||
}
|
||||
|
||||
/// Study 8.5: a placement that counts as a reference (a fixture shown for context) adds nothing to
|
||||
/// what is measured, nor does anything inside it, as in the bill of materials; the placement asked
|
||||
/// for counts whatever its type. 10 mm cubes along X: a steel block (7.85 g at 0 mm) and a fixture
|
||||
/// (1.29 g, the default density, at 20 mm) holding a pin (1.29 g at 40 mm).
|
||||
#[test]
|
||||
fn a_reference_placement_below_the_one_measured_adds_nothing() {
|
||||
let mut s = Session::default();
|
||||
let (_, _, block) = box_at(&mut s, "Block", 0.0);
|
||||
run(&mut s, "material.assign", json!({"bodies": [block], "material": "Steel"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let (fixture, fixture_occurrence, _) = box_at(&mut s, "Fixture", 20.0);
|
||||
let (pin, pin_occurrence, _) = box_at(&mut s, "Pin", 40.0);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let root = |s: &mut Session| run(s, "component.properties", json!({"component": "root"}));
|
||||
let counted = |r: &Value| (r["body_count"].as_u64().unwrap(), r["placement_count"].as_u64().unwrap());
|
||||
let fixture_type = |s: &mut Session, bom_type: &str| run(s, "component.metadata.set", json!({"component": fixture, "bom_type": bom_type}));
|
||||
let occurrence_type = |s: &mut Session, occurrence: u64, bom_type: Value| {
|
||||
run(s, "occurrence.bom_type", json!({"occurrence": occurrence, "bom_type": bom_type}));
|
||||
};
|
||||
|
||||
let all = root(&mut s);
|
||||
assert_eq!(counted(&all), (3, 4), "{all}");
|
||||
close(&all["total"]["mass_g"], 7.85 + 1.29 + 1.29);
|
||||
close(&all["total"]["center_of_mass"][0], (5.0 * 7.85 + 25.0 * 1.29 + 45.0 * 1.29) / 10.43);
|
||||
|
||||
// The fixture kept for reference: only the block counts.
|
||||
fixture_type(&mut s, "reference");
|
||||
let block_only = root(&mut s);
|
||||
assert_eq!(counted(&block_only), (1, 2), "{block_only}");
|
||||
assert_eq!(block_only["bodies"][0]["name"], block.as_str());
|
||||
close(&block_only["total"]["mass_g"], 7.85);
|
||||
close(&block_only["total"]["volume_mm3"], 1000.0);
|
||||
close(&block_only["total"]["area_mm2"], 600.0);
|
||||
close(&block_only["total"]["center_of_mass"][0], 5.0);
|
||||
close(&block_only["total"]["bbox"]["max"][0], 10.0);
|
||||
// Asked for, the fixture counts with the pin it holds, and the pin counts too.
|
||||
let by_component = run(&mut s, "component.properties", json!({"component": fixture}));
|
||||
let by_occurrence = run(&mut s, "occurrence.properties", json!({"occurrence": fixture_occurrence}));
|
||||
for r in [by_component, by_occurrence] {
|
||||
assert_eq!(counted(&r), (2, 2), "{r}");
|
||||
close(&r["total"]["mass_g"], 2.58);
|
||||
close(&r["total"]["center_of_mass"][0], 35.0);
|
||||
}
|
||||
let pin_by_component = run(&mut s, "component.properties", json!({"component": pin}));
|
||||
let pin_by_occurrence = run(&mut s, "occurrence.properties", json!({"occurrence": pin_occurrence}));
|
||||
for r in [pin_by_component, pin_by_occurrence] {
|
||||
assert_eq!(counted(&r), (1, 1), "{r}");
|
||||
close(&r["total"]["mass_g"], 1.29);
|
||||
}
|
||||
|
||||
// The occurrence's own type counts, not its component's.
|
||||
occurrence_type(&mut s, fixture_occurrence, json!("normal"));
|
||||
assert_eq!(root(&mut s), all);
|
||||
fixture_type(&mut s, "normal");
|
||||
occurrence_type(&mut s, fixture_occurrence, json!("reference"));
|
||||
assert_eq!(root(&mut s), block_only);
|
||||
|
||||
// A pin kept for reference inside the fixture: the fixture's own total is its own body.
|
||||
occurrence_type(&mut s, fixture_occurrence, Value::Null);
|
||||
occurrence_type(&mut s, pin_occurrence, json!("reference"));
|
||||
let r = run(&mut s, "component.properties", json!({"component": fixture}));
|
||||
assert_eq!(counted(&r), (1, 1), "{r}");
|
||||
close(&r["total"]["mass_g"], 1.29);
|
||||
let r = root(&mut s);
|
||||
assert_eq!(counted(&r), (2, 3), "{r}");
|
||||
close(&r["total"]["mass_g"], 7.85 + 1.29);
|
||||
// The same pin placed again outside the fixture counts there.
|
||||
run(&mut s, "occurrence.copy", json!({"component": pin, "parent": 0, "translate": [20, 0, 0]}));
|
||||
let r = root(&mut s);
|
||||
assert_eq!(counted(&r), (3, 4), "{r}");
|
||||
close(&r["total"]["mass_g"], 7.85 + 1.29 + 1.29);
|
||||
close(&r["total"]["bbox"]["max"][0], 70.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reassignment_offsets_preserve_world_properties() {
|
||||
let mut s = Session::default();
|
||||
@@ -221,7 +299,7 @@ fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
|
||||
}
|
||||
for command in ["component.properties", "occurrence.properties"] {
|
||||
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
assert!(error.to_string().contains("more than 100000 placements"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +311,7 @@ fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
}
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
assert!(error.to_string().contains("deeper than 64 levels"), "{error}");
|
||||
}
|
||||
|
||||
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
|
||||
|
||||
@@ -8,21 +8,22 @@ use crate::params::{bad, str_};
|
||||
use crate::{EngineError, Result, Session, crash};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).icon("bug_report").noundo().params(
|
||||
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).any_part().icon("bug_report").noundo().params(
|
||||
"limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \
|
||||
command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \
|
||||
Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay <id>`",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).icon("bug_report").noundo().params(
|
||||
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).any_part().icon("bug_report").noundo().params(
|
||||
"id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \
|
||||
crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \
|
||||
(`solidcraft-cli run <replay_path> --in <design_path>`)",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear)
|
||||
.any_part()
|
||||
.icon("delete")
|
||||
.noundo()
|
||||
.params("id?: one report (default: all). Returns {deleted}"),
|
||||
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params(
|
||||
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).any_part().icon("bug_report").noundo().enabled(crash_test_enabled).params(
|
||||
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
|
||||
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
|
||||
at the next start). Only when the SOLIDCRAFT_CRASH_TEST environment variable is set",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Drawing commands (#3890, K4): make, list, open, close and delete a design's drawings, and read
|
||||
//! and change the open drawing's own settings. A drawing lives in its design's file
|
||||
//! ([`solidcraft_doc::Document::drawings`]); while one is open, the drawing commands change it and
|
||||
//! the model's wait ([`super::Part`]). Each change of a drawing is one undo step of that drawing,
|
||||
//! and evaluates nothing; making and deleting one are steps of the model, whose list of drawings
|
||||
//! they change.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::drawing::{self, Drawing, Source, Units};
|
||||
use solidcraft_doc::ids;
|
||||
use solidcraft_geom::cell::Projection;
|
||||
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("drawing.create", "Create Drawing", create).any_part().icon("new").doc_only().params(
|
||||
"name?: the drawing's name (default: the design's); language?: the drawing's content language, a BCP 47 tag (default en); units?: \"follow_model\" (default) or one length unit such as \"mm\". Adds a drawing of this design, one ISO A3 landscape sheet with the ISO border, and opens it (one undo step of the model) → as drawing.inspect",
|
||||
),
|
||||
CommandSpec::new("drawing.list", "List Drawings", list)
|
||||
.any_part()
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ drawings: [{id, name, sheets (how many), open}], by name, then id"),
|
||||
CommandSpec::new("drawing.open", "Open Drawing", open)
|
||||
.any_part()
|
||||
.icon("open")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("drawing: id (from drawing.list) → as drawing.inspect. The drawing commands then change it; the model's wait until drawing.close"),
|
||||
CommandSpec::new("drawing.close", "Close Drawing", close)
|
||||
.any_part()
|
||||
.icon("close")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ closed: the drawing's id, or null when none was open"),
|
||||
CommandSpec::new("drawing.delete", "Delete Drawing", delete).any_part().icon("delete").doc_only().params(
|
||||
"drawing: id → deleted: its id; closed when it was open (one undo step of the model)",
|
||||
),
|
||||
CommandSpec::new("drawing.inspect", "Inspect Drawing", inspect).in_drawing().icon("inspect").noundo().doc_only().params(
|
||||
"→ {id, name, units, projection, language, properties, sources: [{id}], sheets: [{id, name, size_mm, orientation, dpi, documents, excluded}], revisions: {rows: [{row, revision, description, designer, checker, date, hidden}], the oldest first (the newest is the drawing's revision), next: the revision a new row gets}}. Read-only.",
|
||||
),
|
||||
CommandSpec::new("drawing.set", "Drawing Settings", set)
|
||||
.in_drawing()
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.params("name?, language?: a BCP 47 tag, units?: \"follow_model\" or one length unit such as \"mm\", projection?: first or third (angle), which every projected view follows. One undo step."),
|
||||
CommandSpec::new("drawing.properties", "Drawing Properties", properties).in_drawing().icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the drawing's properties as saved (other tools' keys too); properties: project, part_number, revision, author, organization, description and license, each its text or null. The design properties' rules (document.metadata).",
|
||||
),
|
||||
CommandSpec::new("drawing.properties.set", "Change Drawing Properties", properties_set).in_drawing().icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step), as document.metadata.set takes them → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("drawing.properties.remove", "Remove Drawing Property", properties_remove)
|
||||
.in_drawing()
|
||||
.icon("delete")
|
||||
.doc_only()
|
||||
.params("key, or keys: [keys] → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
/// `units` as given: `follow_model`, or a length unit the drawing's rules check.
|
||||
fn units(cmd: &str, p: &Value) -> Result<Option<Units>> {
|
||||
match p.get("units") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(u)) if u == "follow_model" => Ok(Some(Units::FollowModel)),
|
||||
Some(Value::String(u)) => Ok(Some(Units::Fixed { unit: u.clone() })),
|
||||
Some(_) => Err(bad(cmd, "`units` is \"follow_model\" or a length unit such as \"mm\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `projection` as given: first or third angle.
|
||||
fn projection(cmd: &str, p: &Value) -> Result<Option<Projection>> {
|
||||
match p.get("projection") {
|
||||
None => Ok(None),
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad(cmd, "`projection` is first or third (angle)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional text parameter: absent, or a string.
|
||||
fn text<'a>(cmd: &str, p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(_) => str_(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be text"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The design's drawing `p.drawing` names, by id.
|
||||
fn named(cmd: &str, s: &Session, p: &Value) -> Result<u64> {
|
||||
let id = str_(p, "drawing").and_then(ids::decode).ok_or_else(|| bad(cmd, "`drawing` must be a drawing's id (drawing.list)"))?;
|
||||
s.doc.drawing(id).map(|d| d.id).ok_or_else(|| bad(cmd, format!("the design has no drawing {}", ids::encode(id))))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.create";
|
||||
if s.doc.features.is_empty() {
|
||||
return Err(bad(cmd, "the design is empty: there is nothing to draw yet"));
|
||||
}
|
||||
drawing::check_count(s.doc.drawings.len() + 1).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let name = text(cmd, p, "name")?.map_or_else(|| s.doc.name.clone(), str::to_string);
|
||||
let language = text(cmd, p, "language")?.unwrap_or("en");
|
||||
// Not the id of a drawing undo or redo could bring back either.
|
||||
let steps = s.undo.iter().chain(&s.redo).filter_map(|step| step.part);
|
||||
let taken: BTreeSet<u64> = s.doc.drawings.iter().map(|d| d.id).chain(steps).collect();
|
||||
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let fail = |e: solidcraft_doc::DocError| bad(cmd, e.to_string());
|
||||
let mut d = Drawing::new(&name, language, "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &taken, &mut s.ids).map_err(fail)?;
|
||||
let source = d.allocate_id(&mut s.ids).map_err(fail)?;
|
||||
d.sources.push(Source { id: source });
|
||||
if let Some(units) = units(cmd, p)? {
|
||||
d.units = units;
|
||||
}
|
||||
d.validate().map_err(fail)?;
|
||||
s.open_drawing = Some(d.id);
|
||||
s.doc_mut().put_drawing(Arc::new(d));
|
||||
inspect(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let listed: Vec<Value> = s
|
||||
.doc
|
||||
.drawings
|
||||
.iter()
|
||||
.map(|d| json!({"id": ids::encode(d.id), "name": d.name, "sheets": d.sheets.len(), "open": s.open_drawing == Some(d.id)}))
|
||||
.collect();
|
||||
Ok(json!({"drawings": listed}))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.open_drawing = Some(named("drawing.open", s, p)?);
|
||||
inspect(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"closed": s.open_drawing.take().map(ids::encode)}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = named("drawing.delete", s, p)?;
|
||||
if s.open_drawing == Some(id) {
|
||||
s.open_drawing = None;
|
||||
}
|
||||
s.doc_mut().drawings.retain(|d| d.id != id);
|
||||
Ok(json!({"deleted": ids::encode(id)}))
|
||||
}
|
||||
|
||||
/// The drawing open, as `drawing.inspect` answers, which making and opening one answer too.
|
||||
fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.inspect";
|
||||
let d = s.drawing(cmd)?;
|
||||
let mut sheets = Vec::with_capacity(d.sheets.len());
|
||||
for sheet in &d.sheets {
|
||||
let size = sheet.sheet.size_mm().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
sheets.push(json!({
|
||||
"id": ids::encode(sheet.id),
|
||||
"name": sheet.name,
|
||||
"size_mm": [size.x, size.y],
|
||||
"orientation": sheet.sheet.orientation,
|
||||
"dpi": sheet.dpi,
|
||||
"documents": sheet.documents.map(|c| json!({"source": ids::encode(c.source), "component": c.component})),
|
||||
"excluded": sheet.excluded,
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"id": ids::encode(d.id),
|
||||
"name": d.name,
|
||||
"units": d.units,
|
||||
"projection": d.projection,
|
||||
"language": d.language,
|
||||
"properties": d.properties,
|
||||
"sources": d.sources.iter().map(|source| json!({"id": ids::encode(source.id)})).collect::<Vec<_>>(),
|
||||
"sheets": sheets,
|
||||
"revisions": {"rows": super::drawing_revision::rows(&d.revisions), "next": d.revisions.next_revision(None)},
|
||||
}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.set";
|
||||
s.edit_drawing(cmd, |d| {
|
||||
if let Some(name) = text(cmd, p, "name")? {
|
||||
d.name = name.to_string();
|
||||
}
|
||||
if let Some(language) = text(cmd, p, "language")? {
|
||||
d.language = language.to_string();
|
||||
}
|
||||
if let Some(units) = units(cmd, p)? {
|
||||
d.units = units;
|
||||
}
|
||||
if let Some(projection) = projection(cmd, p)? {
|
||||
d.projection = projection;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
inspect(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(super::metadata::reading(&s.drawing("drawing.properties")?.properties))
|
||||
}
|
||||
|
||||
/// The open drawing with the properties `next`, kept when they changed: one undo step.
|
||||
fn keep_properties(s: &mut Session, cmd: &str, next: solidcraft_doc::Metadata) -> Result<Value> {
|
||||
s.edit_drawing(cmd, |d| {
|
||||
d.properties = Arc::new(next);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(s.drawing(cmd)?.properties))
|
||||
}
|
||||
|
||||
fn properties_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.properties.set";
|
||||
let (next, changed) = super::metadata::setting(cmd, &s.drawing(cmd)?.properties, p)?;
|
||||
let metadata = keep_properties(s, cmd, next)?;
|
||||
Ok(json!({"metadata": metadata, "changed": changed}))
|
||||
}
|
||||
|
||||
fn properties_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.properties.remove";
|
||||
let (next, removed) = super::metadata::removing(cmd, &s.drawing(cmd)?.properties, p)?;
|
||||
let metadata = keep_properties(s, cmd, next)?;
|
||||
Ok(json!({"metadata": metadata, "removed": removed}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! `drawing.balloon.add`, `.edit` and `.inspect` (W26, #3874): a balloon on a saved view of the
|
||||
//! open drawing, pointing at a placed part by an edge of it, and where it stands with the model
|
||||
//! now. The edge is picked as a dimension's is and saved by name
|
||||
//! ([`solidcraft_doc::Document::edge_reference`]); its path is the part the balloon numbers.
|
||||
//! Adding and editing are one undo step of the drawing each, and none of the commands evaluates
|
||||
//! the model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::drawing::balloon::Balloon;
|
||||
use solidcraft_doc::ids;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::drawing_dimension::status_words;
|
||||
use crate::params::bad;
|
||||
use crate::schema::drawing_balloon::{AddParams, BalloonResult, EditParams, EditResult, InspectParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD: &str = "drawing.balloon.add";
|
||||
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
|
||||
const EDIT: &str = "drawing.balloon.edit";
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const INSPECT: &str = "drawing.balloon.inspect";
|
||||
const INSPECT_CONTRACT: TypedContract<InspectParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new(ADD, "Add Balloon", crate::checked!("drawing.balloon.add", add))
|
||||
.icon("new")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&ADD_CONTRACT)
|
||||
.output(output_schema_for::<BalloonResult>)
|
||||
.params(
|
||||
"view: a saved view's id; edge: the edge its arrow points at, a point on it [x, y, z] in the design's frame, {point} or \
|
||||
{body, index}, with path: the occurrences its body is placed through, from the top (the part the balloon numbers; at \
|
||||
least one); at: [x, y], the balloon's centre on paper in mm from the view's centre → what drawing.balloon.inspect \
|
||||
answers for it. Saved by the edge's name, never the point picked. One undo step; no model is evaluated.",
|
||||
),
|
||||
CommandSpec::new(EDIT, "Edit Balloon", crate::checked!("drawing.balloon.edit", edit))
|
||||
.icon("edit")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(output_schema_for::<EditResult>)
|
||||
.params(
|
||||
"balloon: its id; set: {at?: [x, y]} → {balloon, changed: [fields]}. What it points at stays: to number another part, add \
|
||||
a new one. One undo step, none when nothing changes; no model is evaluated.",
|
||||
),
|
||||
CommandSpec::new(INSPECT, "Inspect Balloon", crate::checked!("drawing.balloon.inspect", inspect))
|
||||
.icon("inspect")
|
||||
.in_drawing()
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<BalloonResult>)
|
||||
.params(
|
||||
"balloon: its id → {balloon, view, record: as saved, occurrence: the part it numbers, status: ok, ambiguous, suppressed or \
|
||||
lost, why?, point?: where its arrow ends (its edge's middle, in the design's frame), exact, candidates?: [{edge: {body, \
|
||||
index}}]}. Read-only; no model is evaluated.",
|
||||
),
|
||||
];
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: AddParams = crate::schema::read(ADD, p)?;
|
||||
let record = s.drawing(ADD)?.clone();
|
||||
let view = request.view.0;
|
||||
record.view(view).ok_or_else(|| bad(ADD, format!("the drawing has no view {}", ids::encode(view))))?;
|
||||
let edge = s.doc.edge_reference(&s.model, &request.edge.pick, &request.edge.path).map_err(|why| bad(ADD, why.to_string()))?;
|
||||
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(ADD, e.to_string()))?;
|
||||
let balloon = Balloon { id, view, edge, at: request.at };
|
||||
s.edit_drawing(ADD, |d| {
|
||||
d.add_balloons([Arc::new(balloon)]);
|
||||
Ok(())
|
||||
})?;
|
||||
answer(s, ADD, id)
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: EditParams = crate::schema::read(EDIT, p)?;
|
||||
let id = request.balloon.0;
|
||||
let changed = s.edit_drawing(EDIT, |d| {
|
||||
let at = d.balloon_index(id).ok_or_else(|| bad(EDIT, format!("the drawing has no balloon {}", ids::encode(id))))?;
|
||||
let Some(balloon) = d.balloons.get_mut(at).map(Arc::make_mut) else { return Ok(Vec::new()) };
|
||||
let moved = request.set.at.filter(|place| *place != balloon.at);
|
||||
if let Some(place) = moved {
|
||||
balloon.at = place;
|
||||
}
|
||||
Ok(moved.map(|_| vec!["at".to_string()]).unwrap_or_default())
|
||||
})?;
|
||||
let reply = EditResult { balloon: ids::encode(id), changed, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: InspectParams = crate::schema::read(INSPECT, p)?;
|
||||
answer(s, INSPECT, request.balloon.0)
|
||||
}
|
||||
|
||||
/// Balloon `id` of the open drawing as the model has it now.
|
||||
fn answer(s: &Session, cmd: &str, id: u64) -> Result<Value> {
|
||||
let d = s.drawing(cmd)?;
|
||||
let balloon = d.balloon(id).ok_or_else(|| bad(cmd, format!("the drawing has no balloon {}", ids::encode(id))))?;
|
||||
let resolved = s.doc.resolve_balloons(&s.model, d, &[id]).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let r = resolved.into_iter().next().ok_or_else(|| bad(cmd, format!("balloon {} wasn't read", ids::encode(id))))?;
|
||||
let (status, why, candidates) = status_words(r.status);
|
||||
let reply = BalloonResult {
|
||||
balloon: ids::encode(id),
|
||||
view: ids::encode(balloon.view),
|
||||
record: serde_json::to_value(balloon).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
occurrence: balloon.occurrence().to_vec(),
|
||||
status: status.to_string(),
|
||||
why,
|
||||
point: r.point.map(|p| [p.x, p.y, p.z]),
|
||||
exact: r.exact,
|
||||
candidates,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_balloon_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,268 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace};
|
||||
use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides};
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::schema::tests::conforms;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let schema = output_schema_for::<BalloonResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply) || !matches!(id, ADD | INSPECT), "{id}: {reply}");
|
||||
reply
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
s.execute(cmd, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"))
|
||||
}
|
||||
|
||||
fn saved(s: &Session) -> String {
|
||||
s.drawing("test").unwrap().to_json().unwrap()
|
||||
}
|
||||
|
||||
/// A component, Bracket, holding a box 80 x 40 x 30 mm along X, Y and Z from the origin, called
|
||||
/// Box, placed once where it was made; a 10 mm cube of the design's own, Base, at x = 200; and a
|
||||
/// drawing of them, open, with a front view at 1:1. The session, the view's id and the Bracket's
|
||||
/// component and occurrence.
|
||||
fn placed() -> (Session, u64, [u64; 2]) {
|
||||
let mut s = Session::default();
|
||||
let made = run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
let bracket = [made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap()];
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 40, "height": 30, "body_name": "Box"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new", "body_name": "Base"}));
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let mut d = (**s.drawing("test").unwrap()).clone();
|
||||
let id = d.allocate_id(&mut ids::IdSource::seeded(3874)).unwrap();
|
||||
let (sheet, source) = (d.sheets[0].id, d.sources[0].id);
|
||||
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
let settings = ViewOverrides { scale: Some("1:1".parse().unwrap()), ..ViewOverrides::default() };
|
||||
d.add_views([Arc::new(View { id, sheet, kind, place: ViewPlace::At([200.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
|
||||
d.validate().unwrap();
|
||||
s.doc_mut().put_drawing(Arc::new(d));
|
||||
(s, id, bracket)
|
||||
}
|
||||
|
||||
/// A balloon on the Box's bottom front edge, through the Bracket, at (60, 40) on paper.
|
||||
fn add(s: &mut Session, view: u64, occurrence: u64) -> Value {
|
||||
run(s, ADD, json!({"view": ids::encode(view), "edge": {"point": [40, 0, 0], "path": [occurrence]}, "at": [60, 40]}))
|
||||
}
|
||||
|
||||
fn ends_at(reply: &Value, p: [f64; 3]) {
|
||||
let point: Vec<f64> = reply["point"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
assert!(point.iter().zip(p).all(|(a, b)| (a - b).abs() < 1e-9), "{reply}");
|
||||
}
|
||||
|
||||
/// A balloon numbers the part its edge is placed through, saves the edge by name (any point on it
|
||||
/// picks the same record), and its arrow ends on the edge's middle, whatever point was picked. An
|
||||
/// add is one undo step.
|
||||
#[test]
|
||||
fn a_balloon_points_at_a_placed_part_and_is_saved_by_name() {
|
||||
let (mut s, view, [_, bracket]) = placed();
|
||||
let before = saved(&s);
|
||||
let reply = add(&mut s, view, bracket);
|
||||
assert_eq!((reply["status"].as_str(), reply["exact"].as_bool()), (Some("ok"), Some(true)), "{reply}");
|
||||
assert_eq!(reply["occurrence"], json!([bracket]));
|
||||
assert_eq!(reply["view"], json!(ids::encode(view)));
|
||||
assert_eq!(reply["record"]["edge"]["path"], json!([bracket]));
|
||||
assert_eq!(reply["record"]["at"], json!([60.0, 40.0]));
|
||||
ends_at(&reply, [40.0, 0.0, 0.0]);
|
||||
let elsewhere = run(&mut s, ADD, json!({"view": ids::encode(view), "edge": {"point": [7.5, 0, 0], "path": [bracket]}, "at": [0, 0]}));
|
||||
assert_eq!(elsewhere["record"]["edge"], reply["record"]["edge"], "saved by name, not by the point");
|
||||
ends_at(&elsewhere, [40.0, 0.0, 0.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let one = saved(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(saved(&s), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(saved(&s), one);
|
||||
}
|
||||
|
||||
/// What a balloon can't point at is refused with its reason, and the drawing is left as it was: a
|
||||
/// view the drawing doesn't have, a body of the design itself (no part), an occurrence that isn't
|
||||
/// there, a point on no edge, and a place off paper.
|
||||
#[test]
|
||||
fn a_balloon_is_refused_what_it_cant_point_at() {
|
||||
let (mut s, view, [_, bracket]) = placed();
|
||||
let before = saved(&s);
|
||||
let v = json!(ids::encode(view));
|
||||
let nowhere = ids::encode(99);
|
||||
let cases = [
|
||||
(json!({"view": nowhere, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [0, 0]}), format!("the drawing has no view {nowhere}")),
|
||||
(json!({"view": v, "edge": [205, 0, 0], "at": [0, 0]}), "it points at a placed part: its edge's body is placed through an occurrence".into()),
|
||||
(json!({"view": v, "edge": {"point": [40, 0, 0], "path": [999]}, "at": [0, 0]}), "no edge at [40, 0, 0]".into()),
|
||||
(json!({"view": v, "edge": {"point": [40, 20, 0], "path": [bracket]}, "at": [0, 0]}), "no edge at [40, 20, 0]".into()),
|
||||
(json!({"view": v, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [5000.001, 0]}), "its place is at most 5000 mm on paper".into()),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, ADD, p.clone());
|
||||
assert!(e.contains(&why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(saved(&s), before);
|
||||
}
|
||||
|
||||
/// An edit moves a balloon and names what changed, as one undo step; the same place again changes
|
||||
/// nothing and adds no step. What it points at isn't one of its settings.
|
||||
#[test]
|
||||
fn an_edit_moves_a_balloon_and_the_same_place_again_changes_nothing() {
|
||||
let (mut s, view, [_, bracket]) = placed();
|
||||
let id = add(&mut s, view, bracket)["balloon"].clone();
|
||||
let before = saved(&s);
|
||||
let moved = run(&mut s, EDIT, json!({"balloon": id, "set": {"at": [-12.5, 80]}}));
|
||||
assert_eq!(moved, json!({"balloon": id, "changed": ["at"]}));
|
||||
assert_eq!(run(&mut s, INSPECT, json!({"balloon": id}))["record"]["at"], json!([-12.5, 80.0]));
|
||||
let after = saved(&s);
|
||||
for same in [json!({"at": [-12.5, 80]}), json!({})] {
|
||||
assert_eq!(run(&mut s, EDIT, json!({"balloon": id, "set": same}))["changed"], json!([]));
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(saved(&s), before, "one step for the move, none for the same place");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(saved(&s), after);
|
||||
let e = refused(&mut s, EDIT, json!({"balloon": id, "set": {"edge": [40, 0, 0]}}));
|
||||
assert!(e.contains("edge"), "{e}");
|
||||
let e = refused(&mut s, EDIT, json!({"balloon": ids::encode(99), "set": {"at": [0, 0]}}));
|
||||
assert!(e.contains("the drawing has no balloon"), "{e}");
|
||||
assert_eq!(saved(&s), after);
|
||||
}
|
||||
|
||||
/// Inspect says where a balloon stands with the model now: a notch across its edge splits it, so it
|
||||
/// lists both pieces and has no middle; undone, it is whole again; an occurrence that is gone is
|
||||
/// lost, said so, and never moved to another part. The model is changed with the drawing closed.
|
||||
#[test]
|
||||
fn inspect_says_where_a_balloon_stands_with_the_model() {
|
||||
let (mut s, view, [component, bracket]) = placed();
|
||||
let drawing = json!({"drawing": ids::encode(s.drawing("test").unwrap().id)});
|
||||
let id = add(&mut s, view, bracket)["balloon"].clone();
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component": component}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 5, "height": 5, "corner": [35, -1, -1], "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "drawing.open", drawing.clone());
|
||||
let split = run(&mut s, INSPECT, json!({"balloon": id}));
|
||||
assert_eq!(split["status"], "ambiguous", "{split}");
|
||||
assert_eq!(split["candidates"].as_array().map(Vec::len), Some(2), "{split}");
|
||||
assert!(split.get("point").is_none(), "no one middle: {split}");
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "drawing.open", drawing);
|
||||
let whole = run(&mut s, INSPECT, json!({"balloon": id}));
|
||||
assert_eq!(whole["status"], "ok", "{whole}");
|
||||
ends_at(&whole, [40.0, 0.0, 0.0]);
|
||||
let mut d = (**s.drawing("test").unwrap()).clone();
|
||||
let b = d.balloons.first_mut().map(Arc::make_mut).unwrap();
|
||||
b.edge.path = vec![999];
|
||||
s.doc_mut().put_drawing(Arc::new(d));
|
||||
let gone = run(&mut s, INSPECT, json!({"balloon": id}));
|
||||
assert_eq!((gone["status"].as_str(), gone["occurrence"].clone()), (Some("lost"), json!([999])), "{gone}");
|
||||
assert!(gone["why"].as_str().is_some_and(|why| !why.is_empty()) && gone.get("point").is_none(), "{gone}");
|
||||
let e = refused(&mut s, INSPECT, json!({"balloon": ids::encode(99)}));
|
||||
assert!(e.contains("the drawing has no balloon"), "{e}");
|
||||
}
|
||||
|
||||
/// A balloon put on a piece of a cut edge is found through the piece's numbered name, so not exact.
|
||||
/// Once the cut is suppressed, the two faces the name names meet in one whole edge again, found
|
||||
/// exactly, and the arrow moves to its middle.
|
||||
#[test]
|
||||
fn a_balloon_on_a_piece_is_not_exact_until_the_edge_is_whole_again() {
|
||||
let (mut s, view, [component, bracket]) = placed();
|
||||
let drawing = json!({"drawing": ids::encode(s.drawing("test").unwrap().id)});
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component": component}));
|
||||
let cut = run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 5, "height": 5, "corner": [35, -1, -1], "operation": "cut", "targets": ["Box"]}),
|
||||
)["feature"]
|
||||
.clone();
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "drawing.open", drawing.clone());
|
||||
let piece = run(&mut s, ADD, json!({"view": ids::encode(view), "edge": {"point": [10, 0, 0], "path": [bracket]}, "at": [60, 40]}));
|
||||
assert_eq!((piece["status"].as_str(), piece["exact"].as_bool()), (Some("ok"), Some(false)), "{piece}");
|
||||
ends_at(&piece, [17.5, 0.0, 0.0]);
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component": component}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": cut, "suppressed": true}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "drawing.open", drawing);
|
||||
let whole = run(&mut s, INSPECT, json!({"balloon": piece["balloon"]}));
|
||||
assert_eq!((whole["status"].as_str(), whole["exact"].as_bool()), (Some("ok"), Some(true)), "{whole}");
|
||||
ends_at(&whole, [40.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Every place a value can go in `v`, as JSON pointers: `v` itself, then each member and item.
|
||||
fn places(v: &Value, at: String, out: &mut Vec<String>) {
|
||||
out.push(at.clone());
|
||||
match v {
|
||||
Value::Object(o) => o.iter().for_each(|(k, x)| places(x, format!("{at}/{k}"), out)),
|
||||
Value::Array(l) => l.iter().enumerate().for_each(|(i, x)| places(x, format!("{at}/{i}"), out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hostile value in each place of each command's parameters, at every depth (the edge's pick and
|
||||
/// path, the place, the edit's settings): never a panic, and the drawing keeps its rules.
|
||||
#[test]
|
||||
fn hostile_balloon_params_never_panic() {
|
||||
let (mut s, view, [_, bracket]) = placed();
|
||||
let id = add(&mut s, view, bracket)["balloon"].clone();
|
||||
let v = json!(ids::encode(view));
|
||||
let calls = [
|
||||
(ADD, json!({"view": v, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [1, 2]})),
|
||||
(ADD, json!({"view": v, "edge": {"body": "Box", "index": 0, "path": [bracket]}, "at": [1, 2]})),
|
||||
(EDIT, json!({"balloon": id, "set": {"at": [3, 4]}})),
|
||||
(INSPECT, json!({"balloon": id})),
|
||||
];
|
||||
let mut values = crate::tests::hostile_values();
|
||||
values.extend([json!({"body": "Box", "index": u64::MAX}), Value::Array(vec![json!(1); 1000]), json!([1e308, 0]), json!("x".repeat(600))]);
|
||||
let mut internal = Vec::new();
|
||||
for (cmd, template) in calls {
|
||||
let mut at = Vec::new();
|
||||
places(&template, String::new(), &mut at);
|
||||
for (place, value) in at.iter().flat_map(|place| values.iter().map(move |value| (place, value))) {
|
||||
let mut p = template.clone();
|
||||
if let Some(slot) = p.pointer_mut(place) {
|
||||
*slot = value.clone();
|
||||
}
|
||||
let mut copy = s.scratch();
|
||||
if let Err(crate::EngineError::Internal(_, msg)) = copy.execute(cmd, &p) {
|
||||
internal.push(format!("{cmd} {p}: {msg}"));
|
||||
}
|
||||
copy.doc.drawings.iter().for_each(|d| d.validate().unwrap());
|
||||
}
|
||||
}
|
||||
assert!(internal.is_empty(), "balloon commands panicked:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
/// A view lists its balloons. A copied view's balloons are copies with ids of their own, pointing
|
||||
/// at the same part from the same place, and one undo step takes the copy and them out. A sheet
|
||||
/// with them on it is refused, naming them, until its contents go with it.
|
||||
#[test]
|
||||
fn balloons_go_with_their_view_when_it_is_copied_and_with_its_sheet() {
|
||||
let (mut s, view, [_, bracket]) = placed();
|
||||
let original = add(&mut s, view, bracket);
|
||||
let listed = |s: &mut Session, view: &Value| run(s, "drawing.view.inspect", json!({"view": view}))["balloons"].clone();
|
||||
assert_eq!(listed(&mut s, &json!(ids::encode(view))), json!([original["balloon"]]));
|
||||
let before = saved(&s);
|
||||
let sheet = run(&mut s, "drawing.sheet.add", json!({"name": "Sheet 2"}))["sheet"].clone();
|
||||
let reply = run(&mut s, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": sheet, "position_mm": [100, 100]}));
|
||||
let copy = reply["view"].clone();
|
||||
let copied = listed(&mut s, ©);
|
||||
let [balloon] = copied.as_array().map(Vec::as_slice).unwrap_or_default() else { panic!("one copied balloon: {copied}") };
|
||||
assert_ne!(balloon, &original["balloon"]);
|
||||
let read = run(&mut s, INSPECT, json!({"balloon": balloon}));
|
||||
assert_eq!((read["status"].as_str(), &read["view"]), (Some("ok"), ©), "{read}");
|
||||
assert_eq!((&read["record"]["edge"], &read["record"]["at"]), (&original["record"]["edge"], &original["record"]["at"]));
|
||||
assert_eq!(listed(&mut s, &json!(ids::encode(view))), json!([original["balloon"]]), "the original keeps its own");
|
||||
let e = refused(&mut s, "drawing.sheet.delete", json!({"sheet": sheet}));
|
||||
let named = format!("1 view (`{}`) and 1 balloon (`{}`)", copy.as_str().unwrap(), balloon.as_str().unwrap());
|
||||
assert!(e.contains(&named), "{e}");
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": sheet, "contents": "delete"}));
|
||||
let e = refused(&mut s, INSPECT, json!({"balloon": balloon}));
|
||||
assert!(e.contains("the drawing has no balloon"), "{e}");
|
||||
for _ in 0..3 {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
assert_eq!(saved(&s), before);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! `drawing.dimension.add`, `.edit` and `.inspect` (W19d, #4896): a dimension on a saved view of
|
||||
//! the open drawing, its settings, and what it reads now. What a dimension measures is picked (a
|
||||
//! point, or an edge's index) and saved by name, never by the point picked
|
||||
//! ([`solidcraft_doc::Document::edge_reference`]); its value comes from the model each time it is
|
||||
//! read ([`solidcraft_doc::Document::resolve_dimensions`]). Adding and editing are one undo step
|
||||
//! of the drawing each, and none of the commands evaluates the model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::drawing::Drawing;
|
||||
use solidcraft_doc::drawing::dimension::{Candidate, Dimension, DimensionKind, Silhouette, Status, TextPlace, check_chain_length};
|
||||
use solidcraft_doc::ids;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::drawing_measure::{LinearDirection, Reading};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::drawing_dimension::{
|
||||
AddParams, CandidateResult, CornerParam, DimensionResult, DimensionSet, EdgeParam, EditParams, EditResult, FaceParam, InspectParams, KindParam,
|
||||
MemberResult, SilhouetteParam,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD: &str = "drawing.dimension.add";
|
||||
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
|
||||
const EDIT: &str = "drawing.dimension.edit";
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const INSPECT: &str = "drawing.dimension.inspect";
|
||||
const INSPECT_CONTRACT: TypedContract<InspectParams> = TypedContract::new();
|
||||
|
||||
/// How far a new dimension's line is from what it measures, on paper, until a dimension style
|
||||
/// gives it (D1).
|
||||
const DEFAULT_OFFSET_MM: f64 = 10.0;
|
||||
/// How many decimals a new dimension shows unless told.
|
||||
const DEFAULT_DECIMALS: u8 = 2;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new(ADD, "Add Drawing Dimension", crate::checked!("drawing.dimension.add", add))
|
||||
.icon("dimension")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&ADD_CONTRACT)
|
||||
.output(output_schema_for::<DimensionResult>)
|
||||
.params(
|
||||
"view: a saved view's id; kind: {linear: {a, b: corners, direction?: aligned (default), horizontal, vertical or {along: [x, y]}, \
|
||||
reading?: projected (default) or true}}, {length: {edge, direction?, reading?}}, {angle: {a, b: straight edges, reading?, \
|
||||
other_side?}}, {radial: {edge: a circle or an arc the view sees face on, diameter?: by default for a whole circle or \
|
||||
half a turn or more}}, {cylinder: {face: a face of a whole cylinder the view doesn't see end-on, read as Ø between its \
|
||||
silhouettes}}, {silhouettes: {a, b: {face, side: near or far, the silhouette nearer the other cylinder or farther}, \
|
||||
direction?: as a linear one's, aligned being square to them}: two of one cylinder make its cylinder dimension} or \
|
||||
{chain: {points: [3 to 64 corners, in order along one line], direction?: as a linear one's, aligned being first to \
|
||||
last}}; an edge is a point on it [x, y, z] in the design's frame, {point} or {body, index}, and a \
|
||||
corner a point, {point}, {edges: [2 to 16 edges]} or {centre: an edge that is a circle or an arc}, and a face a point on \
|
||||
it, {point} or {body, index}, each with path?: the \
|
||||
occurrences its body is placed through, from the top; offset_mm?: the dimension line's distance on paper (default 10); \
|
||||
text?: centred (default) or {at: [x, y]} from the view's centre; decimals?: 0..6 (default 2); override_text? (not a \
|
||||
chain's) → what drawing.dimension.inspect answers for it. Saved by the edges' names, never the points picked; one the \
|
||||
view can't measure is refused. One undo step; no model is evaluated.",
|
||||
),
|
||||
CommandSpec::new(EDIT, "Edit Drawing Dimension", crate::checked!("drawing.dimension.edit", edit))
|
||||
.icon("dimension")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(output_schema_for::<EditResult>)
|
||||
.params(
|
||||
"dimension: its id; set: {offset_mm?, text?: centred or {at: [x, y]}, decimals?: 0..6, override_text?: text, or null to show \
|
||||
the number again; direction?, reading?: a linear or length dimension's, reading? and other_side?: an angle's, diameter?: a \
|
||||
radial dimension's, direction?: a chain's or a silhouettes one's} → \
|
||||
{dimension, changed: [fields]}. What it measures stays: to measure something else, add a new one. One undo step, none \
|
||||
when nothing changes; no model is evaluated.",
|
||||
),
|
||||
CommandSpec::new(INSPECT, "Inspect Drawing Dimension", crate::checked!("drawing.dimension.inspect", inspect))
|
||||
.icon("inspect")
|
||||
.in_drawing()
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<DimensionResult>)
|
||||
.params(
|
||||
"dimension: its id → {dimension, view, record: as saved, status: ok, overridden, ambiguous, suppressed, lost or unmeasurable, \
|
||||
why?, value? (model mm, or degrees for an angle; a chain's overall), foreshortened?, text? (none for a chain), \
|
||||
members?: a chain's [{value, text}] from its first point, last_good? (the value it last read, kept when the design \
|
||||
is saved, when it has none now), exact, candidates?: [{edge: {body, index}}, {corner: [x, y, z]} or {face: {body, index}}]}. \
|
||||
Read-only; no model is evaluated.",
|
||||
),
|
||||
];
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: AddParams = crate::schema::read(ADD, p)?;
|
||||
let record = s.drawing(ADD)?.clone();
|
||||
let view = request.view.0;
|
||||
record.view(view).ok_or_else(|| bad(ADD, format!("the drawing has no view {}", ids::encode(view))))?;
|
||||
let (doc, model) = (&s.doc, &s.model);
|
||||
let edge = |e: &EdgeParam| doc.edge_reference(model, &e.pick, &e.path).map_err(|why| bad(ADD, why.to_string()));
|
||||
let corner = |c: &CornerParam| doc.corner_reference(model, &c.pick, &c.path).map_err(|why| bad(ADD, why.to_string()));
|
||||
let face = |f: &FaceParam| doc.face_reference(model, &f.pick, &f.path).map_err(|why| bad(ADD, why.to_string()));
|
||||
let choose_diameter = matches!(request.kind, KindParam::Radial { diameter: None, .. });
|
||||
let kind = match request.kind {
|
||||
KindParam::Linear { a, b, direction, reading } => DimensionKind::Linear {
|
||||
a: corner(&a)?,
|
||||
b: corner(&b)?,
|
||||
direction: direction.unwrap_or(LinearDirection::Aligned),
|
||||
reading: reading.unwrap_or(Reading::Projected),
|
||||
},
|
||||
KindParam::Length { edge: e, direction, reading } => DimensionKind::Length {
|
||||
edge: edge(&e)?,
|
||||
direction: direction.unwrap_or(LinearDirection::Aligned),
|
||||
reading: reading.unwrap_or(Reading::Projected),
|
||||
},
|
||||
KindParam::Angle { a, b, reading, other_side } => {
|
||||
DimensionKind::Angle { a: edge(&a)?, b: edge(&b)?, reading: reading.unwrap_or(Reading::Projected), other_side }
|
||||
}
|
||||
KindParam::Radial { edge: e, diameter } => DimensionKind::Radial { edge: edge(&e)?, diameter: diameter.unwrap_or(false) },
|
||||
KindParam::Cylinder { face: f } => DimensionKind::Cylinder { face: face(&f)? },
|
||||
KindParam::Silhouettes { a, b, direction } => {
|
||||
let silhouette = |s: &SilhouetteParam| face(&s.face).map(|face| Silhouette { face, side: s.side });
|
||||
doc.silhouettes_kind(model, silhouette(&a)?, silhouette(&b)?, direction).map_err(|why| bad(ADD, why.to_string()))?
|
||||
}
|
||||
KindParam::Chain { points, direction } => {
|
||||
// Counted before any point is looked up.
|
||||
check_chain_length(points.len()).map_err(|why| bad(ADD, why.to_string()))?;
|
||||
let points = points.iter().map(corner).collect::<Result<_>>()?;
|
||||
DimensionKind::Chain { points, direction: direction.unwrap_or(LinearDirection::Aligned) }
|
||||
}
|
||||
};
|
||||
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(ADD, e.to_string()))?;
|
||||
let mut dimension = Dimension {
|
||||
id,
|
||||
view,
|
||||
kind,
|
||||
offset_mm: request.offset_mm.unwrap_or(DEFAULT_OFFSET_MM),
|
||||
text: request.text.unwrap_or(TextPlace::Centred),
|
||||
decimals: request.decimals.unwrap_or(DEFAULT_DECIMALS),
|
||||
override_text: request.override_text,
|
||||
last_good: None,
|
||||
};
|
||||
// Read before it is added: a dimension its view can't measure would show nothing.
|
||||
let mut staged = Drawing::clone(&record);
|
||||
staged.add_dimensions([Arc::new(dimension.clone())]);
|
||||
let read = s.doc.resolve_dimensions(&s.model, &staged, &[id]).map_err(|e| bad(ADD, e.to_string()))?;
|
||||
if let Some(Status::Unmeasurable(why)) = read.first().map(|r| &r.status) {
|
||||
return Err(bad(ADD, why.clone()));
|
||||
}
|
||||
if choose_diameter && let DimensionKind::Radial { diameter, .. } = &mut dimension.kind {
|
||||
*diameter = read.first().is_some_and(|r| diameter_by_default(&r.points));
|
||||
}
|
||||
s.edit_drawing(ADD, |d| {
|
||||
d.add_dimensions([Arc::new(dimension)]);
|
||||
Ok(())
|
||||
})?;
|
||||
answer(s, ADD, id)
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: EditParams = crate::schema::read(EDIT, p)?;
|
||||
let id = request.dimension.0;
|
||||
let changed = s.edit_drawing(EDIT, |d| change(d, id, &request.set))?;
|
||||
let reply = EditResult { dimension: ids::encode(id), changed, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// A new radial dimension's default from its arc as read (its centre, start and middle): a diameter
|
||||
/// for a whole circle or an arc of half a turn or more, as a hole's rim is (the kernel makes it of
|
||||
/// two halves), else a radius.
|
||||
fn diameter_by_default(points: &[Vec3]) -> bool {
|
||||
let [centre, start, middle, ..] = points else { return false };
|
||||
let (from, to) = (*start - *centre, *middle - *centre);
|
||||
// The middle is half the arc's turn from its start: a quarter turn or more away.
|
||||
from.dot(to) <= from.dot(from) * 1e-9
|
||||
}
|
||||
|
||||
/// `set` made on dimension `id` of `d`: the fields it changes, by name.
|
||||
fn change(d: &mut Drawing, id: u64, set: &DimensionSet) -> Result<Vec<String>> {
|
||||
let at = d.dimension_index(id).ok_or_else(|| bad(EDIT, format!("the drawing has no dimension {}", ids::encode(id))))?;
|
||||
let Some(dimension) = d.dimensions.get_mut(at).map(Arc::make_mut) else { return Ok(Vec::new()) };
|
||||
let before = dimension.clone();
|
||||
if let Some(offset) = set.offset_mm {
|
||||
dimension.offset_mm = offset;
|
||||
}
|
||||
if let Some(text) = set.text {
|
||||
dimension.text = text;
|
||||
}
|
||||
if let Some(decimals) = set.decimals {
|
||||
dimension.decimals = decimals;
|
||||
}
|
||||
if let Some(text) = &set.override_text {
|
||||
dimension.override_text = text.clone();
|
||||
}
|
||||
if set.diameter.is_some() && !matches!(dimension.kind, DimensionKind::Radial { .. }) {
|
||||
return Err(bad(EDIT, "`diameter` is a radial dimension's"));
|
||||
}
|
||||
match &mut dimension.kind {
|
||||
DimensionKind::Linear { direction, reading, .. } | DimensionKind::Length { direction, reading, .. } => {
|
||||
if set.other_side.is_some() {
|
||||
return Err(bad(EDIT, "`other_side` is an angle's"));
|
||||
}
|
||||
*direction = set.direction.unwrap_or(*direction);
|
||||
*reading = set.reading.unwrap_or(*reading);
|
||||
}
|
||||
DimensionKind::Angle { reading, other_side, .. } => {
|
||||
if set.direction.is_some() {
|
||||
return Err(bad(EDIT, "an angle has no `direction`: it measures between its edges' lines"));
|
||||
}
|
||||
*reading = set.reading.unwrap_or(*reading);
|
||||
*other_side = set.other_side.unwrap_or(*other_side);
|
||||
}
|
||||
DimensionKind::Radial { diameter, .. } => {
|
||||
if set.direction.is_some() || set.reading.is_some() || set.other_side.is_some() {
|
||||
return Err(bad(EDIT, "a radial dimension has only `diameter`: it reads its circle as the view sees it face on"));
|
||||
}
|
||||
*diameter = set.diameter.unwrap_or(*diameter);
|
||||
}
|
||||
DimensionKind::Chain { direction, .. } => {
|
||||
if set.reading.is_some() || set.other_side.is_some() {
|
||||
return Err(bad(EDIT, "a chain has only `direction`: it reads as the view shows it"));
|
||||
}
|
||||
*direction = set.direction.unwrap_or(*direction);
|
||||
}
|
||||
DimensionKind::Silhouettes { direction, .. } => {
|
||||
if set.reading.is_some() || set.other_side.is_some() {
|
||||
return Err(bad(EDIT, "a dimension between silhouettes has only `direction`: it reads as the view shows them"));
|
||||
}
|
||||
*direction = set.direction.unwrap_or(*direction);
|
||||
}
|
||||
DimensionKind::Cylinder { .. } => {
|
||||
if set.direction.is_some() || set.reading.is_some() || set.other_side.is_some() {
|
||||
return Err(bad(EDIT, "a cylinder's diameter has no direction, reading or side: it reads between its silhouettes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed_fields(&before, dimension))
|
||||
}
|
||||
|
||||
/// The direction, reading, side and diameter of a dimension's kind, where it has them.
|
||||
fn kind_settings(kind: &DimensionKind) -> (Option<LinearDirection>, Option<Reading>, bool, bool) {
|
||||
match kind {
|
||||
DimensionKind::Linear { direction, reading, .. } | DimensionKind::Length { direction, reading, .. } => {
|
||||
(Some(*direction), Some(*reading), false, false)
|
||||
}
|
||||
DimensionKind::Angle { reading, other_side, .. } => (None, Some(*reading), *other_side, false),
|
||||
DimensionKind::Radial { diameter, .. } => (None, None, false, *diameter),
|
||||
DimensionKind::Chain { direction, .. } | DimensionKind::Silhouettes { direction, .. } => (Some(*direction), None, false, false),
|
||||
DimensionKind::Cylinder { .. } => (None, None, false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the fields that differ between `a` and `b`, two states of one dimension.
|
||||
fn changed_fields(a: &Dimension, b: &Dimension) -> Vec<String> {
|
||||
let (x, y) = (kind_settings(&a.kind), kind_settings(&b.kind));
|
||||
let fields = [
|
||||
("offset_mm", a.offset_mm != b.offset_mm),
|
||||
("text", a.text != b.text),
|
||||
("decimals", a.decimals != b.decimals),
|
||||
("override_text", a.override_text != b.override_text),
|
||||
("direction", x.0 != y.0),
|
||||
("reading", x.1 != y.1),
|
||||
("other_side", x.2 != y.2),
|
||||
("diameter", x.3 != y.3),
|
||||
];
|
||||
fields.into_iter().filter(|(_, changed)| *changed).map(|(name, _)| name.to_string()).collect()
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: InspectParams = crate::schema::read(INSPECT, p)?;
|
||||
answer(s, INSPECT, request.dimension.0)
|
||||
}
|
||||
|
||||
/// Dimension `id` of the open drawing as the model has it now. A value read is kept as its last
|
||||
/// good one ([`Session::dimension_values`]); with none this session, the file's is its last good one.
|
||||
fn answer(s: &mut Session, cmd: &str, id: u64) -> Result<Value> {
|
||||
let d = s.drawing(cmd)?.clone();
|
||||
let dimension = d.dimension(id).ok_or_else(|| bad(cmd, format!("the drawing has no dimension {}", ids::encode(id))))?;
|
||||
let resolved = s.doc.resolve_dimensions(&s.model, &d, &[id]).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let r = resolved.into_iter().next().ok_or_else(|| bad(cmd, format!("dimension {} wasn't read", ids::encode(id))))?;
|
||||
let last_good = match r.measured {
|
||||
Some(m) => {
|
||||
s.dimension_values.insert((d.id, id), m.value);
|
||||
None
|
||||
}
|
||||
None => s.dimension_values.get(&(d.id, id)).copied().or(dimension.last_good),
|
||||
};
|
||||
let (status, why, candidates) = status_words(r.status);
|
||||
let angle = matches!(dimension.kind, DimensionKind::Angle { .. });
|
||||
let shown = |value: f64| if angle { value.to_degrees() } else { value };
|
||||
let reply = DimensionResult {
|
||||
dimension: ids::encode(id),
|
||||
view: ids::encode(dimension.view),
|
||||
record: serde_json::to_value(dimension).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
status: status.to_string(),
|
||||
why,
|
||||
value: r.measured.map(|m| shown(m.value)),
|
||||
foreshortened: r.measured.map(|m| m.foreshortened),
|
||||
text: r.text,
|
||||
members: r.members.into_iter().map(|m| MemberResult { value: m.measured.value, text: m.text }).collect(),
|
||||
last_good: last_good.map(shown),
|
||||
exact: r.exact,
|
||||
candidates,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// How a command's answer says where a reference stands with the model: the status's word, why
|
||||
/// it is lost or can't be measured, and the candidates when several things match. Balloons say it
|
||||
/// the same way.
|
||||
pub(super) fn status_words(status: Status) -> (&'static str, Option<String>, Vec<CandidateResult>) {
|
||||
match status {
|
||||
Status::Ok => ("ok", None, Vec::new()),
|
||||
Status::Overridden => ("overridden", None, Vec::new()),
|
||||
Status::Ambiguous(found) => ("ambiguous", None, found.into_iter().map(candidate).collect()),
|
||||
Status::Suppressed => ("suppressed", None, Vec::new()),
|
||||
Status::Lost(why) => ("lost", Some(why), Vec::new()),
|
||||
Status::Unmeasurable(why) => ("unmeasurable", Some(why), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(c: Candidate) -> CandidateResult {
|
||||
match c {
|
||||
Candidate::Edge { body, index } => CandidateResult::Edge { body, index },
|
||||
Candidate::Corner(p) => CandidateResult::Corner([p.x, p.y, p.z]),
|
||||
Candidate::Face { body, index } => CandidateResult::Face { body, index },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_dimension_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,799 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace};
|
||||
use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::schema::tests::conforms;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let schema = output_schema_for::<DimensionResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply) || !id.starts_with("drawing.dimension"), "{id}: {reply}");
|
||||
reply
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, p: Value) -> String {
|
||||
s.execute(ADD, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"))
|
||||
}
|
||||
|
||||
/// A box 80 x 40 x 30 mm along X, Y and Z from the origin, called Box, and a drawing of it, open,
|
||||
/// with its front view at 1:1: the session and the view's id.
|
||||
fn front_view() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 40, "height": 30, "body_name": "Box"}));
|
||||
drawn_from_the_front(s)
|
||||
}
|
||||
|
||||
/// A drawing of `s`, open, with its front view at 1:1: the session and the view's id.
|
||||
fn drawn_from_the_front(mut s: Session) -> (Session, u64) {
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let mut d = (**s.drawing("test").unwrap()).clone();
|
||||
let id = d.allocate_id(&mut ids::IdSource::seeded(4896)).unwrap();
|
||||
let (sheet, source) = (d.sheets[0].id, d.sources[0].id);
|
||||
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
let settings = ViewOverrides { scale: Some("1:1".parse().unwrap()), ..ViewOverrides::default() };
|
||||
d.add_views([Arc::new(View { id, sheet, kind, place: ViewPlace::At([200.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
|
||||
d.validate().unwrap();
|
||||
s.doc_mut().put_drawing(Arc::new(d));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The index of the box's edge from `a` to `b`, as `{body, index}` names it.
|
||||
fn edge_index(s: &Session, a: [f64; 3], b: [f64; 3]) -> usize {
|
||||
let (a, b) = (Vec3::from(a), Vec3::from(b));
|
||||
let state = s.model.state();
|
||||
let mesh = state.body("Box").unwrap().mesh();
|
||||
let ends = |line: &Vec<Vec3>| (line[0], line[line.len() - 1]);
|
||||
let joins = |(p, q): (Vec3, Vec3)| (p.dist(a) < 1e-9 && q.dist(b) < 1e-9) || (p.dist(b) < 1e-9 && q.dist(a) < 1e-9);
|
||||
mesh.edges.iter().position(|line| joins(ends(line))).unwrap()
|
||||
}
|
||||
|
||||
/// The index of the box's edge whose every point is `radius` from `centre`, as `{body, index}`
|
||||
/// names it: one of a circle's or an arc's.
|
||||
fn round_index(s: &Session, centre: [f64; 3], radius: f64) -> usize {
|
||||
let centre = Vec3::from(centre);
|
||||
let state = s.model.state();
|
||||
let mesh = state.body("Box").unwrap().mesh();
|
||||
mesh.edges.iter().position(|line| line.iter().all(|p| (p.dist(centre) - radius).abs() < 1e-6)).unwrap()
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, dimension: &Value, set: Value) -> Value {
|
||||
s.execute(EDIT, &json!({"dimension": dimension, "set": set})).unwrap_or_else(|e| panic!("{EDIT}: {e}"))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, view: u64, kind: Value) -> Value {
|
||||
run(s, ADD, json!({"view": ids::encode(view), "kind": kind}))
|
||||
}
|
||||
|
||||
fn reads(reply: &Value, value: f64, text: &str) {
|
||||
assert_eq!((reply["status"].as_str(), reply["text"].as_str()), (Some("ok"), Some(text)), "{reply}");
|
||||
assert!((reply["value"].as_f64().unwrap() - value).abs() < 1e-9, "{reply}");
|
||||
}
|
||||
|
||||
/// A dimension added by points or by edges is saved by the edges' names, and reads as the front
|
||||
/// view shows the box: its bottom front corners 80 apart, its top front edge 80 long, and that
|
||||
/// edge square to the left front edge.
|
||||
#[test]
|
||||
fn a_dimension_is_added_by_points_or_edges_and_saved_by_name() {
|
||||
let (mut s, view) = front_view();
|
||||
let steps = s.undo.len();
|
||||
let by_points = add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": [80, 0, 0], "direction": "horizontal"}}));
|
||||
reads(&by_points, 80.0, "80.00");
|
||||
assert_eq!((by_points["exact"].clone(), by_points["foreshortened"].clone()), (json!(true), json!(false)));
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step");
|
||||
let record = &by_points["record"];
|
||||
assert_eq!((&record["offset_mm"], &record["text"], &record["decimals"]), (&json!(10.0), &json!("centred"), &json!(2)), "the defaults");
|
||||
let corner = &record["kind"]["linear"]["a"];
|
||||
assert_eq!((corner["body"].as_str(), corner["edges"].as_array().map(Vec::len)), (Some("Box"), Some(3)), "a box corner: three edges");
|
||||
assert!(corner.get("point").is_none() && corner.to_string().contains('|'), "names, not the point: {corner}");
|
||||
// The same corners by two of their edges each.
|
||||
let bottom = edge_index(&s, [0.0, 0.0, 0.0], [80.0, 0.0, 0.0]);
|
||||
let (left, right) = (edge_index(&s, [0.0, 0.0, 0.0], [0.0, 0.0, 30.0]), edge_index(&s, [80.0, 0.0, 0.0], [80.0, 0.0, 30.0]));
|
||||
let edge = |index: usize| json!({"body": "Box", "index": index});
|
||||
let by_edges = add(&mut s, view, json!({"linear": {"a": {"edges": [edge(bottom), edge(left)]}, "b": {"edges": [edge(right), edge(bottom)]}}}));
|
||||
reads(&by_edges, 80.0, "80.00");
|
||||
// The top front edge's length, by a point on it and by its index, saves the same name.
|
||||
let top = edge_index(&s, [0.0, 0.0, 30.0], [80.0, 0.0, 30.0]);
|
||||
let at = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
|
||||
let indexed = add(&mut s, view, json!({"length": {"edge": edge(top)}}));
|
||||
reads(&at, 80.0, "80.00");
|
||||
assert_eq!(at["record"]["kind"], indexed["record"]["kind"]);
|
||||
let square = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}, "decimals": 1}));
|
||||
reads(&square, 90.0, "90.0°");
|
||||
// Inspect reads each one back as add answered it.
|
||||
let id = by_points["dimension"].clone();
|
||||
assert_eq!(run(&mut s, INSPECT, json!({"dimension": id})), by_points);
|
||||
// The view lists them, in id order.
|
||||
let mut added: Vec<u64> =
|
||||
[&by_points, &by_edges, &at, &indexed, &square].iter().filter_map(|r| r["dimension"].as_str().and_then(ids::decode)).collect();
|
||||
added.sort_unstable();
|
||||
let listed = run(&mut s, "drawing.view.inspect", json!({"view": ids::encode(view)}))["dimensions"].clone();
|
||||
assert_eq!(listed, json!(added.into_iter().map(ids::encode).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
/// An undone add takes the dimension out, and a redo puts it back.
|
||||
#[test]
|
||||
fn an_added_dimension_is_one_undo_step() {
|
||||
let (mut s, view) = front_view();
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let p = json!({"view": ids::encode(view), "kind": {"length": {"edge": [40, 0, 30]}}, "offset_mm": -12.5, "text": {"at": [0, 40]}});
|
||||
let added = run(&mut s, ADD, p);
|
||||
let after = s.drawing("test").unwrap().to_json().unwrap();
|
||||
assert_eq!((added["record"]["offset_mm"].clone(), added["record"]["text"].clone()), (json!(-12.5), json!({"at": [0.0, 40.0]})));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), after);
|
||||
}
|
||||
|
||||
/// A copied view's dimensions are copies with ids of their own, measuring the same, and one undo
|
||||
/// step takes the copy and them out.
|
||||
#[test]
|
||||
fn a_copied_view_takes_copies_of_its_dimensions() {
|
||||
let (mut s, view) = front_view();
|
||||
let original = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"length": {"edge": [40, 0, 30]}}, "text": {"at": [0, 25]}}));
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let reply = run(&mut s, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": "Sheet 1", "position_mm": [100, 100]}));
|
||||
let copy = reply["view"].clone();
|
||||
let listed = run(&mut s, "drawing.view.inspect", json!({"view": copy}))["dimensions"].clone();
|
||||
let [copied] = listed.as_array().map(Vec::as_slice).unwrap_or_default() else { panic!("one copied dimension: {listed}") };
|
||||
assert_ne!(copied, &original["dimension"]);
|
||||
let read = run(&mut s, INSPECT, json!({"dimension": copied}));
|
||||
reads(&read, 80.0, "80.00");
|
||||
assert_eq!(read["view"], copy);
|
||||
let (mine, theirs) = (&read["record"], &original["record"]);
|
||||
assert_eq!((&mine["kind"], &mine["text"], &mine["offset_mm"]), (&theirs["kind"], &theirs["text"], &theirs["offset_mm"]));
|
||||
// The original keeps its own.
|
||||
assert_eq!(run(&mut s, "drawing.view.inspect", json!({"view": ids::encode(view)}))["dimensions"], json!([original["dimension"]]));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// An edit changes only what it is given and names only what changed; the same values again
|
||||
/// change nothing and add no undo step, and `null` shows the number again.
|
||||
#[test]
|
||||
fn an_edit_names_what_it_changed_and_the_same_again_changes_nothing() {
|
||||
let (mut s, view) = front_view();
|
||||
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
|
||||
let id = added["dimension"].clone();
|
||||
let steps = s.undo.len();
|
||||
let set = json!({"offset_mm": -15, "text": {"at": [5, 30]}, "decimals": 1, "override_text": "SEE NOTE", "reading": "true"});
|
||||
assert_eq!(edit(&mut s, &id, set.clone())["changed"], json!(["offset_mm", "text", "decimals", "override_text", "reading"]));
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step");
|
||||
let read = run(&mut s, INSPECT, json!({"dimension": id}));
|
||||
assert_eq!((read["status"].as_str(), read["text"].as_str()), (Some("overridden"), Some("SEE NOTE")), "{read}");
|
||||
assert_eq!(read["record"]["kind"]["length"]["edge"], added["record"]["kind"]["length"]["edge"], "what it measures stays");
|
||||
let bytes = s.drawing("test").unwrap().to_json().unwrap();
|
||||
assert_eq!(edit(&mut s, &id, set)["changed"], json!([]));
|
||||
assert_eq!((s.undo.len(), s.drawing("test").unwrap().to_json().unwrap()), (steps + 1, bytes), "nothing changed: no undo step");
|
||||
assert_eq!(edit(&mut s, &id, json!({"override_text": null}))["changed"], json!(["override_text"]));
|
||||
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 80.0, "80.0");
|
||||
}
|
||||
|
||||
/// A radial dimension on a hole through the box is a diameter by default (its rim is two halves,
|
||||
/// each half a turn), and on a fillet's quarter arc a radius; as asked, the hole reads its radius,
|
||||
/// and an edit turns one into the other and back. It takes no direction, reading or side.
|
||||
#[test]
|
||||
fn a_radial_dimension_reads_a_hole_and_an_edit_makes_it_a_diameter() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let hole = json!({"body": "Box", "index": round_index(&s, [40.0, 0.0, 15.0], 6.0)});
|
||||
let across = add(&mut s, view, json!({"radial": {"edge": hole}}));
|
||||
reads(&across, 12.0, "Ø12.00");
|
||||
assert_eq!(across["record"]["kind"]["radial"]["diameter"], json!(true), "{across}");
|
||||
let fillet = json!({"body": "Box", "index": round_index(&s, [75.0, 0.0, 25.0], 5.0)});
|
||||
let arc = add(&mut s, view, json!({"radial": {"edge": fillet}}));
|
||||
reads(&arc, 5.0, "R5.00");
|
||||
assert_eq!(arc["record"]["kind"]["radial"].get("diameter"), None, "{arc}");
|
||||
reads(&add(&mut s, view, json!({"radial": {"edge": fillet, "diameter": true}})), 10.0, "Ø10.00");
|
||||
let radius = add(&mut s, view, json!({"radial": {"edge": hole, "diameter": false}}));
|
||||
reads(&radius, 6.0, "R6.00");
|
||||
let id = radius["dimension"].clone();
|
||||
assert_eq!(edit(&mut s, &id, json!({"diameter": true}))["changed"], json!(["diameter"]));
|
||||
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 12.0, "Ø12.00");
|
||||
assert_eq!(edit(&mut s, &id, json!({"diameter": false}))["changed"], json!(["diameter"]));
|
||||
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 6.0, "R6.00");
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
for set in [json!({"direction": "horizontal"}), json!({"reading": "true"}), json!({"other_side": true})] {
|
||||
let p = json!({"dimension": id, "set": set});
|
||||
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
|
||||
assert!(e.contains("a radial dimension has only `diameter`"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// A point on a curved edge names it wherever it falls between the edge's chords, a little way off it
|
||||
/// names nothing, and a point on a straight edge is that edge however near a curved one is.
|
||||
#[test]
|
||||
fn a_point_on_a_curved_edge_finds_it_between_its_chords() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let rim = |radius: f64, degrees: f64| {
|
||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
||||
json!([40.0 + radius * cos, 0.0, 15.0 + radius * sin])
|
||||
};
|
||||
// The hole's rim is two half circles, and each point names one of them. Whole steps never land on
|
||||
// where they meet, which names both and is refused.
|
||||
let mut named = BTreeSet::new();
|
||||
for step in 0..55 {
|
||||
let at = add(&mut s, view, json!({"radial": {"edge": rim(6.0, 3.3 + 6.5 * f64::from(step))}}));
|
||||
reads(&at, 12.0, "Ø12.00");
|
||||
named.insert(at["record"]["kind"]["radial"]["edge"]["name"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(named.len(), 2, "{named:?}");
|
||||
assert!(refused(&mut s, json!({"view": ids::encode(view), "kind": {"radial": {"edge": rim(7.0, 20.0)}}})).contains("no edge"));
|
||||
let (sin, cos) = 45f64.to_radians().sin_cos();
|
||||
reads(&add(&mut s, view, json!({"radial": {"edge": [75.0 + 5.0 * cos, 0.0, 25.0 + 5.0 * sin]}})), 5.0, "R5.00");
|
||||
reads(&add(&mut s, view, json!({"length": {"edge": [74.99, 0, 30]}})), 75.0, "75.00");
|
||||
}
|
||||
|
||||
/// A linear dimension measures from the centre of a hole or an arc, each named by its edge: the
|
||||
/// hole through the box about (40, 15) on the front, and the fillet's quarter arc about (75, 25).
|
||||
/// A straight edge has no centre, and a centre's edge has no path of its own.
|
||||
#[test]
|
||||
fn a_linear_dimension_measures_from_a_holes_centre() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let centre = |at: [f64; 3], radius: f64| json!({"centre": {"body": "Box", "index": round_index(&s, at, radius)}});
|
||||
let (hole, arc) = (centre([40.0, 0.0, 15.0], 6.0), centre([75.0, 0.0, 25.0], 5.0));
|
||||
let along = add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": hole, "direction": "horizontal"}}));
|
||||
reads(&along, 40.0, "40.00");
|
||||
let saved = &along["record"]["kind"]["linear"]["b"];
|
||||
assert_eq!((saved["centre"].clone(), saved["edges"].as_array().map(Vec::len)), (json!(true), Some(1)), "{saved}");
|
||||
reads(&add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": hole, "direction": "vertical"}})), 15.0, "15.00");
|
||||
reads(&add(&mut s, view, json!({"linear": {"a": hole, "b": arc, "direction": "horizontal"}})), 35.0, "35.00");
|
||||
reads(&add(&mut s, view, json!({"linear": {"a": hole, "b": arc, "direction": "vertical"}})), 10.0, "10.00");
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let v = ids::encode(view);
|
||||
let linear = |a: Value| json!({"view": v, "kind": {"linear": {"a": a, "b": [80, 0, 0]}}});
|
||||
let cases = [
|
||||
(linear(json!({"centre": [40, 0, 30]})), "isn't a circle or an arc: a centre is a circle's or an arc's"),
|
||||
(linear(json!({"centre": {"point": [40, 0, 30], "path": [1]}})), "{centre: a circle or an arc}"),
|
||||
(linear(json!({"centre": [40, 0, 30], "edges": [[40, 0, 30], [0, 0, 15]]})), "{centre: a circle or an arc}"),
|
||||
(linear(json!({"centre": [40, 20, 15]})), "no edge at [40, 20, 15]"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// A chain from the box's bottom front corner through the centres of holes at 10, 30 and 60 along
|
||||
/// it reads 10, 20 and 30, adding up to its overall 60 within 1e-9, and shows its members' numbers,
|
||||
/// not one of its own. Its direction is its one setting. It keeps its rules: its points in order
|
||||
/// along its line, 3 to 64 of them (counted before any is looked up), and no text instead.
|
||||
#[test]
|
||||
fn a_chain_reads_holes_at_10_30_and_60_as_10_20_and_30() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
for x in [10, 30, 60] {
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [x, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
}
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let hole = |x: f64| json!({"centre": {"body": "Box", "index": round_index(&s, [x, 0.0, 15.0], 3.0)}});
|
||||
let (h10, h30, h60) = (hole(10.0), hole(30.0), hole(60.0));
|
||||
let steps = s.undo.len();
|
||||
let chain = add(&mut s, view, json!({"chain": {"points": [[0, 0, 0], h10, h30, h60], "direction": "horizontal"}}));
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!((chain["status"].as_str(), chain.get("text")), (Some("ok"), None), "{chain}");
|
||||
let members = |reply: &Value| {
|
||||
let listed = reply["members"].as_array().cloned().unwrap_or_default();
|
||||
listed.iter().map(|m| (m["value"].as_f64().unwrap_or(f64::NAN), m["text"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>()
|
||||
};
|
||||
let read = members(&chain);
|
||||
assert_eq!(read.iter().map(|(_, text)| text.as_str()).collect::<Vec<_>>(), ["10.00", "20.00", "30.00"]);
|
||||
assert!(read.iter().zip([10.0, 20.0, 30.0]).all(|((value, _), part)| (value - part).abs() < 1e-9), "{read:?}");
|
||||
let overall = chain["value"].as_f64().unwrap();
|
||||
assert!((overall - 60.0).abs() < 1e-9 && (read.iter().map(|(value, _)| value).sum::<f64>() - overall).abs() < 1e-9, "{chain}");
|
||||
assert_eq!(chain["record"]["kind"]["chain"]["points"].as_array().map(Vec::len), Some(4));
|
||||
// A direction given against the chain's way reads the same.
|
||||
let id = chain["dimension"].clone();
|
||||
assert_eq!(edit(&mut s, &id, json!({"direction": {"along": [-1, 0]}}))["changed"], json!(["direction"]));
|
||||
assert_eq!(members(&run(&mut s, INSPECT, json!({"dimension": id}))), read);
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
for (set, why) in [
|
||||
(json!({"reading": "true"}), "a chain has only `direction`"),
|
||||
(json!({"other_side": true}), "a chain has only `direction`"),
|
||||
(json!({"diameter": true}), "`diameter` is a radial dimension's"),
|
||||
(json!({"override_text": "60"}), "a chain shows its members' numbers, never a text instead"),
|
||||
] {
|
||||
let p = json!({"dimension": id, "set": set});
|
||||
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
let v = ids::encode(view);
|
||||
let chained = |points: Value| json!({"view": v, "kind": {"chain": {"points": points, "direction": "horizontal"}}});
|
||||
let nowhere = json!([1e6, 1e6, 1e6]);
|
||||
let cases = [
|
||||
(chained(json!([h10, h60, h30])), "a chain's points run one way along its line"),
|
||||
(chained(json!([h10, h60])), "a chain has 3 to 64 points"),
|
||||
(chained(Value::Array(vec![nowhere.clone(); 65])), "a chain has 3 to 64 points"),
|
||||
(chained(json!([h10, nowhere, h60])), "no corner at"),
|
||||
(json!({"view": v, "kind": {"chain": {"points": [h10, h30, h60], "reading": "true"}}}), "unknown field `reading`"),
|
||||
(json!({"view": v, "kind": {"chain": {"points": [h10, h30, h60]}}, "override_text": "60"}), "never a text instead"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// A cylinder's diameter is read between its silhouettes: a boss of radius 5 along X on the box's
|
||||
/// right face, seen side on from the front, reads Ø10.00, picked by a point on it or by its body
|
||||
/// and index, and saved by its face's name; it takes no settings. A flat face, a fillet's quarter
|
||||
/// round (one of its silhouettes isn't on it) and a hole the view sees end-on are refused, with
|
||||
/// the drawing unchanged.
|
||||
#[test]
|
||||
fn a_cylinders_diameter_is_read_between_its_silhouettes() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [80, 20, 15], "axis": "X", "operation": "join", "targets": ["Box"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 30]], "radius": 4, "body": "Box"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [20, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
// 30° round from the boss's top, away from where its two half faces meet.
|
||||
let on_boss = [90.0, 22.5, 15.0 + 5.0 * 30f64.to_radians().cos()];
|
||||
let boss = add(&mut s, view, json!({"cylinder": {"face": on_boss}}));
|
||||
reads(&boss, 10.0, "Ø10.00");
|
||||
assert_eq!((boss["exact"].clone(), boss["foreshortened"].clone()), (json!(true), json!(false)), "{boss}");
|
||||
let saved = boss["record"]["kind"]["cylinder"]["face"].clone();
|
||||
assert!(saved["body"] == "Box" && saved["name"].as_str().is_some_and(|n| !n.is_empty()), "{saved}");
|
||||
let index = {
|
||||
let state = s.model.state();
|
||||
solidcraft_doc::appearance::face_index_at(state.body("Box").unwrap(), Vec3::from(on_boss)).unwrap()
|
||||
};
|
||||
let by_index = add(&mut s, view, json!({"cylinder": {"face": {"body": "Box", "index": index}}}));
|
||||
assert_eq!(by_index["record"]["kind"], boss["record"]["kind"]);
|
||||
let id = boss["dimension"].clone();
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
for (set, why) in [
|
||||
(json!({"direction": "horizontal"}), "a cylinder's diameter has no direction, reading or side"),
|
||||
(json!({"reading": "true"}), "a cylinder's diameter has no direction, reading or side"),
|
||||
(json!({"diameter": false}), "`diameter` is a radial dimension's"),
|
||||
] {
|
||||
let p = json!({"dimension": id, "set": set});
|
||||
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
let v = ids::encode(view);
|
||||
let cylinder = |face: Value| json!({"view": v, "kind": {"cylinder": {"face": face}}});
|
||||
let on_fillet = [40.0, 4.0 - 4.0 * 45f64.to_radians().cos(), 26.0 + 4.0 * 45f64.to_radians().sin()];
|
||||
let cases = [
|
||||
(cylinder(json!([40, 0, 10])), "isn't a cylinder"),
|
||||
(cylinder(json!(on_fillet)), "isn't a whole cylinder in this view"),
|
||||
(cylinder(json!([23, 20, 15])), "the cylinder is seen end-on in this view"),
|
||||
(cylinder(json!([200, 200, 200])), "no face at [200, 200, 200]"),
|
||||
(cylinder(json!({"body": "Box", "index": 9999})), "body `Box` has no face 9999"),
|
||||
(cylinder(json!({"body": "Box"})), "a face is a point on it"),
|
||||
(cylinder(json!({"body": 7, "index": 0})), "a face's `body` is its name"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// Two bosses along X on the box's right face, radius 5 at z = 15 and radius 3 at z = 25, in the
|
||||
/// front view: near to near 2, near to far 8, far to near 12 and far to far 18, saved by each
|
||||
/// face's name and side; up the view the same. Two faces of one boss make its Ø10, which takes no
|
||||
/// direction. Its edit takes a direction only. Refused, with the drawing unchanged: a flat face, a
|
||||
/// fillet's quarter round on the side its silhouette isn't on, a hole seen end-on, and a side that
|
||||
/// is neither near nor far.
|
||||
#[test]
|
||||
fn two_cylinders_read_between_the_silhouettes_given() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
for (radius, z) in [(5, 15), (3, 25)] {
|
||||
let boss = json!({"radius": radius, "height": 20, "base": [80, 20, z], "axis": "X", "operation": "join", "targets": ["Box"]});
|
||||
run(&mut s, "solid.cylinder", boss);
|
||||
}
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 30]], "radius": 4, "body": "Box"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [20, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
// Each 30° round from its boss's top, away from where its two half faces meet.
|
||||
let on = |r: f64, z: f64| json!([90.0, 20.0 + r * 30f64.to_radians().sin(), z + r * 30f64.to_radians().cos()]);
|
||||
let (big, small) = (on(5.0, 15.0), on(3.0, 25.0));
|
||||
let between = |a: &str, b: &str| json!({"silhouettes": {"a": {"face": big, "side": a}, "b": {"face": small, "side": b}}});
|
||||
for (a, b, value) in [("near", "near", 2.0), ("near", "far", 8.0), ("far", "near", 12.0), ("far", "far", 18.0)] {
|
||||
let added = add(&mut s, view, between(a, b));
|
||||
reads(&added, value, &format!("{value:.2}"));
|
||||
assert_eq!((added["exact"].clone(), added["foreshortened"].clone()), (json!(true), json!(false)), "{added}");
|
||||
let saved = &added["record"]["kind"]["silhouettes"];
|
||||
assert!(saved["a"]["side"] == a && saved["b"]["side"] == b && saved["direction"] == "aligned", "{saved}");
|
||||
assert_eq!(saved["a"]["face"]["body"], "Box", "{saved}");
|
||||
let up = edit(&mut s, &added["dimension"], json!({"direction": "vertical"}));
|
||||
assert_eq!(up["changed"], json!(["direction"]), "{up}");
|
||||
reads(&run(&mut s, INSPECT, json!({"dimension": added["dimension"]})), value, &format!("{value:.2}"));
|
||||
}
|
||||
let other_half = on(-5.0, 15.0);
|
||||
let one = add(&mut s, view, json!({"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": other_half, "side": "far"}}}));
|
||||
reads(&one, 10.0, "Ø10.00");
|
||||
assert!(one["record"]["kind"]["cylinder"]["face"]["name"].is_string(), "{one}");
|
||||
let id = add(&mut s, view, between("near", "near"))["dimension"].clone();
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let e = s.execute(EDIT, &json!({"dimension": id, "set": {"reading": "true"}})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("only `direction`"), "{e}");
|
||||
let v = ids::encode(view);
|
||||
let with =
|
||||
|b: Value, side: &str| json!({"view": v, "kind": {"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": b, "side": side}}}});
|
||||
let on_fillet = [40.0, 4.0 - 4.0 * 45f64.to_radians().cos(), 26.0 + 4.0 * 45f64.to_radians().sin()];
|
||||
let halves = json!({"view": v, "kind": {"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": other_half, "side": "far"},
|
||||
"direction": "vertical"}}});
|
||||
let cases = [
|
||||
(halves, "takes no direction"),
|
||||
(with(json!([40, 0, 10]), "near"), "isn't a cylinder"),
|
||||
(with(json!(on_fillet), "near"), "near silhouette in this view isn't on it"),
|
||||
(with(json!([23, 20, 15]), "near"), "the cylinder is seen end-on in this view"),
|
||||
(with(small.clone(), "left"), "unknown variant `left`"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
}
|
||||
|
||||
/// A dimension whose edge is filleted away has no value, and gives the one it last read this
|
||||
/// session as its last good value.
|
||||
#[test]
|
||||
fn a_dimension_that_cant_be_measured_gives_its_last_good_value() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
|
||||
reads(&added, 80.0, "80.00");
|
||||
assert!(added.get("last_good").is_none(), "measured now: {added}");
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.fillet", json!({"body": "Box", "radius": 5, "edges": [[40, 0, 30]]}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let read = run(&mut s, INSPECT, json!({"dimension": added["dimension"]}));
|
||||
assert!(read.get("value").is_none() && read["status"] != json!("ok"), "{read}");
|
||||
assert_eq!(read["last_good"], json!(80.0), "{read}");
|
||||
// A read isn't in the document: a session on it, with no file in between, has none.
|
||||
let mut fresh = Session::new((*s.doc).clone());
|
||||
run(&mut fresh, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
assert!(run(&mut fresh, INSPECT, json!({"dimension": added["dimension"]})).get("last_good").is_none());
|
||||
}
|
||||
|
||||
/// A setting its kind hasn't got, and a value the record would refuse, are refused with the
|
||||
/// drawing unchanged; an angle takes its other side.
|
||||
#[test]
|
||||
fn an_edit_refuses_what_the_dimension_cant_take() {
|
||||
let (mut s, view) = front_view();
|
||||
let length = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}))["dimension"].clone();
|
||||
let angle = add(&mut s, view, json!({"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}))["dimension"].clone();
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let nothing = json!(ids::encode(99));
|
||||
let cases = [
|
||||
(&length, json!({"other_side": true}), "`other_side` is an angle's"),
|
||||
(&angle, json!({"direction": "horizontal"}), "an angle has no `direction`"),
|
||||
(&length, json!({"diameter": true}), "`diameter` is a radial dimension's"),
|
||||
(&length, json!({"decimals": 7}), "at most 6 decimals"),
|
||||
(&length, json!({"offset_mm": 1e9}), "on paper"),
|
||||
(&length, json!({"override_text": ""}), "instead of its number is 1 to"),
|
||||
(&length, json!({"offset_mm": null}), ""),
|
||||
(&length, json!({"edge": [0, 0, 0]}), "edge"),
|
||||
(¬hing, json!({"decimals": 1}), "the drawing has no dimension"),
|
||||
];
|
||||
for (id, set, why) in cases {
|
||||
let p = json!({"dimension": id, "set": set});
|
||||
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
assert_eq!(edit(&mut s, &angle, json!({"other_side": true}))["changed"], json!(["other_side"]));
|
||||
assert_eq!(run(&mut s, INSPECT, json!({"dimension": angle}))["record"]["kind"]["angle"]["other_side"], json!(true));
|
||||
}
|
||||
|
||||
/// A pick that doesn't name one thing, and a dimension the record would refuse, are refused
|
||||
/// with the drawing unchanged.
|
||||
#[test]
|
||||
fn a_pick_that_names_nothing_or_several_things_is_refused() {
|
||||
let (mut s, view) = front_view();
|
||||
let before = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let v = ids::encode(view);
|
||||
let length = |edge: Value| json!({"view": v, "kind": {"length": {"edge": edge}}});
|
||||
let linear = |a: Value| json!({"view": v, "kind": {"linear": {"a": a, "b": [80, 0, 0]}}});
|
||||
let cases = [
|
||||
(length(json!([40, 20, 15])), "no edge at [40, 20, 15]"),
|
||||
(length(json!([0, 0, 0])), "[0, 0, 0] is on 3 edges"),
|
||||
(length(json!({"body": "Nope", "index": 0})), "no body `Nope`"),
|
||||
(length(json!({"body": "Box", "index": 999})), "body `Box` has no edge 999"),
|
||||
(length(json!({"point": [40, 0, 30], "index": 1})), "an edge is a point on it"),
|
||||
(linear(json!([40, 0, 0])), "no corner at [40, 0, 0]"),
|
||||
(linear(json!({"edges": [{"point": [40, 0, 0]}, {"point": [40, 40, 30]}]})), "the corner's edges don't meet"),
|
||||
(linear(json!({"edges": [{"point": [40, 0, 0], "path": [1]}]})), "each without a path of its own"),
|
||||
(json!({"view": v, "kind": {"length": {"edge": [40, 0, 30]}}, "decimals": 7}), "at most 6 decimals"),
|
||||
(json!({"view": v, "kind": {"length": {"edge": [40, 0, 30]}}, "offset_mm": 1e9}), "on paper"),
|
||||
(json!({"view": ids::encode(99), "kind": {"length": {"edge": [40, 0, 30]}}}), "the drawing has no view"),
|
||||
(json!({"view": v, "kind": {"radius": {"edge": [40, 0, 30]}}}), "radius"),
|
||||
(json!({"view": v, "kind": {"radial": {"edge": [40, 0, 30]}}}), "isn't a circle or an arc"),
|
||||
(json!({"view": v, "kind": {"radial": {"edge": [0, 20, 0]}}}), "isn't a circle in this view (it isn't parallel to it)"),
|
||||
(json!({"view": v, "kind": {"radial": {"edge": [40, 0, 30], "diameter": 1}}}), "must be true or false"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
let e = refused(&mut s, p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
|
||||
let e = s.execute(INSPECT, &json!({"dimension": ids::encode(99)})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("the drawing has no dimension"), "{e}");
|
||||
}
|
||||
|
||||
/// Every place a value can go in `v`, as JSON pointers: `v` itself, then each member and item.
|
||||
fn places(v: &Value, at: String, out: &mut Vec<String>) {
|
||||
out.push(at.clone());
|
||||
match v {
|
||||
Value::Object(o) => o.iter().for_each(|(k, x)| places(x, format!("{at}/{k}"), out)),
|
||||
Value::Array(l) => l.iter().enumerate().for_each(|(i, x)| places(x, format!("{at}/{i}"), out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hostile value in each place of each command's parameters, at every depth (picks, paths,
|
||||
/// corners' edges, a chain's points, the edit's settings): never a panic, and the drawing keeps its
|
||||
/// rules.
|
||||
#[test]
|
||||
fn hostile_dimension_params_never_panic() {
|
||||
let (mut s, view) = front_view();
|
||||
let id = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}))["dimension"].clone();
|
||||
// Aligned from the first corner to the last, each corner past the one before.
|
||||
let chain = add(&mut s, view, json!({"chain": {"points": [[0, 0, 0], [80, 0, 0], [80, 0, 30]]}}))["dimension"].clone();
|
||||
let v = json!(ids::encode(view));
|
||||
let corner = json!({"edges": [{"body": "Box", "index": 0}, {"point": [0, 0, 15]}], "path": [1]});
|
||||
let calls = [
|
||||
(
|
||||
ADD,
|
||||
json!({"view": v, "kind": {"linear": {"a": [0, 0, 0], "b": corner, "direction": {"along": [1, 0]}, "reading": "projected"}},
|
||||
"offset_mm": 5, "text": {"at": [1, 2]}, "decimals": 1, "override_text": "x"}),
|
||||
),
|
||||
(ADD, json!({"view": v, "kind": {"angle": {"a": {"point": [40, 0, 30], "path": [1]}, "b": [0, 0, 15], "other_side": true}}})),
|
||||
(ADD, json!({"view": v, "kind": {"radial": {"edge": {"body": "Box", "index": 0, "path": [1]}, "diameter": true}}})),
|
||||
(
|
||||
ADD,
|
||||
json!({"view": v, "kind": {"chain": {"points": [[0, 0, 0], {"centre": {"body": "Box", "index": 0}, "path": [1]}, corner, [80, 0, 0]],
|
||||
"direction": "horizontal"}}}),
|
||||
),
|
||||
(EDIT, json!({"dimension": chain, "set": {"direction": {"along": [1, 1]}, "decimals": 3}})),
|
||||
(ADD, json!({"view": v, "kind": {"cylinder": {"face": {"body": "Box", "index": 0, "path": [1]}}}})),
|
||||
(
|
||||
ADD,
|
||||
json!({"view": v, "kind": {"silhouettes": {"a": {"face": {"body": "Box", "index": 0, "path": [1]}, "side": "near"},
|
||||
"b": {"face": [0, 0, 15], "side": "far"}, "direction": {"along": [1, 1]}}}}),
|
||||
),
|
||||
(
|
||||
EDIT,
|
||||
json!({"dimension": id, "set": {"offset_mm": 5, "text": "centred", "decimals": 1, "override_text": null, "direction": "vertical",
|
||||
"reading": "projected"}}),
|
||||
),
|
||||
(INSPECT, json!({"dimension": id})),
|
||||
];
|
||||
let many = |v: Value| Value::Array(vec![v; 1000]);
|
||||
let mut values = crate::tests::hostile_values();
|
||||
values.extend([
|
||||
json!({"body": "Box", "index": u64::MAX}),
|
||||
json!({"edges": []}),
|
||||
json!({"edges": many(json!([0, 0, 0]))}),
|
||||
many(json!(1)),
|
||||
json!([1e308, 0, 0]),
|
||||
json!("x".repeat(600)),
|
||||
]);
|
||||
let mut internal = Vec::new();
|
||||
for (cmd, template) in calls {
|
||||
let mut at = Vec::new();
|
||||
places(&template, String::new(), &mut at);
|
||||
for (place, value) in at.iter().flat_map(|place| values.iter().map(move |value| (place, value))) {
|
||||
let mut p = template.clone();
|
||||
if let Some(slot) = p.pointer_mut(place) {
|
||||
*slot = value.clone();
|
||||
}
|
||||
let mut copy = s.scratch();
|
||||
if let Err(crate::EngineError::Internal(_, msg)) = copy.execute(cmd, &p) {
|
||||
internal.push(format!("{cmd} {p}: {msg}"));
|
||||
}
|
||||
copy.doc.drawings.iter().for_each(|d| d.validate().unwrap());
|
||||
}
|
||||
}
|
||||
assert!(internal.is_empty(), "dimension commands panicked:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
/// A dimension on a reference part's edge: added by its occurrence, reading its length, and the same
|
||||
/// after the part stops being reference. The finder doesn't ask what a part's type is.
|
||||
#[test]
|
||||
fn a_dimension_can_measure_a_reference_parts_edge() {
|
||||
let mut s = Session::default();
|
||||
let block = run(&mut s, "component.create", json!({"name": "Block"}))["occurrence"].clone();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 30, "height": 10, "body_name": "Block", "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.metadata.set", json!({"component": "Block", "bom_type": "reference"}));
|
||||
let (mut s, view) = drawn_from_the_front(s);
|
||||
let edge = json!({"point": [10, 0, 10], "path": [block]});
|
||||
let added = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"length": {"edge": edge}}}));
|
||||
assert!((added["value"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{added}");
|
||||
let drawing = ids::encode(s.drawing("test").unwrap().id);
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "component.metadata.set", json!({"component": "Block", "bom_type": "normal"}));
|
||||
run(&mut s, "drawing.open", json!({"drawing": drawing}));
|
||||
let read = run(&mut s, INSPECT, json!({"dimension": added["dimension"]}));
|
||||
assert_eq!((&read["status"], &read["value"]), (&json!("ok"), &added["value"]), "{read}");
|
||||
}
|
||||
|
||||
/// A box with an 80 mm dimension on its top front edge, read once, and the drawing closed: the
|
||||
/// session, the drawing's id and the dimension's.
|
||||
fn read_once() -> (Session, u64, u64) {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
(s, drawing, ids::decode(added["dimension"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
/// Fillets away the edge the [`read_once`] dimension measures.
|
||||
fn lose_the_edge(s: &mut Session) {
|
||||
run(s, "solid.fillet", json!({"body": "Box", "radius": 5, "edges": [[40, 0, 30]]}));
|
||||
}
|
||||
|
||||
/// `s` saved to a file called `name` and opened from it in a new session, with `drawing` open.
|
||||
fn reopened(s: &mut Session, drawing: u64, name: &str) -> Session {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-last-good-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
run(s, "file.save_as", json!({"path": path}));
|
||||
let mut fresh = Session::default();
|
||||
run(&mut fresh, "doc.open", json!({"path": path}));
|
||||
run(&mut fresh, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
fresh
|
||||
}
|
||||
|
||||
fn inspect_of(s: &mut Session, dimension: u64) -> Value {
|
||||
run(s, INSPECT, json!({"dimension": ids::encode(dimension)}))
|
||||
}
|
||||
|
||||
/// A lost dimension still says what it last read after the design is saved and opened again, in
|
||||
/// either form of the file. It stays lost, with no value and no text, and a read of it leaves
|
||||
/// nothing to save.
|
||||
#[test]
|
||||
fn a_lost_dimension_keeps_its_last_good_value_after_reopening() {
|
||||
for name in [format!("part.{}", solidcraft_io::DESIGN_EXT), "part.json".to_string()] {
|
||||
let (mut s, drawing, dimension) = read_once();
|
||||
lose_the_edge(&mut s);
|
||||
let mut opened = reopened(&mut s, drawing, &name);
|
||||
let read = inspect_of(&mut opened, dimension);
|
||||
assert_eq!(read["status"], json!("lost"), "{name}: {read}");
|
||||
assert!(read.get("value").is_none() && read.get("text").is_none(), "{name}, never its value: {read}");
|
||||
assert_eq!(read["last_good"], json!(80.0), "{name}: {read}");
|
||||
assert!(!opened.is_dirty(), "{name}: a read leaves nothing to save");
|
||||
}
|
||||
}
|
||||
|
||||
/// A read changes nothing in the document: no undo step, nothing to save, the same document. Only
|
||||
/// the file's copy has the value, and a session that read nothing copies nothing.
|
||||
#[test]
|
||||
fn a_read_stays_out_of_the_document() {
|
||||
let (mut s, drawing, dimension) = read_once();
|
||||
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-last-good-read-{}.json", std::process::id()));
|
||||
run(&mut s, "file.save_as", json!({"path": path}));
|
||||
let (doc, steps) = (s.doc.clone(), s.undo.len());
|
||||
inspect_of(&mut s, dimension);
|
||||
assert!(Arc::ptr_eq(&s.doc, &doc) && s.undo.len() == steps && !s.is_dirty());
|
||||
assert_eq!(s.doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, None, "only in the file's copy");
|
||||
assert_eq!(s.design().drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, Some(80.0));
|
||||
let opened = Session::new(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(matches!(opened.design(), std::borrow::Cow::Borrowed(_)), "nothing read: nothing to copy");
|
||||
assert_eq!(opened.doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, Some(80.0));
|
||||
let bytes = solidcraft_io::write_design(&opened.design());
|
||||
assert_eq!(solidcraft_io::write_design(&solidcraft_io::read_design(&bytes).unwrap()), bytes, "save, open, save: the same bytes");
|
||||
}
|
||||
|
||||
/// A saved value is only ever what it last read: measured now, a dimension reads the model and has
|
||||
/// no last good value, whatever the file said. A newer read replaces the saved one, in the session
|
||||
/// and through the next save.
|
||||
#[test]
|
||||
fn a_saved_last_good_value_never_reads_as_the_value() {
|
||||
let (mut s, drawing, dimension) = read_once();
|
||||
s.dimension_values.insert((drawing, dimension), 55.5);
|
||||
let mut opened = reopened(&mut s, drawing, "seeded.json");
|
||||
let read = inspect_of(&mut opened, dimension);
|
||||
reads(&read, 80.0, "80.00");
|
||||
assert_eq!(read["record"]["last_good"], json!(55.5), "as saved");
|
||||
assert!(read.get("last_good").is_none(), "measured now: {read}");
|
||||
run(&mut opened, "drawing.close", json!({}));
|
||||
lose_the_edge(&mut opened);
|
||||
run(&mut opened, "drawing.open", json!({"drawing": ids::encode(drawing)}));
|
||||
assert_eq!(inspect_of(&mut opened, dimension)["last_good"], json!(80.0), "this session's read comes first");
|
||||
run(&mut opened, "drawing.close", json!({}));
|
||||
let mut again = reopened(&mut opened, drawing, "again.json");
|
||||
assert_eq!(inspect_of(&mut again, dimension)["last_good"], json!(80.0), "and replaces the saved one");
|
||||
}
|
||||
|
||||
/// An angle saves its last good value in radians and gives it in degrees, as it gives its value.
|
||||
#[test]
|
||||
fn a_lost_angle_gives_its_saved_value_in_degrees() {
|
||||
let (mut s, view) = front_view();
|
||||
let drawing = s.drawing("test").unwrap().id;
|
||||
let added = add(&mut s, view, json!({"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}));
|
||||
let dimension = ids::decode(added["dimension"].as_str().unwrap()).unwrap();
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
lose_the_edge(&mut s);
|
||||
let mut opened = reopened(&mut s, drawing, "angle.json");
|
||||
let read = inspect_of(&mut opened, dimension);
|
||||
assert_eq!(read["status"], json!("lost"), "{read}");
|
||||
assert!((read["record"]["last_good"].as_f64().unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "{read}");
|
||||
assert!((read["last_good"].as_f64().unwrap() - 90.0).abs() < 1e-9, "{read}");
|
||||
}
|
||||
|
||||
/// A model can read past the largest number a drawing saves: that value isn't saved, so the file
|
||||
/// still opens, and the dimension has none.
|
||||
#[test]
|
||||
fn a_value_past_what_a_drawing_saves_leaves_the_file_able_to_open() {
|
||||
let (mut s, drawing, dimension) = read_once();
|
||||
s.dimension_values.insert((drawing, dimension), 2e9);
|
||||
let mut opened = reopened(&mut s, drawing, "huge.json");
|
||||
assert!(inspect_of(&mut opened, dimension)["record"].get("last_good").is_none());
|
||||
}
|
||||
|
||||
/// A background save runs on a scratch copy of the session, and an autosave on its own snapshot:
|
||||
/// both keep what the dimensions last read, so a recovered design has it too.
|
||||
#[test]
|
||||
fn background_saves_and_autosaves_keep_the_last_good_value() {
|
||||
let (s, drawing, dimension) = read_once();
|
||||
let last_good = |doc: &solidcraft_doc::Document| doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good;
|
||||
assert_eq!(last_good(&s.scratch().design()), Some(80.0));
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-last-good-autosave-{}", std::process::id()));
|
||||
let mut saver = crate::recovery::Autosaver::new(&dir, std::time::Duration::from_secs(3600)).unwrap();
|
||||
assert!(saver.save(&s).unwrap());
|
||||
drop(saver);
|
||||
let found = crate::recovery::orphans(&dir);
|
||||
let (doc, _) = crate::recovery::load(&dir, &found[0].id).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(last_good(&doc), Some(80.0));
|
||||
}
|
||||
|
||||
/// What this session read goes with this design: opening another one starts with none, so it can't
|
||||
/// reach that design's file, even a copy with the same ids.
|
||||
#[test]
|
||||
fn opening_a_design_starts_with_no_last_good_values() {
|
||||
let (mut s, _, _) = read_once();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-last-good-open-{}.json", std::process::id()));
|
||||
std::fs::write(&path, solidcraft_io::write_design(&s.doc)).unwrap();
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(s.dimension_values.is_empty());
|
||||
}
|
||||
|
||||
/// A view copied to a sheet brings its dimensions with what they saved: the same references, read
|
||||
/// on the same view direction.
|
||||
#[test]
|
||||
fn a_copied_dimension_keeps_its_saved_value() {
|
||||
let (mut s, drawing, dimension) = read_once();
|
||||
let mut opened = reopened(&mut s, drawing, "copy.json");
|
||||
let view = opened.drawing("test").unwrap().dimension(dimension).unwrap().view;
|
||||
run(&mut opened, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": "Sheet 1", "position_mm": [100, 100]}));
|
||||
let d = opened.drawing("test").unwrap();
|
||||
let copy = d.dimensions.iter().find(|x| x.id != dimension).unwrap();
|
||||
assert_eq!(copy.last_good, Some(80.0));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! `drawing.export` (#4890 A7): one sheet of the open drawing written out from its scene
|
||||
//! ([`crate::drawing_scene::build`]), the scene the sheet page draws, so a file shows what the page
|
||||
//! does. SVG for now: PNG comes with the sheet PNG writer (#4890 A4) and DXF with the shared DXF
|
||||
//! writer (#5319). A sheet with a view that failed isn't written; the refusal names the views.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::drawing::DEFAULT_DPI;
|
||||
use solidcraft_doc::ids;
|
||||
use solidcraft_geom::paper::NoPictures;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::drawing_scene::{Status, sheet_id};
|
||||
use crate::params::bad;
|
||||
use crate::schema::drawing_export::{ExportFormat, ExportParams, ExportReport, ExportResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "drawing.export";
|
||||
const CONTRACT: TypedContract<ExportParams> = TypedContract::new();
|
||||
/// The longest path taken, in bytes, as the view DXF's.
|
||||
const MAX_PATH_BYTES: usize = 4096;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Sheet", crate::checked!("drawing.export", export))
|
||||
.icon("export")
|
||||
.in_drawing()
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ExportResult>)
|
||||
.params("sheet: id or name; format?: svg (default; png and dxf are not supported yet); path?: .svg file (else the reply holds the SVG text) → {sheet, format, bytes, path | svg, report: {widths_snapped, pieces_merged}, views: [{view, status: drawn|placeholder|failed, reason?, stats?}]}. The sheet as drawing.scene draws it: its drawing border, then each view's visible lines. Refused, naming them, while any view on the sheet failed; a placeholder view draws nothing. No model is evaluated.")];
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: ExportParams = crate::schema::read(CMD, p)?;
|
||||
match request.format {
|
||||
ExportFormat::Svg => {}
|
||||
ExportFormat::Png => return Err(bad(CMD, "not supported yet: PNG needs the sheet PNG writer (#4890 A4)")),
|
||||
ExportFormat::Dxf => return Err(bad(CMD, "not supported yet: DXF needs the shared DXF writer (#5319)")),
|
||||
}
|
||||
if let Some(path) = &request.path {
|
||||
let svg = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("svg"));
|
||||
if path.len() > MAX_PATH_BYTES || !svg {
|
||||
return Err(bad(CMD, "path must be a .svg file"));
|
||||
}
|
||||
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
let d = s.drawing(CMD)?.clone();
|
||||
let sheet = sheet_id(&d, &request.sheet, CMD)?;
|
||||
let built = crate::drawing_scene::build(s, &d, sheet)?;
|
||||
let failed: Vec<String> = built
|
||||
.views
|
||||
.iter()
|
||||
.filter_map(|v| match &v.status {
|
||||
Status::Failed(why) => Some(format!("{} ({why})", ids::encode(v.view))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !failed.is_empty() {
|
||||
return Err(bad(CMD, format!("not written: views on the sheet failed: {}", failed.join(", "))));
|
||||
}
|
||||
let prepared = built.scene.prepare().map_err(|e| bad(CMD, e.to_string()))?;
|
||||
// A drawing's scene holds no pictures yet, so their resolution goes unused until shaded views bring the sheet's own.
|
||||
let (svg, written) = solidcraft_io::paper::paper_svg(&prepared, &NoPictures, DEFAULT_DPI).map_err(|e| bad(CMD, e.to_string()))?;
|
||||
let bytes = svg.len();
|
||||
let (path, svg) = match request.path {
|
||||
Some(path) => {
|
||||
// Checked again just before writing, as the view DXF does.
|
||||
solidcraft_io::vfs::check(&path).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
solidcraft_io::write_atomic(Path::new(&path), &svg, false).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
(Some(path), None)
|
||||
}
|
||||
None => (None, Some(String::from_utf8(svg).map_err(|e| EngineError::Other(e.to_string()))?)),
|
||||
};
|
||||
let reply = ExportResult {
|
||||
sheet: ids::encode(sheet),
|
||||
format: request.format,
|
||||
bytes,
|
||||
path,
|
||||
svg,
|
||||
report: ExportReport { widths_snapped: written.widths_snapped, pieces_merged: written.pieces_merged },
|
||||
views: built.views.into_iter().map(Into::into).collect(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_export_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,119 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::MAX_PLACEMENT_DEPTH;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing_scene::tests::{front_and_top, nest};
|
||||
use crate::schema::output_schema_for;
|
||||
use crate::schema::tests::conforms;
|
||||
use crate::tests::Folder;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, p: Value) -> String {
|
||||
s.execute(CMD, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"))
|
||||
}
|
||||
|
||||
/// The SVG of sheet `sheet` of the drawing open, written straight from its built scene.
|
||||
fn scene_svg(s: &Session, sheet: u64) -> Vec<u8> {
|
||||
let d = s.drawing("test").unwrap().clone();
|
||||
let built = crate::drawing_scene::build(s, &d, sheet).unwrap();
|
||||
solidcraft_io::paper::paper_svg(&built.scene.prepare().unwrap(), &NoPictures, DEFAULT_DPI).unwrap().0
|
||||
}
|
||||
|
||||
/// A sheet exports as its scene's SVG, byte for byte, by id or by name: the reply holds it, with
|
||||
/// its size and each view's status, as its schema says. Nothing changes and no model is evaluated.
|
||||
#[test]
|
||||
fn a_sheet_exports_as_its_scenes_svg() {
|
||||
let (mut s, sheet, [front, top]) = front_and_top();
|
||||
let (doc, evaluations) = (s.doc.clone(), s.evaluations);
|
||||
let reply = run(&mut s, CMD, json!({"sheet": ids::encode(sheet)}));
|
||||
let schema = output_schema_for::<ExportResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
let svg = scene_svg(&s, sheet);
|
||||
assert_eq!(reply["svg"].as_str().map(str::as_bytes), Some(svg.as_slice()));
|
||||
assert_eq!((&reply["format"], reply["bytes"].as_u64(), reply.get("path")), (&json!("svg"), Some(svg.len() as u64), None), "{reply}");
|
||||
// Each view on the sheet, in id order, as drawing.scene lists them.
|
||||
let views: Vec<_> = reply["views"].as_array().unwrap().iter().map(|v| (v["view"].clone(), v["status"].clone())).collect();
|
||||
let mut in_order = [front, top];
|
||||
in_order.sort_unstable();
|
||||
assert_eq!(views, in_order.map(|v| (json!(ids::encode(v)), json!("drawn"))));
|
||||
let name = s.drawing("test").unwrap().sheets[0].name.clone();
|
||||
assert_eq!(run(&mut s, CMD, json!({"sheet": name, "format": "svg"}))["svg"], reply["svg"]);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc) && s.evaluations == evaluations);
|
||||
// The sheet named, not the first: a second sheet, with no views, exports as its own scene.
|
||||
let added = run(&mut s, "drawing.sheet.add", json!({}));
|
||||
let second = ids::decode(added["sheet"].as_str().unwrap()).unwrap();
|
||||
let other = run(&mut s, CMD, json!({"sheet": ids::encode(second)}));
|
||||
assert_eq!((&other["sheet"], other["views"].as_array().map(Vec::len)), (&json!(ids::encode(second)), Some(0)), "{other}");
|
||||
assert_eq!(other["svg"].as_str().map(str::as_bytes), Some(scene_svg(&s, second).as_slice()));
|
||||
assert_ne!(other["svg"], reply["svg"]);
|
||||
}
|
||||
|
||||
/// With a path the file holds the same SVG and the reply names it instead.
|
||||
#[test]
|
||||
fn a_path_gets_the_svg_written_to_it() {
|
||||
let (mut s, sheet, _) = front_and_top();
|
||||
let folder = Folder::new("drawing-export");
|
||||
let path = folder.path("Sheet 1.SVG");
|
||||
let reply = run(&mut s, CMD, json!({"sheet": ids::encode(sheet), "path": path}));
|
||||
let written = std::fs::read(&path).unwrap();
|
||||
assert_eq!(written, scene_svg(&s, sheet));
|
||||
assert_eq!(
|
||||
(reply["path"].as_str(), reply.get("svg"), reply["bytes"].as_u64()),
|
||||
(Some(path.as_str()), None, Some(written.len() as u64)),
|
||||
"{reply}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A view that failed stops the export, and the refusal names it with why; a placeholder draws
|
||||
/// nothing and is exported all the same.
|
||||
#[test]
|
||||
fn a_failed_view_is_named_and_nothing_is_written() {
|
||||
let (mut s, sheet, [front, top]) = front_and_top();
|
||||
let folder = Folder::new("drawing-export-failed");
|
||||
let path = folder.path("sheet.svg");
|
||||
run(&mut s, "drawing.view.edit", json!({"view": ids::encode(top), "set": {"display_style": "shaded"}}));
|
||||
let why = refused(&mut s, json!({"sheet": ids::encode(sheet), "path": path}));
|
||||
assert!(why.contains(&format!("views on the sheet failed: {} (not supported yet: shaded views", ids::encode(top))), "{why}");
|
||||
assert!(!why.contains(&ids::encode(front)) && !std::path::Path::new(&path).exists(), "{why}");
|
||||
run(&mut s, "drawing.view.edit", json!({"view": ids::encode(top), "set": {"display_style": "visible"}}));
|
||||
for level in 0..=MAX_PLACEMENT_DEPTH as u64 + 1 {
|
||||
nest(&mut s, level);
|
||||
}
|
||||
let reply = run(&mut s, CMD, json!({"sheet": ids::encode(sheet)}));
|
||||
let statuses: Vec<_> = reply["views"].as_array().unwrap().iter().map(|v| v["status"].clone()).collect();
|
||||
assert_eq!(statuses, [json!("placeholder"), json!("placeholder")], "{reply}");
|
||||
assert_eq!(reply["svg"].as_str().map(str::as_bytes), Some(scene_svg(&s, sheet).as_slice()));
|
||||
}
|
||||
|
||||
/// PNG and DXF aren't supported yet; a path that isn't a `.svg` file, an unknown sheet, an unknown
|
||||
/// key and no drawing open are refused, and nothing is written.
|
||||
#[test]
|
||||
fn what_it_cant_write_is_refused() {
|
||||
let (mut s, sheet, _) = front_and_top();
|
||||
let folder = Folder::new("drawing-export-refused");
|
||||
let id = ids::encode(sheet);
|
||||
let long = format!("{}.svg", "a".repeat(MAX_PATH_BYTES));
|
||||
for (p, why) in [
|
||||
(json!({"sheet": id, "format": "png"}), "not supported yet: PNG"),
|
||||
(json!({"sheet": id, "format": "dxf"}), "not supported yet: DXF"),
|
||||
(json!({"sheet": id, "format": "pdf"}), "format"),
|
||||
(json!({"sheet": id, "path": folder.path("sheet.png")}), "path must be a .svg file"),
|
||||
(json!({"sheet": id, "path": folder.path("sheet")}), "path must be a .svg file"),
|
||||
(json!({"sheet": id, "path": " "}), "path must be a .svg file"),
|
||||
(json!({"sheet": id, "path": long}), "path must be a .svg file"),
|
||||
(json!({"sheet": id, "path": null}), "path"),
|
||||
(json!({"sheet": "Sheet 9"}), "no sheet `Sheet 9`"),
|
||||
(json!({"sheet": 5}), "sheet"),
|
||||
(json!({}), "sheet"),
|
||||
(json!({"sheet": id, "dpi": 300}), "dpi"),
|
||||
] {
|
||||
let message = refused(&mut s, p.clone());
|
||||
assert!(message.contains(why), "{p}: {message}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0);
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
assert!(refused(&mut s, json!({"sheet": id})).contains("open a drawing first"));
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A drawing's revision rows (#3874, W31): add, edit and delete them, each one undo step of the
|
||||
//! drawing (none when nothing changes); `drawing.inspect` lists them. One revision record covers the whole drawing
|
||||
//! ([`solidcraft_geom::revision`]): a new row gets the next revision, after the highest the record
|
||||
//! has given, and people type the rest. The newest row is the drawing's revision, hidden or not.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::ids;
|
||||
use solidcraft_geom::revision::{Field, RevisionRecord, RevisionRow, SkippedLetter};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::drawing_revision::{RevisionAddParams, RevisionDeleteParams, RevisionEditParams, RevisionsResult, RowResult, RowSet};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD: &str = "drawing.revision.add";
|
||||
const ADD_CONTRACT: TypedContract<RevisionAddParams> = TypedContract::new();
|
||||
const EDIT: &str = "drawing.revision.edit";
|
||||
const EDIT_CONTRACT: TypedContract<RevisionEditParams> = TypedContract::new();
|
||||
const DELETE: &str = "drawing.revision.delete";
|
||||
const DELETE_CONTRACT: TypedContract<RevisionDeleteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new(ADD, "Add Revision", crate::checked!("drawing.revision.add", add))
|
||||
.icon("new")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&ADD_CONTRACT)
|
||||
.output(output_schema_for::<RevisionsResult>)
|
||||
.params("set?: {revision?, description?, designer?, checker?, date?, hidden?}: the new row's texts (the revision, when not given, the next one: after the highest the drawing has given) → {row: its id, rows, next}. One undo step."),
|
||||
CommandSpec::new(EDIT, "Edit Revision", crate::checked!("drawing.revision.edit", edit))
|
||||
.icon("edit")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(output_schema_for::<RevisionsResult>)
|
||||
.params("row: a row's id; set: {revision?, description?, designer?, checker?, date?: each as typed (a revision is one line); hidden?: left out of the revision table} → {row, rows, next, changed: [fields], warnings?: a revision in letters with a letter the lettering skips}. One undo step, none when nothing changes."),
|
||||
CommandSpec::new(DELETE, "Delete Revision", crate::checked!("drawing.revision.delete", delete))
|
||||
.icon("delete")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<RevisionsResult>)
|
||||
.params("row: a row's id → {row, rows, next, tags_lost?: the revision tags bound to it, which keep the revision they showed}. Its revision is not given out again. One undo step."),
|
||||
];
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RevisionAddParams = crate::schema::read(ADD, p)?;
|
||||
let id = s.drawing(ADD)?.clone().allocate_id(&mut s.ids).map_err(|e| bad(ADD, e.to_string()))?;
|
||||
let warnings = s.edit_drawing(ADD, |d| {
|
||||
let record = Arc::make_mut(&mut d.revisions);
|
||||
record.add(None, id).map_err(|e| bad(ADD, e.to_string()))?;
|
||||
apply(record, id, &request.set, ADD)
|
||||
})?;
|
||||
answer(s, ADD, RevisionsResult { row: Some(ids::encode(id)), warnings, ..Default::default() })
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RevisionEditParams = crate::schema::read(EDIT, p)?;
|
||||
let id = request.row.0;
|
||||
let before = row(s.drawing(EDIT)?.revisions.as_ref(), id, EDIT)?.clone();
|
||||
let warnings = s.edit_drawing(EDIT, |d| apply(Arc::make_mut(&mut d.revisions), id, &request.set, EDIT))?;
|
||||
let after = row(s.drawing(EDIT)?.revisions.as_ref(), id, EDIT)?;
|
||||
let mut changed: Vec<String> =
|
||||
Field::ALL.into_iter().filter(|f| before.field(f.key()) != after.field(f.key())).map(|f| f.key().to_string()).collect();
|
||||
if before.hidden != after.hidden {
|
||||
changed.push("hidden".into());
|
||||
}
|
||||
answer(s, EDIT, RevisionsResult { row: Some(ids::encode(id)), changed, warnings, ..Default::default() })
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RevisionDeleteParams = crate::schema::read(DELETE, p)?;
|
||||
let id = request.row.0;
|
||||
let lost = s.edit_drawing(DELETE, |d| Arc::make_mut(&mut d.revisions).delete(id).map_err(|e| bad(DELETE, e.to_string())))?;
|
||||
answer(s, DELETE, RevisionsResult { row: Some(ids::encode(id)), tags_lost: lost.into_iter().map(ids::encode).collect(), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Sets what `set` gives on row `id`: the warnings for a revision in letters with a letter the
|
||||
/// lettering skips.
|
||||
fn apply(record: &mut RevisionRecord, id: u64, set: &RowSet, cmd: &str) -> Result<Vec<String>> {
|
||||
let texts = [
|
||||
(Field::Revision, &set.revision),
|
||||
(Field::Description, &set.description),
|
||||
(Field::Designer, &set.designer),
|
||||
(Field::Checker, &set.checker),
|
||||
(Field::Date, &set.date),
|
||||
];
|
||||
let mut warnings = Vec::new();
|
||||
for (field, text) in texts {
|
||||
let Some(text) = text else { continue };
|
||||
if let Some(SkippedLetter(letter)) = record.set(id, field, text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
warnings.push(format!("the revisions' lettering skips {letter}: kept as typed"));
|
||||
}
|
||||
}
|
||||
if let Some(hidden) = set.hidden {
|
||||
record.hide(id, hidden).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
fn row<'a>(record: &'a RevisionRecord, id: u64, cmd: &str) -> Result<&'a RevisionRow> {
|
||||
record.rows().iter().find(|r| r.id == id).ok_or_else(|| bad(cmd, format!("no revision row {}", ids::encode(id))))
|
||||
}
|
||||
|
||||
/// `reply` with the drawing's rows and its next revision, as every revision command answers.
|
||||
fn answer(s: &Session, cmd: &str, reply: RevisionsResult) -> Result<Value> {
|
||||
let record = &s.drawing(cmd)?.revisions;
|
||||
let reply = RevisionsResult { rows: rows(record), next: record.next_revision(None), ..reply };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Every row of `record`, the oldest first: what the revision commands and `drawing.inspect` list.
|
||||
pub(super) fn rows(record: &RevisionRecord) -> Vec<RowResult> {
|
||||
let row = |r: &RevisionRow| RowResult {
|
||||
row: ids::encode(r.id),
|
||||
revision: r.revision.clone(),
|
||||
description: r.description.clone(),
|
||||
designer: r.designer.clone(),
|
||||
checker: r.checker.clone(),
|
||||
date: r.date.clone(),
|
||||
hidden: r.hidden,
|
||||
};
|
||||
record.rows().iter().map(row).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_revision_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,176 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::drawing::Drawing;
|
||||
|
||||
use super::*;
|
||||
use crate::schema::tests::conforms;
|
||||
use crate::tests::Folder;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let schema = output_schema_for::<RevisionsResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
reply
|
||||
}
|
||||
|
||||
/// `id` refuses `p`, leaving the drawing and the undo steps as they were: why.
|
||||
fn refused(s: &mut Session, id: &str, p: Value) -> String {
|
||||
let (record, steps) = (s.drawing("test").ok().cloned(), s.undo.len());
|
||||
let message = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("{id} accepted {p}"));
|
||||
assert_eq!((s.drawing("test").ok().cloned(), s.undo.len()), (record, steps), "{id} {p}");
|
||||
message
|
||||
}
|
||||
|
||||
/// A box's drawing, open.
|
||||
fn drawing_session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10})).unwrap();
|
||||
s.execute("drawing.create", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Adds a row with `set`: its id.
|
||||
fn add(s: &mut Session, set: Value) -> String {
|
||||
run(s, ADD, json!({"set": set}))["row"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The rows `drawing.inspect` lists, and the next revision.
|
||||
fn listed(s: &mut Session) -> Value {
|
||||
s.execute("drawing.inspect", &json!({})).unwrap()["revisions"].clone()
|
||||
}
|
||||
|
||||
fn revisions(reply: &Value) -> Vec<&str> {
|
||||
reply["rows"].as_array().unwrap().iter().map(|r| r["revision"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// A new row gets the next revision, after the highest the drawing has had: 1, 2, 3; after a
|
||||
/// revision typed as 5, 6; after deleting row 2, still after the highest. The texts given are the
|
||||
/// row's own; each command is one undo step, and an edit that changes nothing is none.
|
||||
#[test]
|
||||
fn rows_take_the_next_revision_and_keep_what_is_typed() {
|
||||
let mut s = drawing_session();
|
||||
assert_eq!(listed(&mut s), json!({"rows": [], "next": "1"}));
|
||||
let steps = s.undo.len();
|
||||
let first = add(&mut s, json!({"description": "First issue", "designer": "CW", "date": "2026-10-29"}));
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step, texts and all");
|
||||
let second = add(&mut s, json!({}));
|
||||
let third = add(&mut s, json!({}));
|
||||
let shown = listed(&mut s);
|
||||
assert_eq!((revisions(&shown), shown["next"].as_str()), (vec!["1", "2", "3"], Some("4")));
|
||||
assert_eq!(
|
||||
shown["rows"][0],
|
||||
json!({"row": first, "revision": "1", "description": "First issue", "designer": "CW", "checker": "", "date": "2026-10-29", "hidden": false})
|
||||
);
|
||||
let edited = run(&mut s, EDIT, json!({"row": third, "set": {"revision": "5", "checker": "JB", "hidden": true}}));
|
||||
assert_eq!(edited["changed"], json!(["revision", "checker", "hidden"]));
|
||||
assert_eq!(edited["next"], "6");
|
||||
let steps = s.undo.len();
|
||||
let same = run(&mut s, EDIT, json!({"row": third, "set": {"revision": "5"}}));
|
||||
assert!(same.get("changed").is_none() && s.undo.len() == steps, "nothing changed, no step: {same}");
|
||||
let deleted = run(&mut s, DELETE, json!({"row": second}));
|
||||
assert_eq!((revisions(&deleted), deleted["next"].as_str()), (vec!["1", "5"], Some("6")));
|
||||
run(&mut s, DELETE, json!({"row": third}));
|
||||
assert_eq!(listed(&mut s)["next"], "2", "the highest left is 1");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(revisions(&listed(&mut s)), ["1", "2", "5"], "undo puts both back where they were");
|
||||
let record = &s.drawing("test").unwrap().revisions;
|
||||
assert_eq!(record.newest(None).map(|r| r.revision.as_str()), Some("5"), "a hidden row is still the newest");
|
||||
assert_eq!(record.table_rows(None).len(), 2, "but the table leaves it out");
|
||||
}
|
||||
|
||||
/// The rows are saved with the drawing, their ids as every drawing id is written, and read back as
|
||||
/// they were; a drawing with no rows saves no revisions, as one saved before them.
|
||||
#[test]
|
||||
fn rows_are_saved_with_the_drawing() {
|
||||
let folder = Folder::new("drawing-revisions");
|
||||
let mut s = drawing_session();
|
||||
let empty: Value = serde_json::from_str(&s.drawing("test").unwrap().to_json().unwrap()).unwrap();
|
||||
assert!(empty.get("revisions").is_none(), "{empty}");
|
||||
let row = add(&mut s, json!({"description": "Holes added"}));
|
||||
let saved = s.drawing("test").unwrap().to_json().unwrap();
|
||||
let v: Value = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(v["revisions"]["rows"][0]["id"], json!(row));
|
||||
assert!(v["format_pending"].as_array().unwrap().contains(&json!("revisions")), "{}", v["format_pending"]);
|
||||
assert_eq!(Drawing::from_json(&saved).unwrap().revisions, s.drawing("test").unwrap().revisions);
|
||||
// A damaged record is refused as the drawing is read, and one kept per sheet isn't a drawing's yet.
|
||||
for (path, bad) in [("/revisions/rows/0/revision", json!("A\nB")), ("/revisions/per_sheet", json!(true))] {
|
||||
let mut damaged = v.clone();
|
||||
*damaged.pointer_mut(path).unwrap_or_else(|| panic!("{path}")) = bad;
|
||||
assert!(Drawing::from_json(&damaged.to_string()).is_err(), "{path}");
|
||||
}
|
||||
// A record kept per sheet, each row on a sheet of the drawing, keeps its own rules: the drawing refuses it.
|
||||
let mut per_sheet = v.clone();
|
||||
per_sheet["revisions"]["per_sheet"] = json!(true);
|
||||
per_sheet["revisions"]["rows"][0]["sheet"] = v["sheets"][0]["id"].clone();
|
||||
let why = Drawing::from_json(&per_sheet.to_string()).unwrap_err().to_string();
|
||||
assert!(why.contains("one revision record for all its sheets"), "{why}");
|
||||
s.execute("file.save", &json!({"path": folder.path("box.solidcraft")})).unwrap();
|
||||
let mut opened = Session::default();
|
||||
opened.execute("doc.open", &json!({"path": folder.path("box.solidcraft")})).unwrap();
|
||||
assert_eq!(opened.doc.drawings[0].revisions, s.drawing("test").unwrap().revisions);
|
||||
// A row's id is one of the drawing's: no other object may take it, so no new id is it either.
|
||||
let mut d = (**s.drawing("test").unwrap()).clone();
|
||||
Arc::make_mut(&mut d.sheets[0]).id = ids::decode(&row).unwrap();
|
||||
assert!(d.validate().unwrap_err().to_string().contains("two objects have the id"));
|
||||
}
|
||||
|
||||
/// Deleting a row names the revision tags bound to it, which keep the revision they showed.
|
||||
#[test]
|
||||
fn a_deleted_row_names_the_tags_it_leaves() {
|
||||
let mut s = drawing_session();
|
||||
let row = add(&mut s, json!({}));
|
||||
let mut d = (**s.drawing("test").unwrap()).clone();
|
||||
let tag = d.allocate_id(&mut s.ids).unwrap();
|
||||
Arc::make_mut(&mut d.revisions).add_tag(ids::decode(&row).unwrap(), tag).unwrap();
|
||||
s.doc_mut().put_drawing(Arc::new(d));
|
||||
let deleted = run(&mut s, DELETE, json!({"row": row}));
|
||||
assert_eq!(deleted["tags_lost"], json!([ids::encode(tag)]));
|
||||
let record = &s.drawing("test").unwrap().revisions;
|
||||
assert_eq!(record.tags()[0].lost.as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
/// What the revision commands refuse, each with the drawing and the undo steps unchanged.
|
||||
#[test]
|
||||
fn hostile_revision_calls_are_refused() {
|
||||
let mut s = Session::default();
|
||||
assert!(refused(&mut s, ADD, json!({})).contains("open a drawing first"));
|
||||
let mut s = drawing_session();
|
||||
let row = add(&mut s, json!({}));
|
||||
let long = "x".repeat(70_000);
|
||||
for (cmd, p) in [
|
||||
(EDIT, json!({"row": ids::encode(99), "set": {"date": "today"}})),
|
||||
(EDIT, json!({"row": 7, "set": {}})),
|
||||
(EDIT, json!({"row": null, "set": {}})),
|
||||
(EDIT, json!({"row": row})),
|
||||
(EDIT, json!({"row": row, "set": {"revision": "A\nB"}})),
|
||||
(EDIT, json!({"row": row, "set": {"description": "bell \u{7}"}})),
|
||||
(EDIT, json!({"row": row, "set": {"description": long}})),
|
||||
(EDIT, json!({"row": row, "set": {"hidden": "yes"}})),
|
||||
(EDIT, json!({"row": row, "set": {"date": null}})),
|
||||
(EDIT, json!({"row": row, "set": {"colour": "red"}})),
|
||||
(ADD, json!({"set": {"revision": "1\n2"}})),
|
||||
(ADD, json!({"set": null})),
|
||||
(ADD, json!({"row": row})),
|
||||
(DELETE, json!({"row": ids::encode(99)})),
|
||||
(DELETE, json!({})),
|
||||
] {
|
||||
refused(&mut s, cmd, p);
|
||||
}
|
||||
// A record holds at most 1000 rows.
|
||||
for _ in 1..solidcraft_geom::revision::MAX_ROWS {
|
||||
add(&mut s, json!({}));
|
||||
}
|
||||
assert!(refused(&mut s, ADD, json!({})).contains("at most 1000 rows"));
|
||||
}
|
||||
|
||||
/// Sessions seeded alike give rows the same ids.
|
||||
#[test]
|
||||
fn a_rows_id_comes_from_the_sessions_seed() {
|
||||
let made = |seed: u64| {
|
||||
let mut s = drawing_session();
|
||||
s.seed_ids(seed);
|
||||
[add(&mut s, json!({})), add(&mut s, json!({}))]
|
||||
};
|
||||
assert_eq!(made(7), made(7));
|
||||
assert_ne!(made(7), made(8));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `drawing.scene` (#4890 W10): one sheet of the open drawing as one paper scene, drawn at once
|
||||
//! ([`crate::drawing_scene::build`]): its drawing border, then each view on it by id. The sheet
|
||||
//! page draws the same scene through the provider, and the drawing export (A7) writes it.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::ids;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::drawing_scene::CMD;
|
||||
use crate::schema::drawing_scene::{SheetPaperScene, SheetSceneParams, SheetSceneResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CONTRACT: TypedContract<SheetSceneParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Draw Sheet", crate::checked!("drawing.scene", scene))
|
||||
.icon("export")
|
||||
.in_drawing()
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<SheetSceneResult>)
|
||||
.params("sheet: id or name → {sheet, scene: {size_mm, groups: [{object, kind: drawing_border|view, items: [{kind: stroke, path, closed, width_mm} or {kind: fill, region}]}]} in paint order: the sheet's drawing border (object: the sheet), then each view on it by id (object: the view; its visible lines only for now), views: [{view, status: drawn|placeholder|failed, reason?, stats?}]}. A view with nothing to measure, or one that can't be drawn yet, is left out and says why. Read-only; no model is evaluated.")];
|
||||
|
||||
fn scene(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: SheetSceneParams = crate::schema::read(CMD, p)?;
|
||||
let d = s.drawing(CMD)?.clone();
|
||||
let sheet = crate::drawing_scene::sheet_id(&d, &request.sheet, CMD)?;
|
||||
let built = crate::drawing_scene::build(s, &d, sheet)?;
|
||||
let views = built.views.into_iter().map(Into::into).collect();
|
||||
let reply = SheetSceneResult { sheet: ids::encode(sheet), scene: SheetPaperScene(built.scene), views, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! A drawing's sheets (#3875, W18 S4): add, change, reorder and delete them, each one undo step of
|
||||
//! the drawing (none when nothing changes). A sheet is named by its id or, when only one has it,
|
||||
//! its name.
|
||||
//!
|
||||
//! A new sheet takes the paper, border and resolution of the sheet it follows, so a drawing's
|
||||
//! sheets match unless told otherwise. Changing a sheet's paper regrows its border and zones,
|
||||
//! which nothing on it is saved against, and moves nothing (S5): the answer lists the sheet's views
|
||||
//! whose boundary the new paper leaves outside its drawing space. A sheet with anything on it is
|
||||
//! deleted only with what is on it, when asked, in the same undo step.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::drawing::view::ViewKind;
|
||||
use solidcraft_doc::drawing::{Drawing, Excluded, OnSheet, Sheet, SheetComponent};
|
||||
use solidcraft_doc::ids;
|
||||
use solidcraft_geom::drawing_sheet::{Orientation, SheetSize};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_};
|
||||
use crate::schema::drawing_sheet::{SheetAddParams, SheetDeleteParams, SheetName, SheetReorderParams, SheetSetParams, SheetsResult};
|
||||
use crate::schema::drawing_view::OutsideResult;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ADD: TypedContract<SheetAddParams> = TypedContract::new();
|
||||
const SET: TypedContract<SheetSetParams> = TypedContract::new();
|
||||
const REORDER: TypedContract<SheetReorderParams> = TypedContract::new();
|
||||
const DELETE: TypedContract<SheetDeleteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("drawing.sheet.add", "Add Sheet", crate::checked!("drawing.sheet.add", add))
|
||||
.icon("new")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&ADD)
|
||||
.output(output_schema_for::<SheetsResult>)
|
||||
.params(
|
||||
"after?: a sheet (id or name; default the last); name?; size?: {standard: a3} or {custom: {sides_mm: [w, h]}}; \
|
||||
orientation?: landscape|portrait; dpi?: 25..2400 (each defaulting to the sheet it follows); documents?: {source: id, \
|
||||
component: id (0 the design)}; excluded?: {count?, print?} (none unless given) → sheet: the new sheet's id, sheets. \
|
||||
One undo step.",
|
||||
),
|
||||
CommandSpec::new("drawing.sheet.set", "Sheet Settings", crate::checked!("drawing.sheet.set", set))
|
||||
.icon("settings")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&SET)
|
||||
.output(output_schema_for::<SheetsResult>)
|
||||
.params(
|
||||
"sheet: id or name; name?, size?, orientation?, dpi?; documents?: {source: id, component: id (0 the design)} or null; \
|
||||
excluded?: {count?, print?} → the drawing's sheets, and after a change of paper `outside`: the sheet's views whose \
|
||||
boundary leaves its drawing space (none is moved). One undo step, none when nothing changes.",
|
||||
),
|
||||
CommandSpec::new("drawing.sheet.reorder", "Reorder Sheets", crate::checked!("drawing.sheet.reorder", reorder))
|
||||
.icon("reorder")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&REORDER)
|
||||
.output(output_schema_for::<SheetsResult>)
|
||||
.params("sheets: every sheet's id, in the new order → the drawing's sheets. One undo step, none for the same order."),
|
||||
CommandSpec::new("drawing.sheet.delete", "Delete Sheet", crate::checked!("drawing.sheet.delete", delete))
|
||||
.icon("delete")
|
||||
.in_drawing()
|
||||
.doc_only()
|
||||
.contract(&DELETE)
|
||||
.output(output_schema_for::<SheetsResult>)
|
||||
.params(
|
||||
"sheet: id or name (a drawing keeps at least one); contents?: delete, to delete the views on it, their dimensions and \
|
||||
balloons with it (without it a sheet with anything on it is refused, naming what) → the drawing's sheets. One undo step.",
|
||||
),
|
||||
];
|
||||
|
||||
/// The drawing's sheets, as each command answers (`sheet`: the one `drawing.sheet.add` made;
|
||||
/// `outside`: the views `drawing.sheet.set`'s paper leaves outside).
|
||||
fn answer(s: &Session, cmd: &str, sheet: Option<u64>, outside: Vec<OutsideResult>) -> Result<Value> {
|
||||
let sheets = s.drawing(cmd)?.sheets.iter().map(|sheet| SheetName { id: ids::encode(sheet.id), name: sheet.name.clone() }).collect();
|
||||
let reply = SheetsResult { sheet: sheet.map(ids::encode), sheets, outside, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// At most this many ids of a kind in a message.
|
||||
const NAMED: usize = 8;
|
||||
|
||||
/// Ids for a message: the first [`NAMED`], then how many more.
|
||||
fn named(list: &[u64]) -> String {
|
||||
let mut named: Vec<String> = list.iter().take(NAMED).map(|id| format!("`{}`", ids::encode(*id))).collect();
|
||||
if list.len() > NAMED {
|
||||
named.push(format!("{} more", list.len() - NAMED));
|
||||
}
|
||||
named.join(", ")
|
||||
}
|
||||
|
||||
/// What is on a sheet, for a message: `2 views (…), 1 dimension (…) and 3 balloons (…)`.
|
||||
fn listed(on: &OnSheet) -> String {
|
||||
let kind = |what: &str, list: &[u64]| {
|
||||
let s = if list.len() == 1 { "" } else { "s" };
|
||||
(!list.is_empty()).then(|| format!("{} {what}{s} ({})", list.len(), named(list)))
|
||||
};
|
||||
let mut kinds: Vec<String> =
|
||||
[kind("view", &on.views), kind("dimension", &on.dimensions), kind("balloon", &on.balloons)].into_iter().flatten().collect();
|
||||
let last = kinds.pop().unwrap_or_default();
|
||||
if kinds.is_empty() { last } else { format!("{} and {last}", kinds.join(", ")) }
|
||||
}
|
||||
|
||||
/// The position of the sheet `p[key]` names ([`Drawing::sheet_index`]).
|
||||
fn sheet_at(cmd: &str, d: &Drawing, p: &Value, key: &str) -> Result<usize> {
|
||||
let named = str_(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a sheet's id or name")))?;
|
||||
d.sheet_index(named).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The settings `p` gives a sheet, over the ones it has.
|
||||
fn apply(cmd: &str, sheet: &mut Sheet, p: &Value) -> Result<()> {
|
||||
if let Some(name) = p.get("name") {
|
||||
sheet.name = name.as_str().ok_or_else(|| bad(cmd, "`name` must be text"))?.to_string();
|
||||
}
|
||||
if let Some(size) = p.get("size") {
|
||||
sheet.sheet.size = serde_json::from_value::<SheetSize>(size.clone())
|
||||
.map_err(|_| bad(cmd, "`size` is {\"standard\": \"a3\"} or {\"custom\": {\"sides_mm\": [w, h]}}"))?;
|
||||
}
|
||||
if let Some(orientation) = p.get("orientation") {
|
||||
sheet.sheet.orientation =
|
||||
serde_json::from_value::<Orientation>(orientation.clone()).map_err(|_| bad(cmd, "`orientation` is landscape or portrait"))?;
|
||||
}
|
||||
if p.get("dpi").is_some() {
|
||||
sheet.dpi = num(p, "dpi").ok_or_else(|| bad(cmd, "`dpi` must be a number"))?;
|
||||
}
|
||||
match p.get("documents") {
|
||||
None => {}
|
||||
Some(Value::Null) => sheet.documents = None,
|
||||
Some(documents) => {
|
||||
let source = str_(documents, "source").and_then(ids::decode).ok_or_else(|| bad(cmd, "`documents.source` must be a source's id"))?;
|
||||
let component =
|
||||
documents.get("component").map_or(Some(0), Value::as_u64).ok_or_else(|| bad(cmd, "`documents.component` must be a component id"))?;
|
||||
sheet.documents = Some(SheetComponent { source, component });
|
||||
}
|
||||
}
|
||||
if let Some(excluded) = p.get("excluded") {
|
||||
// An object of the two flags: anything else (a mistyped key) would change nothing unseen.
|
||||
let shape = || bad(cmd, "`excluded` is {count?: true|false, print?: true|false}");
|
||||
let excluded = excluded.as_object().filter(|o| o.keys().all(|k| matches!(k.as_str(), "count" | "print"))).ok_or_else(shape)?;
|
||||
let flag = |key: &str, was: bool| match excluded.get(key) {
|
||||
None => Ok(was),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(bad(cmd, format!("`excluded.{key}` is true or false"))),
|
||||
};
|
||||
sheet.excluded = Excluded { count: flag("count", sheet.excluded.count)?, print: flag("print", sheet.excluded.print)? };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.sheet.add";
|
||||
let record = s.drawing(cmd)?.clone();
|
||||
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.edit_drawing(cmd, |d| {
|
||||
let after = if p.get("after").is_some() { sheet_at(cmd, d, p, "after")? } else { d.sheets.len().saturating_sub(1) };
|
||||
let model = d.sheets.get(after).ok_or_else(|| bad(cmd, "the drawing has no sheet to follow"))?;
|
||||
let free = (d.sheets.len() + 1..).map(|n| format!("Sheet {n}")).find(|name| d.sheets.iter().all(|s| s.name != *name)).unwrap_or_default();
|
||||
let mut sheet = Sheet { id, name: free, documents: None, excluded: Excluded::default(), ..(**model).clone() };
|
||||
apply(cmd, &mut sheet, p)?;
|
||||
d.sheets.insert(after + 1, Arc::new(sheet));
|
||||
Ok(())
|
||||
})?;
|
||||
answer(s, cmd, Some(id), Vec::new())
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.sheet.set";
|
||||
let (id, paper) = s.edit_drawing(cmd, |d| {
|
||||
let at = sheet_at(cmd, d, p, "sheet")?;
|
||||
let sheet = Arc::make_mut(d.sheets.get_mut(at).ok_or_else(|| bad(cmd, "no such sheet"))?);
|
||||
let was = sheet.sheet;
|
||||
apply(cmd, sheet, p)?;
|
||||
Ok((sheet.id, sheet.sheet != was))
|
||||
})?;
|
||||
// Only a change of paper moves the drawing space; each view measured once.
|
||||
let d = s.drawing(cmd)?;
|
||||
let on: Vec<u64> = if paper { d.on_sheet(id).views } else { Vec::new() };
|
||||
let outside = super::drawing_view::outside_results(crate::drawing_view::outside(s, d, &on, cmd)?);
|
||||
answer(s, cmd, None, outside)
|
||||
}
|
||||
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.sheet.reorder";
|
||||
s.edit_drawing(cmd, |d| {
|
||||
let every = || bad(cmd, "`sheets` must list every sheet's id once");
|
||||
let order: Vec<u64> = p
|
||||
.get("sheets")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(every)?
|
||||
.iter()
|
||||
.map(|v| v.as_str().and_then(ids::decode))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(every)?;
|
||||
let mut sheets = Vec::with_capacity(order.len());
|
||||
for id in &order {
|
||||
let at = d.sheets.iter().position(|s| s.id == *id).ok_or_else(every)?;
|
||||
sheets.push(d.sheets.remove(at));
|
||||
}
|
||||
if !d.sheets.is_empty() {
|
||||
return Err(every());
|
||||
}
|
||||
d.sheets = sheets;
|
||||
Ok(())
|
||||
})?;
|
||||
answer(s, cmd, None, Vec::new())
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "drawing.sheet.delete";
|
||||
let with_contents = match p.get("contents") {
|
||||
None => false,
|
||||
Some(v) if v.as_str() == Some("delete") => true,
|
||||
Some(_) => return Err(bad(cmd, "`contents` is delete: the sheet goes with everything on it")),
|
||||
};
|
||||
s.edit_drawing(cmd, |d| {
|
||||
let at = sheet_at(cmd, d, p, "sheet")?;
|
||||
if d.sheets.len() <= 1 {
|
||||
return Err(bad(cmd, "a drawing keeps at least one sheet"));
|
||||
}
|
||||
let Some(sheet) = d.sheets.get(at).cloned() else { return Ok(()) };
|
||||
let on = d.on_sheet(sheet.id);
|
||||
if !on.is_empty() && !with_contents {
|
||||
let what = listed(&on);
|
||||
return Err(bad(cmd, format!("sheet `{}` has {what} on it: give `\"contents\": \"delete\"` to delete them with it", sheet.name)));
|
||||
}
|
||||
// Nothing on another sheet goes with it: a view there projected from one of its views would
|
||||
// be left without its parent.
|
||||
let elsewhere: Vec<u64> = d
|
||||
.views
|
||||
.iter()
|
||||
.filter(|v| v.sheet != sheet.id && matches!(v.kind, ViewKind::Projected { parent, .. } if on.views.binary_search(&parent).is_ok()))
|
||||
.map(|v| v.id)
|
||||
.collect();
|
||||
if !elsewhere.is_empty() {
|
||||
let views = named(&elsewhere);
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("sheet `{}`'s views have views projected from them on other sheets ({views}): move or delete those first", sheet.name),
|
||||
));
|
||||
}
|
||||
d.remove_sheet(sheet.id);
|
||||
Ok(())
|
||||
})?;
|
||||
answer(s, cmd, None, Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,214 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::drawing_view::tests::three_views;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, id: &str, p: Value) -> String {
|
||||
s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("{id} accepted {p}"))
|
||||
}
|
||||
|
||||
/// A box's drawing, open, with its one A3 sheet.
|
||||
fn drawing_session() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn sheets(s: &Session) -> Vec<Arc<Sheet>> {
|
||||
s.drawing("test").unwrap().sheets.clone()
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
sheets(s).iter().map(|sheet| sheet.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A new sheet follows the last, or the one named, with its paper, border and resolution and a
|
||||
/// name of its own; each add is one undo step.
|
||||
#[test]
|
||||
fn a_new_sheet_takes_the_paper_of_the_sheet_it_follows() {
|
||||
let mut s = drawing_session();
|
||||
let steps = s.undo.len();
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": "Sheet 1", "dpi": 600, "size": {"standard": "a2"}}));
|
||||
let added = run(&mut s, "drawing.sheet.add", json!({}));
|
||||
let all = sheets(&s);
|
||||
assert_eq!(added["sheet"], json!(ids::encode(all[1].id)));
|
||||
assert_eq!((all[1].name.as_str(), all[1].dpi, all[1].sheet), ("Sheet 2", 600.0, all[0].sheet));
|
||||
assert_eq!(all[1].border, all[0].border);
|
||||
run(&mut s, "drawing.sheet.add", json!({"after": "Sheet 1", "name": "Details", "orientation": "portrait"}));
|
||||
assert_eq!(names(&s), ["Sheet 1", "Details", "Sheet 2"]);
|
||||
assert_eq!(sheets(&s)[1].sheet.orientation, Orientation::Portrait);
|
||||
assert_eq!(s.undo.len(), steps + 3);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["Sheet 1", "Sheet 2"]);
|
||||
// After a sheet by id; an unknown one is refused.
|
||||
let first = ids::encode(sheets(&s)[0].id);
|
||||
run(&mut s, "drawing.sheet.add", json!({"after": first, "name": "Second"}));
|
||||
assert_eq!(names(&s), ["Sheet 1", "Second", "Sheet 2"]);
|
||||
assert!(refused(&mut s, "drawing.sheet.add", json!({"after": "Nowhere"})).contains("no sheet `Nowhere`"));
|
||||
// A new sheet documents nothing and leaves nothing out unless told, whatever the one it follows does.
|
||||
let source = ids::encode(s.drawing("test").unwrap().sources[0].id);
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": "Sheet 2", "documents": {"source": source}, "excluded": {"count": true}}));
|
||||
run(&mut s, "drawing.sheet.add", json!({"after": "Sheet 2"}));
|
||||
let added = sheets(&s).last().cloned().unwrap();
|
||||
assert_eq!((added.documents, added.excluded.count, added.excluded.print), (None, false, false));
|
||||
let told = json!({"documents": {"source": source, "component": 0}, "excluded": {"print": true}});
|
||||
let id = run(&mut s, "drawing.sheet.add", told)["sheet"].clone();
|
||||
let added = sheets(&s).into_iter().find(|sheet| json!(ids::encode(sheet.id)) == id).unwrap();
|
||||
assert!(added.documents.is_some() && added.excluded.print && !added.excluded.count);
|
||||
}
|
||||
|
||||
/// A sheet's id comes from the session's id source: two sessions seeded alike and run alike give
|
||||
/// the same ids (where a crash replay starts), another seed others, and a failed add draws none.
|
||||
#[test]
|
||||
fn sheet_ids_come_from_the_sessions_seed() {
|
||||
let made = |seed: u64, fail: bool| {
|
||||
let mut s = Session::default();
|
||||
s.seed_ids(seed);
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
if fail {
|
||||
refused(&mut s, "drawing.sheet.add", json!({"after": "Nowhere"}));
|
||||
}
|
||||
run(&mut s, "drawing.sheet.add", json!({}));
|
||||
let record = s.drawing("test").unwrap().clone();
|
||||
record.sheets.iter().map(|sheet| sheet.id).chain(record.sources.iter().map(|source| source.id)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(made(7, false), made(7, false));
|
||||
assert_eq!(made(7, true), made(7, false), "the failed add drew none");
|
||||
assert_ne!(made(7, false), made(8, false));
|
||||
}
|
||||
|
||||
/// 1000 sheets at most: the 1000th is added, the 1001st refused with nothing changed.
|
||||
#[test]
|
||||
fn a_drawing_has_at_most_1000_sheets() {
|
||||
let mut s = drawing_session();
|
||||
for _ in 1..solidcraft_doc::drawing::MAX_SHEETS {
|
||||
run(&mut s, "drawing.sheet.add", json!({}));
|
||||
}
|
||||
assert_eq!(sheets(&s).len(), 1000);
|
||||
let before = s.drawing("test").unwrap().clone();
|
||||
assert!(refused(&mut s, "drawing.sheet.add", json!({})).contains("1 to 1000 sheets"));
|
||||
assert!(Arc::ptr_eq(s.drawing("test").unwrap(), &before));
|
||||
}
|
||||
|
||||
/// A sheet's settings change one at a time, the rest kept; what the sheet's rules refuse is
|
||||
/// refused with the drawing unchanged.
|
||||
#[test]
|
||||
fn a_sheets_settings_change_and_keep_its_rules() {
|
||||
let mut s = drawing_session();
|
||||
let source = ids::encode(s.drawing("test").unwrap().sources[0].id);
|
||||
let id = ids::encode(sheets(&s)[0].id);
|
||||
run(
|
||||
&mut s,
|
||||
"drawing.sheet.set",
|
||||
json!({"sheet": id, "name": "Assembly", "documents": {"source": source, "component": 0}, "excluded": {"print": true}}),
|
||||
);
|
||||
let sheet = sheets(&s)[0].clone();
|
||||
assert_eq!(sheet.name, "Assembly");
|
||||
assert_eq!(sheet.documents.map(|d| d.component), Some(0));
|
||||
assert_eq!((sheet.excluded.count, sheet.excluded.print), (false, true));
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": "Assembly", "excluded": {"count": true}, "documents": null}));
|
||||
let sheet = sheets(&s)[0].clone();
|
||||
assert_eq!((sheet.excluded.count, sheet.excluded.print, sheet.documents), (true, true, None), "the other flag kept");
|
||||
let before = s.drawing("test").unwrap().clone();
|
||||
for (p, says) in [
|
||||
(json!({"sheet": "Assembly", "dpi": 24}), "dpi"),
|
||||
(json!({"sheet": "Assembly", "size": {"standard": "b7"}}), "`size`"),
|
||||
(json!({"sheet": "Assembly", "size": {"custom": {"sides_mm": [0, 100]}}}), "a custom side is finite, above 0 and at most 5000 mm"),
|
||||
(json!({"sheet": "Assembly", "size": {"custom": {"sides_mm": [15, 15]}}}), "the margins leave no drawing space"),
|
||||
(json!({"sheet": "Assembly", "dpi": 2400.001}), "dpi"),
|
||||
(json!({"sheet": "Assembly", "excluded": true}), "`excluded` is {count?"),
|
||||
(json!({"sheet": "Assembly", "excluded": {"cuont": true}}), "`excluded` is {count?"),
|
||||
(json!({"sheet": "Assembly", "orientation": "upright"}), "landscape or portrait"),
|
||||
(json!({"sheet": "Assembly", "name": ""}), "name"),
|
||||
(json!({"sheet": "Assembly", "documents": {"source": "0000000000001"}}), "documents source"),
|
||||
(json!({"sheet": "Assembly", "excluded": {"count": "yes"}}), "true or false"),
|
||||
(json!({"sheet": "Nowhere"}), "no sheet `Nowhere`"),
|
||||
(json!({}), "`sheet`"),
|
||||
] {
|
||||
let e = refused(&mut s, "drawing.sheet.set", p.clone());
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert!(Arc::ptr_eq(s.drawing("test").unwrap(), &before), "{p}");
|
||||
}
|
||||
// The resolution's bounds are in; the same settings again are no step; a name two sheets
|
||||
// share is allowed, and then the sheet is named by id.
|
||||
let steps = s.undo.len();
|
||||
for dpi in [25.0, 2400.0] {
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": "Assembly", "dpi": dpi}));
|
||||
assert_eq!(sheets(&s)[0].dpi, dpi);
|
||||
}
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": "Assembly", "dpi": 2400}));
|
||||
assert_eq!(s.undo.len(), steps + 2, "no change, no step");
|
||||
run(&mut s, "drawing.sheet.add", json!({}));
|
||||
let second = ids::encode(sheets(&s)[1].id);
|
||||
run(&mut s, "drawing.sheet.set", json!({"sheet": second, "name": "Assembly"}));
|
||||
assert!(refused(&mut s, "drawing.sheet.set", json!({"sheet": "Assembly", "dpi": 300})).contains("give its id"));
|
||||
}
|
||||
|
||||
/// Sheets reorder by every id once, a name shared by two is refused in favour of the id, and the
|
||||
/// last sheet can't be deleted.
|
||||
#[test]
|
||||
fn sheets_reorder_and_delete_but_one_always_stays() {
|
||||
let mut s = drawing_session();
|
||||
run(&mut s, "drawing.sheet.add", json!({}));
|
||||
run(&mut s, "drawing.sheet.add", json!({"name": "Sheet 1"}));
|
||||
let ids: Vec<String> = sheets(&s).iter().map(|sheet| ids::encode(sheet.id)).collect();
|
||||
assert!(refused(&mut s, "drawing.sheet.delete", json!({"sheet": "Sheet 1"})).contains("give its id"));
|
||||
let steps = s.undo.len();
|
||||
run(&mut s, "drawing.sheet.reorder", json!({"sheets": [ids[0], ids[1], ids[2]]}));
|
||||
assert_eq!(s.undo.len(), steps, "the same order is no step");
|
||||
run(&mut s, "drawing.sheet.reorder", json!({"sheets": [ids[2], ids[0], ids[1]]}));
|
||||
assert_eq!(sheets(&s).iter().map(|sheet| ids::encode(sheet.id)).collect::<Vec<_>>(), [ids[2].clone(), ids[0].clone(), ids[1].clone()]);
|
||||
for order in [json!([ids[0], ids[1]]), json!([ids[0], ids[0], ids[1]]), json!([ids[0], ids[1], ids[2], "0000000000001"]), json!("all")] {
|
||||
let e = refused(&mut s, "drawing.sheet.reorder", json!({"sheets": order}));
|
||||
assert!(e.contains("every sheet's id once"), "{order}: {e}");
|
||||
}
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": ids[0]}));
|
||||
// An undone delete puts the sheet back where it was.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(sheets(&s).iter().map(|sheet| ids::encode(sheet.id)).collect::<Vec<_>>(), [ids[2].clone(), ids[0].clone(), ids[1].clone()]);
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": ids[0]}));
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": ids[1]}));
|
||||
assert!(refused(&mut s, "drawing.sheet.delete", json!({"sheet": ids[2]})).contains("at least one sheet"));
|
||||
assert_eq!(sheets(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// A sheet with views on it is refused, naming them, unless `contents` is `delete`: then it goes
|
||||
/// with them in one undo step, which an undo brings back whole. Nothing on another sheet goes with
|
||||
/// it, so a view there projected from one of its views is refused by name.
|
||||
#[test]
|
||||
fn a_sheet_with_views_on_it_goes_only_with_them() {
|
||||
let (mut s, views) = three_views();
|
||||
let [_, _, top] = views;
|
||||
let first = ids::encode(sheets(&s)[0].id);
|
||||
let second = run(&mut s, "drawing.sheet.add", json!({}))["sheet"].clone();
|
||||
let e = refused(&mut s, "drawing.sheet.delete", json!({"sheet": first}));
|
||||
assert!(e.contains("has 3 views (") && views.iter().all(|v| e.contains(&ids::encode(*v))) && e.contains(r#""contents": "delete""#), "{e}");
|
||||
for contents in [json!("keep"), json!(true), json!(null)] {
|
||||
let e = refused(&mut s, "drawing.sheet.delete", json!({"sheet": first, "contents": contents}));
|
||||
assert!(e.contains("`contents` is delete"), "{contents}: {e}");
|
||||
}
|
||||
// The top view, placed apart, on the second sheet: its parent is on the first.
|
||||
run(&mut s, "drawing.view.move_to_sheet", json!({"view": ids::encode(top), "sheet": second}));
|
||||
let e = refused(&mut s, "drawing.sheet.delete", json!({"sheet": first, "contents": "delete"}));
|
||||
assert!(e.contains(&ids::encode(top)) && e.contains("projected"), "{e}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let steps = s.undo.len();
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": first, "contents": "delete"}));
|
||||
assert_eq!(s.undo.len(), steps + 1);
|
||||
let d = s.drawing("test").unwrap();
|
||||
assert_eq!((d.sheets.len(), d.views.len()), (1, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let mut every = views.to_vec();
|
||||
every.sort_unstable();
|
||||
let d = s.drawing("test").unwrap();
|
||||
assert_eq!(ids::encode(d.sheets[0].id), first, "back in its place");
|
||||
assert_eq!(d.views.iter().map(|v| v.id).collect::<Vec<_>>(), every, "with its views");
|
||||
// An empty sheet goes without being asked twice.
|
||||
run(&mut s, "drawing.sheet.delete", json!({"sheet": second}));
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
use super::*;
|
||||
use crate::cmd::{Part, command_specs, find_command};
|
||||
use crate::tests::Folder;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, id: &str, p: Value) -> String {
|
||||
s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("{id} accepted {p}"))
|
||||
}
|
||||
|
||||
/// A design with a box.
|
||||
fn design() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The drawing open, as the design holds it.
|
||||
fn open_one(s: &Session) -> Arc<Drawing> {
|
||||
s.drawing("test").unwrap().clone()
|
||||
}
|
||||
|
||||
/// The params naming drawing `id`.
|
||||
fn the(id: u64) -> Value {
|
||||
json!({"drawing": ids::encode(id)})
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<&str> {
|
||||
s.doc.drawings.iter().map(|d| d.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// A drawing of the design: one A3 sheet with the ISO border, the design its one source, kept in
|
||||
/// the design and opened. Making it is one step of the model, and evaluates nothing.
|
||||
#[test]
|
||||
fn a_drawing_of_the_design_is_kept_in_it_and_opened() {
|
||||
let mut s = design();
|
||||
let (undo, evaluations, features) = (s.undo.len(), s.evaluations, s.doc.features.clone());
|
||||
let made = run(&mut s, "drawing.create", json!({"name": "Plate drawing", "units": "mm"}));
|
||||
assert_eq!((made["name"].as_str(), made["language"].as_str()), (Some("Plate drawing"), Some("en")));
|
||||
assert_eq!(made["units"], json!({"fixed": {"unit": "mm"}}));
|
||||
let sheet = &made["sheets"][0];
|
||||
assert_eq!(
|
||||
(sheet["size_mm"].clone(), sheet["orientation"].clone(), sheet["dpi"].clone()),
|
||||
(json!([420.0, 297.0]), json!("landscape"), json!(300.0))
|
||||
);
|
||||
assert_eq!((made["sheets"].as_array().map(Vec::len), made["sources"].as_array().map(Vec::len)), (Some(1), Some(1)));
|
||||
let d = open_one(&s);
|
||||
assert_eq!(made["id"], json!(ids::encode(d.id)));
|
||||
assert!(d.sheets[0].border.is_some(), "the ISO border");
|
||||
assert_eq!((s.doc.drawings.len(), s.open_drawing), (1, Some(d.id)));
|
||||
assert_eq!((s.undo.len(), s.undo.last().map(|u| u.part)), (undo + 1, Some(None)), "a step of the model");
|
||||
assert_eq!((s.evaluations, &s.doc.features), (evaluations, &features), "nothing evaluated, the model as it was");
|
||||
assert!(s.is_dirty());
|
||||
assert_eq!(run(&mut s, "drawing.inspect", json!({})), made);
|
||||
let listed = run(&mut s, "drawing.list", json!({}));
|
||||
assert_eq!(listed, json!({"drawings": [{"id": made["id"], "name": "Plate drawing", "sheets": 1, "open": true}]}));
|
||||
// Its name is the design's unless given.
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
assert_eq!(open_one(&s).name, s.doc.name);
|
||||
}
|
||||
|
||||
/// A drawing's ids come from the session's id source: two sessions seeded alike make a drawing
|
||||
/// with the same ids (where a crash replay starts), another seed other ids. A file change keeps
|
||||
/// the source, so the same holds for a drawing made after `file.new`.
|
||||
#[test]
|
||||
fn drawing_ids_come_from_the_sessions_seed() {
|
||||
let ids = |seed: u64, new_file: bool| {
|
||||
let mut s = design();
|
||||
s.seed_ids(seed);
|
||||
if new_file {
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
|
||||
}
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let d = open_one(&s);
|
||||
(d.id, d.sheets[0].id, d.sources[0].id)
|
||||
};
|
||||
assert_eq!(ids(7, false), ids(7, false));
|
||||
assert_ne!(ids(7, false), ids(8, false));
|
||||
assert_eq!(ids(7, true), ids(7, true), "the source kept across the file change");
|
||||
assert_ne!(ids(7, true), ids(8, true));
|
||||
}
|
||||
|
||||
/// What making a drawing refuses, the design unchanged: an empty design, units, a language and a
|
||||
/// name the record can't take, and a design that holds the most drawings already.
|
||||
#[test]
|
||||
fn a_drawing_is_made_only_of_a_design_with_something_in_it() {
|
||||
assert!(refused(&mut Session::default(), "drawing.create", json!({})).contains("nothing to draw"));
|
||||
let mut s = design();
|
||||
let before = s.doc.clone();
|
||||
for (p, says) in [
|
||||
(json!({"units": "parsec"}), "length unit"),
|
||||
(json!({"units": 3}), "follow_model"),
|
||||
(json!({"language": "e"}), "BCP 47"),
|
||||
(json!({"name": " "}), "name"),
|
||||
(json!({"name": 7}), "must be text"),
|
||||
] {
|
||||
let e = refused(&mut s, "drawing.create", p.clone());
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert!(Arc::ptr_eq(&s.doc, &before) && s.open_drawing.is_none() && s.undo.len() == 1, "{p}: nothing changed");
|
||||
}
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
let one = s.doc.drawings[0].clone();
|
||||
s.doc_mut().drawings = (1..=drawing::MAX_DRAWINGS as u64).map(|id| Arc::new(Drawing { id, ..(*one).clone() })).collect();
|
||||
let full = s.doc.clone();
|
||||
assert!(refused(&mut s, "drawing.create", json!({})).contains("at most 1000 drawings"));
|
||||
assert!(Arc::ptr_eq(&s.doc, &full));
|
||||
}
|
||||
|
||||
/// While a drawing is open the model's commands wait, and the drawing's run only while one is:
|
||||
/// running a command, running it inside another and listing it all say so.
|
||||
#[test]
|
||||
fn model_and_drawing_commands_each_run_on_their_own_part() {
|
||||
let mut s = design();
|
||||
for id in ["drawing.inspect", "drawing.set", "drawing.properties", "drawing.properties.set", "drawing.properties.remove"] {
|
||||
assert!(refused(&mut s, id, json!({"name": "X", "key": "author", "value": "X"})).contains("open a drawing first"), "{id}");
|
||||
}
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let doc = s.doc.clone();
|
||||
let boxed = json!({"length": 5, "width": 5, "height": 5});
|
||||
let e = refused(&mut s, "solid.box", boxed.clone());
|
||||
assert!(e.contains("a drawing is open: close it to change the model (drawing.close)"), "{e}");
|
||||
assert!(find_command("solid.box").unwrap().run_nested(&mut s, &boxed).is_err(), "nor inside another command");
|
||||
assert!(Arc::ptr_eq(&s.doc, &doc));
|
||||
let enabled = |s: &mut Session, id: &str| {
|
||||
let listed = run(s, "engine.commands", json!({}));
|
||||
listed.as_array().unwrap().iter().find(|c| c["id"] == id).map(|c| c["enabled"] == true).unwrap()
|
||||
};
|
||||
assert!(!enabled(&mut s, "solid.box") && enabled(&mut s, "drawing.set"));
|
||||
for id in ["edit.undo", "file.save", "file.new", "doc.open", "select.clear", "edit.checkpoint", "drawing.list", "drawing.close"] {
|
||||
assert!(find_command(id).unwrap().allowed(&s).is_ok(), "{id} runs with a drawing open");
|
||||
}
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
assert!(enabled(&mut s, "solid.box") && !enabled(&mut s, "drawing.set"));
|
||||
run(&mut s, "solid.box", boxed);
|
||||
// A new design or another opened has no drawing open.
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(s.open_drawing.is_none() && s.doc.drawings.is_empty());
|
||||
}
|
||||
|
||||
/// Each change of a drawing is one undo step of that drawing: undo and redo bring back the same
|
||||
/// record, and a change to the same values is no step. No model is evaluated, and the design
|
||||
/// changes, so whatever follows it sees each change.
|
||||
#[test]
|
||||
fn drawing_edits_are_undo_steps_of_the_drawing_that_evaluate_nothing() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let (first, evaluations, features, steps) = (open_one(&s), s.evaluations, s.doc.features.clone(), s.undo.len());
|
||||
let (doc, revision) = (s.doc.clone(), s.revision);
|
||||
run(&mut s, "drawing.set", json!({"name": "Renamed", "language": "de", "units": "in"}));
|
||||
let renamed = open_one(&s);
|
||||
assert_eq!((renamed.name.as_str(), renamed.language.as_str()), ("Renamed", "de"));
|
||||
assert_eq!(renamed.units, Units::Fixed { unit: "in".into() });
|
||||
assert!(!Arc::ptr_eq(&s.doc, &doc) && s.revision > revision, "the design changed");
|
||||
assert_eq!((s.undo.len(), s.undo.last().map(|u| u.part)), (steps + 1, Some(Some(first.id))), "a step of the drawing");
|
||||
run(&mut s, "drawing.set", json!({"name": "Renamed"}));
|
||||
assert_eq!(s.undo.len(), steps + 1, "the same value again is no step");
|
||||
for _ in 0..100 {
|
||||
assert_eq!(run(&mut s, "edit.undo", json!({}))["undone"], "Drawing Settings");
|
||||
assert!(Arc::ptr_eq(&open_one(&s), &first));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(Arc::ptr_eq(&open_one(&s), &renamed));
|
||||
}
|
||||
assert_eq!((s.evaluations, &s.doc.features), (evaluations, &features), "no model evaluated or changed");
|
||||
}
|
||||
|
||||
/// A drawing's projection is first or third angle, third when nothing chooses: shown by
|
||||
/// `drawing.inspect`, changed by `drawing.set` as one undo step, saved with the design, and
|
||||
/// anything else refused.
|
||||
#[test]
|
||||
fn a_drawings_projection_is_first_or_third_angle() {
|
||||
let folder = Folder::new("drawing-projection");
|
||||
let mut s = design();
|
||||
let made = run(&mut s, "drawing.create", json!({}));
|
||||
assert_eq!(made["projection"], "third");
|
||||
let steps = s.undo.len();
|
||||
assert_eq!(run(&mut s, "drawing.set", json!({"projection": "first"}))["projection"], "first");
|
||||
assert_eq!(s.undo.len(), steps + 1);
|
||||
run(&mut s, "file.save", json!({"path": folder.path("plate.solidcraft")}));
|
||||
let mut opened = Session::default();
|
||||
run(&mut opened, "doc.open", json!({"path": folder.path("plate.solidcraft")}));
|
||||
assert_eq!(opened.doc.drawings[0].projection, Projection::First);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "drawing.inspect", json!({}))["projection"], "third");
|
||||
for bad in [json!("second"), json!("First"), json!(1), json!(null)] {
|
||||
assert!(refused(&mut s, "drawing.set", json!({"projection": bad})).contains("first or third"), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and redo go through the open part's steps only. With a drawing open, its own steps; the
|
||||
/// model's stay where they are. With none open, the model's steps, which put the design back as
|
||||
/// it was but keep each drawing it had as it is now.
|
||||
#[test]
|
||||
fn undo_and_redo_go_through_one_part_at_a_time() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({"name": "A"}));
|
||||
let id = open_one(&s).id;
|
||||
run(&mut s, "drawing.set", json!({"name": "B"}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
let two = s.doc.features.len();
|
||||
run(&mut s, "drawing.open", the(id));
|
||||
run(&mut s, "drawing.set", json!({"name": "C"}));
|
||||
// In the drawing: C, B, A; the model's box stays.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((open_one(&s).name.as_str(), s.doc.features.len()), ("A", two));
|
||||
assert!(refused(&mut s, "edit.undo", json!({})).contains("nothing to undo in this drawing (close it to undo in the model)"));
|
||||
// In the model: the box goes and the drawing stays as it is now (A, where the step had B);
|
||||
// then the drawing goes.
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
let evaluations = s.evaluations;
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((s.doc.features.len(), names(&s)), (two - 1, vec!["A"]));
|
||||
assert!(s.evaluations > evaluations, "the model's step evaluates the model");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.drawings.is_empty());
|
||||
// Redo: the drawing as it was when it went, then the box, the drawing kept.
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!((names(&s), s.open_drawing), (vec!["A"], None));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!((s.doc.features.len(), names(&s)), (two, vec!["A"]));
|
||||
assert_eq!(refused(&mut s, "edit.redo", json!({})), "nothing to redo");
|
||||
// The drawing's own redo steps were kept all along.
|
||||
run(&mut s, "drawing.open", the(id));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(open_one(&s).name, "C");
|
||||
assert!(refused(&mut s, "edit.redo", json!({})).contains("nothing to redo in this drawing"));
|
||||
}
|
||||
|
||||
/// A new step ends what could be redone in its own part only: the others' redo steps still hold.
|
||||
#[test]
|
||||
fn a_new_step_ends_what_could_be_redone_in_its_part_only() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({"name": "A"}));
|
||||
let id = open_one(&s).id;
|
||||
run(&mut s, "drawing.set", json!({"name": "B"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
run(&mut s, "drawing.open", the(id));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(open_one(&s).name, "B", "a model step leaves the drawing's redo");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "drawing.set", json!({"name": "Z"}));
|
||||
assert!(refused(&mut s, "edit.redo", json!({})).contains("nothing to redo in this drawing"), "a drawing step ends its own");
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
let features = s.doc.features.len();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "drawing.open", the(id));
|
||||
run(&mut s, "drawing.set", json!({"name": "Q"}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!((s.doc.features.len(), names(&s)), (features, vec!["Q"]), "a drawing step leaves the model's redo");
|
||||
}
|
||||
|
||||
/// A design lists its drawings by name, then id, whatever order they were made, renamed or
|
||||
/// undone in.
|
||||
#[test]
|
||||
fn a_designs_drawings_are_listed_by_name_then_id() {
|
||||
let mut s = design();
|
||||
for name in ["Zeta", "Alpha", "Mid", "Alpha"] {
|
||||
run(&mut s, "drawing.create", json!({"name": name}));
|
||||
}
|
||||
assert_eq!(names(&s), ["Alpha", "Alpha", "Mid", "Zeta"]);
|
||||
assert!(s.doc.drawings[0].id < s.doc.drawings[1].id);
|
||||
let first = s.doc.drawings[0].id;
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
run(&mut s, "drawing.open", the(first));
|
||||
run(&mut s, "drawing.set", json!({"name": "Zz"}));
|
||||
assert_eq!(names(&s), ["Alpha", "Mid", "Zeta", "Zz"]);
|
||||
let listed = run(&mut s, "drawing.list", json!({}));
|
||||
let open: Vec<bool> = listed["drawings"].as_array().unwrap().iter().map(|d| d["open"] == true).collect();
|
||||
assert_eq!(open, [false, false, false, true]);
|
||||
// The model's step, made before the rename, keeps the drawing renamed and in its place.
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["Alpha", "Mid", "Zeta", "Zz"]);
|
||||
run(&mut s, "drawing.open", the(first));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["Alpha", "Alpha", "Mid", "Zeta"]);
|
||||
}
|
||||
|
||||
/// Deleting a drawing is a step of the model: it closes it when it was open, and undo brings it
|
||||
/// back as it was, closed. What isn't a drawing of the design is refused.
|
||||
#[test]
|
||||
fn deleting_a_drawing_is_a_step_of_the_model() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({"name": "A"}));
|
||||
let a = open_one(&s);
|
||||
run(&mut s, "drawing.create", json!({"name": "B"}));
|
||||
let b = open_one(&s);
|
||||
let doc = s.doc.clone();
|
||||
for (p, says) in [(json!({}), "must be a drawing's id"), (json!({"drawing": 7}), "must be a drawing's id"), (the(1), "has no drawing")] {
|
||||
let e = refused(&mut s, "drawing.delete", p.clone());
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert!(Arc::ptr_eq(&s.doc, &doc) && s.open_drawing == Some(b.id));
|
||||
}
|
||||
assert_eq!(run(&mut s, "drawing.delete", the(b.id)), json!({"deleted": ids::encode(b.id)}));
|
||||
assert_eq!((names(&s), s.open_drawing), (vec!["A"], None));
|
||||
run(&mut s, "drawing.delete", the(a.id));
|
||||
assert!(s.doc.drawings.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&s.doc.drawings[0], &a) && Arc::ptr_eq(&s.doc.drawings[1], &b), "back as they were");
|
||||
assert_eq!(s.open_drawing, None);
|
||||
assert!(refused(&mut s, "drawing.open", the(1)).contains("has no drawing"));
|
||||
}
|
||||
|
||||
/// A refused change leaves the drawing as it was, with no step, and a refused opening leaves the
|
||||
/// drawing open as it was.
|
||||
#[test]
|
||||
fn a_refused_change_leaves_the_drawing_as_it_was() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let (before, steps) = (s.doc.clone(), s.undo.len());
|
||||
for p in [json!({"language": "e"}), json!({"name": ""}), json!({"units": "kg"}), json!({"name": "x".repeat(300)})] {
|
||||
refused(&mut s, "drawing.set", p.clone());
|
||||
assert!(Arc::ptr_eq(&s.doc, &before) && s.undo.len() == steps, "{p}");
|
||||
}
|
||||
let open = s.open_drawing;
|
||||
refused(&mut s, "drawing.open", json!({"drawing": "AAAAAAAAAAAAA"}));
|
||||
assert_eq!(s.open_drawing, open);
|
||||
}
|
||||
|
||||
/// A checkpoint brings back the design's drawings, the one open and the undo history. Going back
|
||||
/// over a change of the model and of a drawing evaluates the model: it is no drawing-only change.
|
||||
#[test]
|
||||
fn a_checkpoint_brings_back_the_drawings_and_the_one_open() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({"name": "A"}));
|
||||
let a = open_one(&s);
|
||||
let (checkpoint, steps, features) = (run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].clone(), s.undo.len(), s.doc.features.clone());
|
||||
run(&mut s, "drawing.set", json!({"name": "B"}));
|
||||
run(&mut s, "drawing.close", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "drawing.delete", the(a.id));
|
||||
run(&mut s, "drawing.create", json!({"name": "C"}));
|
||||
let evaluations = s.evaluations;
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": checkpoint}));
|
||||
assert!(s.doc.drawings.len() == 1 && Arc::ptr_eq(&s.doc.drawings[0], &a));
|
||||
assert_eq!((s.open_drawing, s.undo.len(), &s.doc.features), (Some(a.id), steps, &features));
|
||||
assert!(s.evaluations > evaluations, "the model changed too, so it is evaluated");
|
||||
}
|
||||
|
||||
/// A design's drawings are saved in its file and come back with it, as a container and as plain
|
||||
/// JSON, with a drawing open when it is saved; a design opened has none open.
|
||||
#[test]
|
||||
fn a_designs_drawings_are_saved_with_it_and_come_back() {
|
||||
let folder = Folder::new("drawing-saved");
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({"name": "A", "language": "en-GB"}));
|
||||
run(&mut s, "drawing.properties.set", json!({"values": {"author": "Jane Doe", "com.acme.shop": {"shop_id": 88}}}));
|
||||
run(&mut s, "drawing.create", json!({"name": "B", "units": "mm"}));
|
||||
let drawings = s.doc.drawings.clone();
|
||||
for file in ["plate.solidcraft", "plate.json"] {
|
||||
run(&mut s, "file.save", json!({"path": folder.path(file)}));
|
||||
assert!(!s.is_dirty(), "{file}");
|
||||
let mut opened = Session::default();
|
||||
run(&mut opened, "doc.open", json!({"path": folder.path(file)}));
|
||||
assert_eq!(opened.doc.drawings, drawings, "{file}");
|
||||
assert!(opened.open_drawing.is_none(), "{file}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing's properties follow the design properties' rules: set, read and removed, each change
|
||||
/// one undo step, other tools' keys kept, and what the design's would refuse refused.
|
||||
#[test]
|
||||
fn a_drawings_properties_follow_the_design_properties_rules() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
let steps = s.undo.len();
|
||||
let set = run(&mut s, "drawing.properties.set", json!({"values": {"author": "Jane Doe", "com.acme.shop": {"shop_id": 88}}}));
|
||||
assert_eq!(set["changed"], json!(["author", "com.acme.shop"]));
|
||||
assert_eq!(s.undo.len(), steps + 1);
|
||||
let read = run(&mut s, "drawing.properties", json!({}));
|
||||
assert_eq!((read["properties"]["author"].clone(), read["metadata"]["com.acme.shop"]["shop_id"].clone()), (json!("Jane Doe"), json!(88)));
|
||||
assert_eq!(read["properties"]["revision"], Value::Null);
|
||||
run(&mut s, "drawing.properties.set", json!({"key": "author", "value": "Jane Doe"}));
|
||||
assert_eq!(s.undo.len(), steps + 1, "the same value again is no change");
|
||||
let removed = run(&mut s, "drawing.properties.remove", json!({"keys": ["author", "missing"]}));
|
||||
assert_eq!(removed["removed"], json!(["author"]));
|
||||
assert_eq!((open_one(&s).properties.text("author"), s.undo.len()), (None, steps + 2));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(open_one(&s).properties.text("author"), Some("Jane Doe"));
|
||||
assert!(s.doc.metadata.text("author").is_none(), "the design's own properties untouched");
|
||||
let before = open_one(&s);
|
||||
for (id, p, says) in [
|
||||
("drawing.properties.set", json!({"key": "bom_type", "value": "purchased"}), "component.metadata.set"),
|
||||
("drawing.properties.set", json!({"key": "author"}), "`value` is required"),
|
||||
("drawing.properties.set", json!({"values": {"author": 7}}), "author"),
|
||||
("drawing.properties.remove", json!({"keys": [1]}), "list of keys"),
|
||||
] {
|
||||
let e = refused(&mut s, id, p.clone());
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert!(Arc::ptr_eq(&open_one(&s), &before));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile values for each key of every command that runs with a drawing open, from the registry,
|
||||
/// and as the whole params, each on a copy of one session with a drawing open (the shared pass
|
||||
/// runs with none, where the part gate refuses them): an error or a result, never a panic, and the
|
||||
/// design's drawings and the one open still keeping their rules. As in the shared pass, what
|
||||
/// writes files is left to its own tests; the recovery commands never read a drawing.
|
||||
#[test]
|
||||
fn hostile_drawing_params_never_panic() {
|
||||
let mut s = design();
|
||||
run(&mut s, "drawing.create", json!({}));
|
||||
run(&mut s, "drawing.create", json!({"name": "Second"}));
|
||||
assert_eq!(s.scratch().open_drawing, s.open_drawing, "each copy has the drawing open");
|
||||
let keys = ["name", "language", "units", "drawing", "key", "value", "values", "keys", "checkpoint", "id"];
|
||||
let skipped = |id: &str| matches!(id, "doc.open" | "file.save" | "file.save_as") || id.starts_with("doc.recover");
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs().iter().filter(|spec| spec.part != Part::Model && !skipped(spec.id)) {
|
||||
for value in crate::tests::hostile_values() {
|
||||
for p in keys.iter().map(|key| json!({ *key: value.clone() })).chain([value.clone()]) {
|
||||
let mut copy = s.scratch();
|
||||
if let Err(crate::EngineError::Internal(_, msg)) = copy.execute(spec.id, &p) {
|
||||
internal.push(format!("{} {p}: {msg}", spec.id));
|
||||
}
|
||||
drawing::check_in_design(©.doc.drawings).unwrap();
|
||||
copy.doc.drawings.iter().for_each(|d| d.validate().unwrap());
|
||||
assert!(copy.open_drawing.is_none_or(|id| copy.doc.drawing(id).is_some()), "{} {p}", spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(internal.is_empty(), "commands panicked with a drawing open:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
/// Making, opening, closing, changing and deleting drawings, model edits, undo, redo and
|
||||
/// checkpoints in any order: never a panic, and the design's drawings and the one open always
|
||||
/// keep their rules.
|
||||
#[test]
|
||||
fn drawing_steps_in_any_order_keep_the_rules() {
|
||||
let mut state = 3890_u64;
|
||||
let mut next = move |n: u64| {
|
||||
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(state >> 33) % n
|
||||
};
|
||||
for _ in 0..12 {
|
||||
let mut s = design();
|
||||
let mut checkpoints = Vec::new();
|
||||
for _ in 0..50 {
|
||||
let pick = next(8) as usize;
|
||||
let named = s.doc.drawings.get(pick % s.doc.drawings.len().max(1)).map_or(json!({"drawing": "AAAAAAAAAAAAA"}), |d| the(d.id));
|
||||
let (id, p) = match next(11) {
|
||||
0 => ("drawing.create", json!({"name": format!("D{}", next(4))})),
|
||||
1 => ("drawing.open", named),
|
||||
2 => ("drawing.close", json!({})),
|
||||
3 => ("drawing.set", json!({"name": format!("D{}", next(4))})),
|
||||
4 => ("drawing.delete", named),
|
||||
5 | 6 => ("edit.undo", json!({})),
|
||||
7 => ("edit.redo", json!({})),
|
||||
8 => ("solid.box", json!({"length": 1 + next(9), "width": 3, "height": 3})),
|
||||
9 => ("drawing.properties.set", json!({"key": "author", "value": format!("A{}", next(3))})),
|
||||
_ => match checkpoints.pop() {
|
||||
Some(checkpoint) if next(2) == 0 => ("edit.restore_checkpoint", json!({"checkpoint": checkpoint})),
|
||||
_ => ("edit.checkpoint", json!({})),
|
||||
},
|
||||
};
|
||||
let r = s.execute(id, &p);
|
||||
if id == "edit.checkpoint"
|
||||
&& let Ok(v) = &r
|
||||
{
|
||||
checkpoints.push(v["checkpoint"].clone());
|
||||
}
|
||||
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{id} {p}: {r:?}");
|
||||
drawing::check_in_design(&s.doc.drawings).unwrap();
|
||||
assert!(s.doc.drawings.windows(2).all(|w| (&w[0].name, w[0].id) < (&w[1].name, w[1].id)), "{id}: in their order");
|
||||
assert!(s.open_drawing.is_none_or(|id| s.doc.drawing(id).is_some()), "{id}: the drawing open is the design's");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Provisional Drawing ribbon metadata, not a drawing-file or persistence API.
|
||||
//!
|
||||
//! These entries reserve discoverable homes while the owning Drawing contracts land.
|
||||
//! JSON execution always refuses; the UI may show reviewed option layouts separately.
|
||||
//! `drawing.export.planned` keeps the disabled UI action separate from `drawing.export`
|
||||
//! (the sheet export provider). View previews likewise use `.planned` ids so the
|
||||
//! real base-view and projected-view providers keep their executable contracts.
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
/// Artwork awaiting owner review. Runtime assignments remain the intentional placeholder.
|
||||
pub const PENDING_ICONS: &[(&str, &str)] = &[
|
||||
("drawing.view.base.planned", "drawing_base_view"),
|
||||
("drawing.view.projected.planned", "drawing_projected_view"),
|
||||
("drawing.view.section", "drawing_section_view"),
|
||||
("drawing.view.detail", "drawing_detail_view"),
|
||||
("drawing.views.align", "drawing_align_views"),
|
||||
("drawing.image", "drawing_image"),
|
||||
("drawing.resources", "drawing_resources"),
|
||||
("drawing.dimension", "drawing_dimension"),
|
||||
("drawing.hole_callout", "drawing_hole_callout"),
|
||||
("drawing.leader", "drawing_leader"),
|
||||
("drawing.datum", "drawing_datum"),
|
||||
("drawing.centre_mark", "drawing_centre_mark"),
|
||||
("drawing.annotations.align", "drawing_align_annotations"),
|
||||
("drawing.annotations.space", "drawing_space_annotations"),
|
||||
("drawing.parts_list", "drawing_parts_list"),
|
||||
("drawing.balloon", "drawing_balloon"),
|
||||
("drawing.table.edit", "drawing_edit_table"),
|
||||
("drawing.revision_table", "drawing_revision_table"),
|
||||
("drawing.update", "drawing_update"),
|
||||
("drawing.check", "drawing_check"),
|
||||
("drawing.revision", "drawing_revision"),
|
||||
("drawing.print", "drawing_print"),
|
||||
];
|
||||
|
||||
pub fn pending_icon(id: &str) -> Option<&'static str> {
|
||||
PENDING_ICONS.iter().find(|(command, _)| *command == id).map(|(_, icon)| *icon)
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
planned!("drawing.view.base.planned", "Base View", "4816").at("DRAWING LAYOUT", "VIEWS").icon("placeholder"),
|
||||
planned!("drawing.view.projected.planned", "Projected View", "4816").at("DRAWING LAYOUT", "VIEWS").icon("placeholder"),
|
||||
planned!("drawing.view.section", "Section View", "4816").at("DRAWING LAYOUT", "VIEWS").icon("placeholder"),
|
||||
planned!("drawing.view.detail", "Detail View", "4816").at("DRAWING LAYOUT", "VIEWS").icon("placeholder"),
|
||||
planned!("drawing.views.align", "Align Views", "4894").at("DRAWING LAYOUT", "ARRANGE").icon("placeholder"),
|
||||
planned!("drawing.image", "Insert Image", "4894").at("DRAWING LAYOUT", "INSERT").icon("placeholder"),
|
||||
planned!("drawing.line", "Drawing Line", "4890").at("DRAWING LAYOUT", "INSERT").icon("line"),
|
||||
planned!("drawing.resources", "Browse Drawing Resources", "4894").at("DRAWING LAYOUT", "RESOURCES").icon("placeholder"),
|
||||
planned!("drawing.dimension", "Drawing Dimension", "4896").at("DRAWING ANNOTATE", "MEASURE").icon("placeholder"),
|
||||
planned!("drawing.hole_callout", "Hole Callout", "4896").at("DRAWING ANNOTATE", "MEASURE").icon("placeholder"),
|
||||
planned!("drawing.note", "Drawing Note", "4896").at("DRAWING ANNOTATE", "NOTES").icon("text"),
|
||||
planned!("drawing.leader", "Leader Note", "4896").at("DRAWING ANNOTATE", "NOTES").icon("placeholder"),
|
||||
planned!("drawing.datum", "Datum", "4896").at("DRAWING ANNOTATE", "SYMBOLS").icon("placeholder"),
|
||||
planned!("drawing.centre_mark", "Centre Mark", "4896").at("DRAWING ANNOTATE", "SYMBOLS").icon("placeholder"),
|
||||
planned!("drawing.annotations.align", "Align Annotations", "4896").at("DRAWING ANNOTATE", "ARRANGE").icon("placeholder"),
|
||||
planned!("drawing.annotations.space", "Space Annotations", "4896").at("DRAWING ANNOTATE", "ARRANGE").icon("placeholder"),
|
||||
planned!("drawing.parts_list", "Parts List", "3874").at("DRAWING PARTS", "ASSEMBLY").icon("placeholder"),
|
||||
planned!("drawing.balloon", "Balloon", "3874").at("DRAWING PARTS", "ASSEMBLY").icon("placeholder"),
|
||||
planned!("drawing.table.edit", "Edit Drawing Table", "4894").at("DRAWING PARTS", "TABLES").icon("placeholder"),
|
||||
planned!("drawing.revision_table", "Revision Table", "4894").at("DRAWING PARTS", "TABLES").icon("placeholder"),
|
||||
planned!("drawing.update", "Update Drawing", "4892").at("DRAWING REVIEW", "CHANGES").icon("placeholder"),
|
||||
planned!("drawing.check", "Check Drawing", "4894").at("DRAWING REVIEW", "CHANGES").icon("placeholder"),
|
||||
planned!("drawing.revision", "Add Drawing Revision", "4894").at("DRAWING REVIEW", "REVISION").icon("placeholder"),
|
||||
planned!("drawing.export.planned", "Export Drawing", "4890").at("DRAWING REVIEW", "OUTPUT").icon("export"),
|
||||
planned!("drawing.print", "Print Drawing", "4890").at("DRAWING REVIEW", "OUTPUT").icon("placeholder"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn drawing_entries_are_metadata_only() {
|
||||
let mut session = crate::Session::new(Default::default());
|
||||
let before = serde_json::to_value(&session.doc).unwrap();
|
||||
for id in ["drawing.export", "drawing.view.base", "drawing.view.projected"] {
|
||||
assert!(crate::find_command(id).unwrap().planned.is_none(), "the executable provider {id} stays registered");
|
||||
}
|
||||
assert!(crate::find_command("drawing.export.planned").unwrap().planned.is_some(), "the scaffold export stays disabled");
|
||||
for command in super::COMMANDS {
|
||||
assert!(command.planned.is_some(), "{}", command.id);
|
||||
assert!(!command.info(&session).enabled, "{}", command.id);
|
||||
assert!((command.run)(&mut session, &serde_json::json!({})).is_err(), "{}", command.id);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);
|
||||
}
|
||||
}
|
||||