Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69709bdbf1 | |||
| 8067a0af23 | |||
| 5792393fb2 | |||
| 188192cb8b | |||
| 371d25dadd | |||
| e821b3ea3c | |||
| e6e6df0d86 | |||
| bf32aa1fdc | |||
| 30942265a8 | |||
| 13cd786c50 | |||
| f7a24278bb | |||
| da576824b2 | |||
| f2b0b39393 | |||
| d0da5e5277 | |||
| b3af8346cf | |||
| 6be23a3e19 | |||
| e81a201ff8 | |||
| 96bb41c480 | |||
| ca6a095795 | |||
| 0273441236 | |||
| 58a00f41be | |||
| b841eb939f | |||
| 49eedf122c | |||
| 669aaa7744 | |||
| a3375c58e3 | |||
| a301359652 | |||
| fd4b7a7bc0 | |||
| 664c9812d2 | |||
| 08cd8d6442 | |||
| c5dab0b9a8 | |||
| cca77b176f | |||
| 1bd0040d25 | |||
| 49a3ba1c03 | |||
| d78895de4d | |||
| 112e0a3d9c | |||
| eb0b427a36 | |||
| ba69e5488c | |||
| 9308095744 | |||
| 5bf3940c04 | |||
| 2f483a00c0 | |||
| d5fd14135f | |||
| 85be50df0d | |||
| 71edae80df | |||
| 93baf88e2d | |||
| 4060069fcd | |||
| a4c9bc380f | |||
| f219d9b795 | |||
| 21888e8000 | |||
| 9b73ec8d9d | |||
| 0d4739fffb | |||
| 693cf911f1 | |||
| b21c377296 | |||
| 878bc1b421 | |||
| 562bb379a5 | |||
| 1a47ac13de | |||
| 1c957c105d | |||
| ec527a32ab | |||
| d01de87552 | |||
| fefca6bfa0 | |||
| 6a2bdbf144 | |||
| 413c145c56 | |||
| b708170028 | |||
| 8679063629 | |||
| f87b66d759 | |||
| a09c24e9bf | |||
| ebafe07487 | |||
| 3575c99750 | |||
| de7bf4fc4d | |||
| f18616e19c | |||
| 657c733166 | |||
| 4c4f25115b | |||
| 672ef13eb8 | |||
| 4c63a88fc3 | |||
| 5e23f58f62 | |||
| 01a542d6e8 | |||
| f0cc73eb83 |
@@ -4219,6 +4219,7 @@ dependencies = [
|
||||
"gethostname",
|
||||
"gltf",
|
||||
"log",
|
||||
"png",
|
||||
"quick-xml",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4229,8 +4230,10 @@ dependencies = [
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-profile",
|
||||
"solidcraft-sketch",
|
||||
"solidcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4294,6 +4297,7 @@ dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -460,16 +460,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` or `panel.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
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
|
||||
<!-- A drawing's Arabic, Korean and Chinese text: each fetched once, when a drawing needs it. -->
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.ttf" data-target-path="fonts/noto-sans-arabic" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-arabic/OFL.txt" data-target-path="fonts/noto-sans-arabic" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-kr/NotoSansKR-Regular.otf" data-target-path="fonts/noto-sans-kr" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-kr/OFL.txt" data-target-path="fonts/noto-sans-kr" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-sc/NotoSansSC-Regular.otf" data-target-path="fonts/noto-sans-sc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-sc/OFL.txt" data-target-path="fonts/noto-sans-sc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-tc/NotoSansTC-Regular.otf" data-target-path="fonts/noto-sans-tc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-tc/OFL.txt" data-target-path="fonts/noto-sans-tc" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
|
||||
@@ -23,6 +23,11 @@ mod linked;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
/// The folder the page serves its fonts from: index.html copies each there, and a drawing's text
|
||||
/// fetches its fonts from it (#4890).
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const FONTS: &str = "fonts";
|
||||
|
||||
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
|
||||
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
|
||||
/// Ctrl+PageDown already reach the browser.)
|
||||
@@ -287,6 +292,8 @@ mod web {
|
||||
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
|
||||
/// The fetches of drawing fonts that have finished, for the next frame to hand on.
|
||||
type DrawingFontsDone = Rc<std::cell::RefCell<Vec<(solidcraft_engine::sketch::fallback::WebFont, Result<Vec<u8>, String>)>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
@@ -317,6 +324,9 @@ mod web {
|
||||
font_fetch_kind: Option<&'static str>,
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
/// The fonts a drawing's text asked for, other than Japanese, each fetched once a session.
|
||||
drawing_fonts_asked: std::collections::HashSet<solidcraft_engine::sketch::fallback::WebFont>,
|
||||
drawing_fonts_done: DrawingFontsDone,
|
||||
space_mouse: SpaceMouse,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
|
||||
@@ -720,6 +730,10 @@ mod web {
|
||||
self.chinese_kind = Some("zh-TW");
|
||||
}
|
||||
_ => {
|
||||
solidcraft_engine::sketch::fallback::provide_web_font(
|
||||
solidcraft_engine::sketch::fallback::WebFont::Japanese,
|
||||
bytes.clone(),
|
||||
);
|
||||
self.cjk_bytes = Some(bytes);
|
||||
}
|
||||
}
|
||||
@@ -733,6 +747,9 @@ mod web {
|
||||
_ => JAPANESE_FONT_PATH,
|
||||
};
|
||||
log::warn!("failed to fetch script font from {path}: {err}");
|
||||
if !matches!(kind, Some("zh" | "zh-TW")) {
|
||||
solidcraft_engine::sketch::fallback::web_font_failed(solidcraft_engine::sketch::fallback::WebFont::Japanese);
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
@@ -768,10 +785,12 @@ mod web {
|
||||
self.font_language = effective.to_owned();
|
||||
}
|
||||
|
||||
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
|
||||
/// fetch the Japanese font, so the text does not stay tofu (#720).
|
||||
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name), or a
|
||||
/// drawing's text waiting for one: fetch the Japanese font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
let japanese = solidcraft_engine::sketch::fallback::web_fonts_wanted().contains(&solidcraft_engine::sketch::fallback::WebFont::Japanese);
|
||||
let wanted = solidcraft_ui_egui::fonts::painted_cjk(ctx) || japanese;
|
||||
if self.font.watching_text() && wanted && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx, "ja");
|
||||
}
|
||||
}
|
||||
@@ -792,6 +811,33 @@ mod web {
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// The other fonts a drawing's text waits for (Arabic, Chinese, Korean): each fetched once,
|
||||
/// and handed to the text when it comes. Japanese comes with the interface's CJK font.
|
||||
fn fetch_drawing_fonts(&mut self, ctx: &egui::Context) {
|
||||
use solidcraft_engine::sketch::fallback::{self, WebFont};
|
||||
for (font, res) in self.drawing_fonts_done.borrow_mut().drain(..) {
|
||||
match res {
|
||||
Ok(bytes) => fallback::provide_web_font(font, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch the drawing font {}/{}: {err}", crate::FONTS, font.file());
|
||||
fallback::web_font_failed(font);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
for font in fallback::web_fonts_wanted() {
|
||||
if font == WebFont::Japanese || !self.drawing_fonts_asked.insert(font) {
|
||||
continue;
|
||||
}
|
||||
let (done, ctx) = (self.drawing_fonts_done.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(&format!("./{}/{}", crate::FONTS, font.file())).await;
|
||||
done.borrow_mut().push((font, res));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
@@ -799,6 +845,7 @@ mod web {
|
||||
// First: a language another tab chose fetches its font this frame.
|
||||
self.load_other_tabs_prefs();
|
||||
self.tick_font(ctx);
|
||||
self.fetch_drawing_fonts(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.api.poll();
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
@@ -1022,6 +1069,8 @@ mod web {
|
||||
chinese_kind: None,
|
||||
font_fetch_kind: None,
|
||||
font_language,
|
||||
drawing_fonts_asked: Default::default(),
|
||||
drawing_fonts_done: Default::default(),
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
prefs_writer: crate::PrefsWriter::default(),
|
||||
@@ -1187,4 +1236,20 @@ mod tests {
|
||||
assert_eq!(settings_written(key, None, None), None, "clearing all storage leaves this tab's settings");
|
||||
assert_eq!(settings_written(key, Some("rc:solidcraft.prefs"), Some("{}".into())), None, "another channel's settings are not loaded");
|
||||
}
|
||||
|
||||
/// Each font a drawing's text can ask for on the web is one index.html copies, from an asset
|
||||
/// in the repository, into the folder the app fetches it from.
|
||||
#[test]
|
||||
fn each_drawing_font_is_copied_where_it_is_fetched() {
|
||||
let page = include_str!("../index.html");
|
||||
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
for font in solidcraft_engine::sketch::fallback::WebFont::ALL {
|
||||
let served = format!("{FONTS}/{}", font.file());
|
||||
let (folder, file) = served.rsplit_once('/').unwrap_or_default();
|
||||
let copy = page.lines().find(|l| l.contains("rel=\"copy-file\"") && l.contains(&format!("/{file}\""))).unwrap_or_default();
|
||||
assert!(copy.contains(&format!("data-target-path=\"{folder}\"")), "index.html doesn't copy {served}: {copy}");
|
||||
let from = copy.split("href=\"").nth(1).and_then(|rest| rest.split('"').next()).unwrap_or_default();
|
||||
assert!(here.join(from).is_file(), "{served}: no {from}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ assets/
|
||||
appearances/library.rs # built-in visual presets
|
||||
materials/library.rs # physical defaults and colours
|
||||
shaders/viewport.wgsl # original viewport shader
|
||||
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
|
||||
fonts/<family>/ # unmodified fonts and OFL licences
|
||||
locales/{en,de,ja,ko}.json # shared offline catalogs
|
||||
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
|
||||
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
|
||||
@@ -79,7 +79,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `io.tangencylabs.solidcraft` |
|
||||
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
|
||||
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
|
||||
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
|
||||
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies the Japanese font/licence to `fonts/` and the Noto Sans Arabic, KR, SC and TC fonts with their licences to `fonts/<family>/` (fetched when a drawing's text needs them), uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
|
||||
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
|
||||
|
||||
## Code and provenance
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -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 |
@@ -833,6 +833,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.assembly_mass_properties": "Masseeigenschaften",
|
||||
"cmd.body.print_face": "Auflagefläche",
|
||||
"cmd.body_rename": "Körper umbenennen",
|
||||
@@ -1024,6 +1026,7 @@
|
||||
"cmd.motion_trace_edit": "Bewegungsbahn bearbeiten",
|
||||
"cmd.motion_trace_keep": "Bewegungsbahn behalten",
|
||||
"cmd.occurrence_align": "Instanz ausrichten",
|
||||
"cmd.occurrence_bom_type": "Stücklistentyp",
|
||||
"cmd.occurrence_copy": "Einfügen (Instanz)",
|
||||
"cmd.occurrence_drag": "Instanz ziehen",
|
||||
"cmd.occurrence_ground": "Festsetzen",
|
||||
@@ -2327,6 +2330,7 @@
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster",
|
||||
"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.",
|
||||
@@ -4953,6 +4957,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",
|
||||
@@ -5253,6 +5258,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",
|
||||
@@ -5435,6 +5441,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",
|
||||
|
||||
@@ -833,6 +833,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.assembly_mass_properties": "Mass Properties",
|
||||
"cmd.body.print_face": "Print Face",
|
||||
"cmd.body_rename": "Rename Body",
|
||||
@@ -932,6 +934,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",
|
||||
@@ -1029,6 +1061,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",
|
||||
@@ -2345,6 +2378,7 @@
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window",
|
||||
"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.",
|
||||
@@ -4981,6 +5015,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",
|
||||
@@ -5285,6 +5320,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",
|
||||
@@ -5470,6 +5506,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",
|
||||
|
||||
@@ -833,6 +833,8 @@
|
||||
"cmd.appearance_pick_up": "外観を取得",
|
||||
"cmd.appearance_replace": "外観を置換",
|
||||
"cmd.assembly_bom": "部品表",
|
||||
"cmd.assembly_bom_item_set": "項目番号の変更",
|
||||
"cmd.assembly_bom_renumber": "項目番号の振り直し",
|
||||
"cmd.assembly_mass_properties": "質量特性",
|
||||
"cmd.body.print_face": "造形面",
|
||||
"cmd.body_rename": "ボディの名前を変更",
|
||||
@@ -932,6 +934,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": "チェックポイントを復元",
|
||||
@@ -1029,6 +1061,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": "空間固定",
|
||||
@@ -2345,6 +2378,7 @@
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ",
|
||||
"prefs.display": "表示",
|
||||
"prefs.drawings": "図面",
|
||||
"prefs.edges": "エッジと陰影",
|
||||
"prefs.extend": "拡張",
|
||||
"prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。",
|
||||
@@ -4981,6 +5015,7 @@
|
||||
"ui.fine": "細かい",
|
||||
"ui.finest": "最も細かい",
|
||||
"ui.finish_sketch": "スケッチを終了",
|
||||
"ui.first_angle": "第一角法",
|
||||
"ui.fit": "フィット",
|
||||
"ui.fix_unfix": "固定/固定解除",
|
||||
"ui.flat": "展開",
|
||||
@@ -5285,6 +5320,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": "一次モーメント",
|
||||
@@ -5470,6 +5506,7 @@
|
||||
"ui.text": "テキスト",
|
||||
"ui.theme": "テーマ",
|
||||
"ui.thickness": "厚さ",
|
||||
"ui.third_angle": "第三角法",
|
||||
"ui.thread": "ねじ",
|
||||
"ui.tie_ends": "拘束",
|
||||
"ui.tie_ends_tip": "未拘束をすべて拘束",
|
||||
|
||||
@@ -724,6 +724,8 @@
|
||||
"cmd.appearance_pick_up": "모양 가져오기",
|
||||
"cmd.appearance_replace": "모양 바꾸기",
|
||||
"cmd.assembly_bom": "자재 명세서",
|
||||
"cmd.assembly_bom_item_set": "항목 번호 변경",
|
||||
"cmd.assembly_bom_renumber": "항목 번호 다시 매기기",
|
||||
"cmd.assembly_mass_properties": "질량 특성",
|
||||
"cmd.body.print_face": "출력 바닥면",
|
||||
"cmd.body_rename": "바디 이름 변경",
|
||||
@@ -894,6 +896,7 @@
|
||||
"cmd.motion_study": "운동 연구",
|
||||
"cmd.motion_trace": "모션 궤적",
|
||||
"cmd.motion_trace_keep": "모션 궤적 유지",
|
||||
"cmd.occurrence_bom_type": "자재 명세서 유형",
|
||||
"cmd.occurrence_copy": "붙여넣기(인스턴스)",
|
||||
"cmd.occurrence_ground": "고정",
|
||||
"cmd.occurrence_move": "인스턴스 이동",
|
||||
@@ -1732,6 +1735,7 @@
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서",
|
||||
"prefs.display": "표시",
|
||||
"prefs.drawings": "도면",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
"prefs.extra_navigation": "Ctrl+왼쪽 드래그로 빈 공간에서 회전하고, Space를 누른 채 이동합니다.",
|
||||
@@ -3750,6 +3754,7 @@
|
||||
"ui.fine": "세밀",
|
||||
"ui.finest": "가장 세밀",
|
||||
"ui.finish_sketch": "스케치 종료",
|
||||
"ui.first_angle": "제1각법",
|
||||
"ui.fit": "맞춤",
|
||||
"ui.fix_unfix": "고정/고정 해제",
|
||||
"ui.flat": "플랫",
|
||||
@@ -4023,6 +4028,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차 모멘트",
|
||||
@@ -4182,6 +4188,7 @@
|
||||
"ui.text": "텍스트",
|
||||
"ui.theme": "테마",
|
||||
"ui.thickness": "두께",
|
||||
"ui.third_angle": "제3각법",
|
||||
"ui.thread": "나사산",
|
||||
"ui.tie_ends": "연결",
|
||||
"ui.tie_ends_tip": "연결되지 않은 모든 끝점 구속",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"export.paper.blueprint": "Blueprint Blue",
|
||||
"export.paper.prussian_blue": "Prussian Blue (Historical)"
|
||||
},
|
||||
"ar": {
|
||||
"export.paper.blueprint": "أزرق المخطط",
|
||||
"export.paper.prussian_blue": "أزرق بروسي (تاريخي)"
|
||||
},
|
||||
"de": {
|
||||
"export.paper.blueprint": "Blaupausenblau",
|
||||
"export.paper.prussian_blue": "Preußischblau (historisch)"
|
||||
},
|
||||
"he": {
|
||||
"export.paper.blueprint": "כחול שרטוט",
|
||||
"export.paper.prussian_blue": "כחול פרוסי (היסטורי)"
|
||||
},
|
||||
"ja": {
|
||||
"export.paper.blueprint": "ブループリントブルー",
|
||||
"export.paper.prussian_blue": "プルシアンブルー(歴史的)"
|
||||
},
|
||||
"ko": {
|
||||
"export.paper.blueprint": "블루프린트 블루",
|
||||
"export.paper.prussian_blue": "프러시안 블루(역사적)"
|
||||
},
|
||||
"vi": {
|
||||
"export.paper.blueprint": "Xanh bản vẽ",
|
||||
"export.paper.prussian_blue": "Xanh Phổ (lịch sử)"
|
||||
},
|
||||
"zh": {
|
||||
"export.paper.blueprint": "蓝图蓝",
|
||||
"export.paper.prussian_blue": "普鲁士蓝(历史)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_image_add": "Add Image"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_image_add": "إضافة صورة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_image_add": "Bild hinzufügen"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_image_add": "הוספת תמונה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_image_add": "画像を追加"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_image_add": "이미지 추가"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_image_add": "Thêm hình ảnh"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_image_add": "添加图像"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_note_add": "Add Note",
|
||||
"cmd.drawing_note_edit": "Edit Note",
|
||||
"cmd.drawing_note_inspect": "Inspect Note"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_note_add": "إضافة ملاحظة",
|
||||
"cmd.drawing_note_edit": "تحرير الملاحظة",
|
||||
"cmd.drawing_note_inspect": "فحص الملاحظة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_note_add": "Hinweis hinzufügen",
|
||||
"cmd.drawing_note_edit": "Hinweis bearbeiten",
|
||||
"cmd.drawing_note_inspect": "Hinweis prüfen"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_note_add": "הוספת הערה",
|
||||
"cmd.drawing_note_edit": "עריכת הערה",
|
||||
"cmd.drawing_note_inspect": "בדיקת הערה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_note_add": "注記を追加",
|
||||
"cmd.drawing_note_edit": "注記を編集",
|
||||
"cmd.drawing_note_inspect": "注記を確認"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_note_add": "주석 추가",
|
||||
"cmd.drawing_note_edit": "주석 편집",
|
||||
"cmd.drawing_note_inspect": "주석 확인"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_note_add": "Thêm ghi chú",
|
||||
"cmd.drawing_note_edit": "Sửa ghi chú",
|
||||
"cmd.drawing_note_inspect": "Kiểm tra ghi chú"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_note_add": "添加注释",
|
||||
"cmd.drawing_note_edit": "编辑注释",
|
||||
"cmd.drawing_note_inspect": "检查注释"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_balloon_repair": "Repair Balloon",
|
||||
"cmd.drawing_dimension_repair": "Repair Drawing Dimension"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_balloon_repair": "إصلاح البالون",
|
||||
"cmd.drawing_dimension_repair": "إصلاح بُعد اللوحة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_balloon_repair": "Positionsnummer reparieren",
|
||||
"cmd.drawing_dimension_repair": "Zeichnungsbemaßung reparieren"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_balloon_repair": "תיקון בלון",
|
||||
"cmd.drawing_dimension_repair": "תיקון מידת שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_balloon_repair": "バルーンを修復",
|
||||
"cmd.drawing_dimension_repair": "図面寸法を修復"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_balloon_repair": "벌룬 복구",
|
||||
"cmd.drawing_dimension_repair": "도면 치수 복구"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_balloon_repair": "Sửa chữa bóng chú thích",
|
||||
"cmd.drawing_dimension_repair": "Sửa chữa kích thước bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_balloon_repair": "修复零件序号",
|
||||
"cmd.drawing_dimension_repair": "修复绘图尺寸"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_revise": "Issue Revision"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_revise": "إصدار مراجعة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_revise": "Revision herausgeben"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_revise": "הוצאת גרסה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_revise": "リビジョンを発行"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_revise": "리비전 발행"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_revise": "Phát hành bản sửa đổi"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_revise": "发布版本"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_revision_tag_add": "Add Revision Tag",
|
||||
"cmd.drawing_revision_tag_edit": "Edit Revision Tag",
|
||||
"cmd.drawing_revision_tag_delete": "Delete Revision Tag"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_revision_tag_add": "إضافة علامة مراجعة",
|
||||
"cmd.drawing_revision_tag_edit": "تحرير علامة المراجعة",
|
||||
"cmd.drawing_revision_tag_delete": "حذف علامة المراجعة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_revision_tag_add": "Revisionskennzeichen hinzufügen",
|
||||
"cmd.drawing_revision_tag_edit": "Revisionskennzeichen bearbeiten",
|
||||
"cmd.drawing_revision_tag_delete": "Revisionskennzeichen löschen"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_revision_tag_add": "הוספת תג גרסה",
|
||||
"cmd.drawing_revision_tag_edit": "עריכת תג גרסה",
|
||||
"cmd.drawing_revision_tag_delete": "מחיקת תג גרסה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_revision_tag_add": "リビジョンタグを追加",
|
||||
"cmd.drawing_revision_tag_edit": "リビジョンタグを編集",
|
||||
"cmd.drawing_revision_tag_delete": "リビジョンタグを削除"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_revision_tag_add": "리비전 태그 추가",
|
||||
"cmd.drawing_revision_tag_edit": "리비전 태그 편집",
|
||||
"cmd.drawing_revision_tag_delete": "리비전 태그 삭제"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_revision_tag_add": "Thêm thẻ sửa đổi",
|
||||
"cmd.drawing_revision_tag_edit": "Sửa thẻ sửa đổi",
|
||||
"cmd.drawing_revision_tag_delete": "Xóa thẻ sửa đổi"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_revision_tag_add": "添加版本标记",
|
||||
"cmd.drawing_revision_tag_edit": "编辑版本标记",
|
||||
"cmd.drawing_revision_tag_delete": "删除版本标记"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_status": "Drawing Status"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_status": "حالة الرسم"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_status": "Zeichnungsstatus"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_status": "מצב השרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_status": "図面の状態"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_status": "도면 상태"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_status": "Trạng thái bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_status": "绘图状态"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_table_add": "Add Table",
|
||||
"cmd.drawing_table_delete": "Delete Table",
|
||||
"cmd.drawing_table_edit": "Edit Table",
|
||||
"cmd.drawing_table_inspect": "Inspect Table",
|
||||
"help.drawing.table.edit": "Changes a saved table on a sheet: what it lists, where it sits, its columns and its words. Use it to adjust a parts list or revision table after adding it."
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_table_add": "إضافة جدول",
|
||||
"cmd.drawing_table_delete": "حذف الجدول",
|
||||
"cmd.drawing_table_edit": "تحرير الجدول",
|
||||
"cmd.drawing_table_inspect": "فحص الجدول",
|
||||
"help.drawing.table.edit": "يغيّر جدولاً محفوظاً على ورقة: ما يعرضه ومكانه وأعمدته ونصوصه. استخدمه لضبط قائمة أجزاء أو جدول مراجعات بعد إضافته."
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_table_add": "Tabelle hinzufügen",
|
||||
"cmd.drawing_table_delete": "Tabelle löschen",
|
||||
"cmd.drawing_table_edit": "Tabelle bearbeiten",
|
||||
"cmd.drawing_table_inspect": "Tabelle prüfen",
|
||||
"help.drawing.table.edit": "Ändert eine gespeicherte Tabelle auf einem Blatt: was sie auflistet, wo sie steht, ihre Spalten und Texte. Damit passen Sie eine Stückliste oder Revisionstabelle nach dem Hinzufügen an."
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_table_add": "הוספת טבלה",
|
||||
"cmd.drawing_table_delete": "מחיקת טבלה",
|
||||
"cmd.drawing_table_edit": "עריכת טבלה",
|
||||
"cmd.drawing_table_inspect": "בדיקת טבלה",
|
||||
"help.drawing.table.edit": "משנה טבלה שמורה בגיליון: מה היא מציגה, היכן היא ממוקמת, העמודות והטקסטים שלה. השתמשו בכך כדי להתאים רשימת חלקים או טבלת גרסאות לאחר הוספתה."
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_table_add": "表を追加",
|
||||
"cmd.drawing_table_delete": "表を削除",
|
||||
"cmd.drawing_table_edit": "表を編集",
|
||||
"cmd.drawing_table_inspect": "表を確認",
|
||||
"help.drawing.table.edit": "シート上の保存済みの表の内容、位置、列、文字を変更します。部品表や改訂表を追加した後の調整に使います。"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_table_add": "표 추가",
|
||||
"cmd.drawing_table_delete": "표 삭제",
|
||||
"cmd.drawing_table_edit": "표 편집",
|
||||
"cmd.drawing_table_inspect": "표 확인",
|
||||
"help.drawing.table.edit": "시트에 저장된 표의 내용, 위치, 열, 글자를 변경합니다. 부품표나 개정표를 추가한 뒤 조정할 때 사용합니다."
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_table_add": "Thêm bảng",
|
||||
"cmd.drawing_table_delete": "Xóa bảng",
|
||||
"cmd.drawing_table_edit": "Sửa bảng",
|
||||
"cmd.drawing_table_inspect": "Kiểm tra bảng",
|
||||
"help.drawing.table.edit": "Thay đổi một bảng đã lưu trên trang: nội dung, vị trí, các cột và chữ của bảng. Dùng để chỉnh danh sách chi tiết hoặc bảng sửa đổi sau khi thêm."
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_table_add": "添加表格",
|
||||
"cmd.drawing_table_delete": "删除表格",
|
||||
"cmd.drawing_table_edit": "编辑表格",
|
||||
"cmd.drawing_table_inspect": "检查表格",
|
||||
"help.drawing.table.edit": "更改图纸页上已保存的表格的内容、位置、列和文字。添加零件表或修订表后,可用它进行调整。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_apply": "Apply Drawing Template"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_apply": "تطبيق قالب لوحة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_apply": "Zeichnungsvorlage anwenden"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_apply": "החלת תבנית שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_apply": "図面テンプレートを適用"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_apply": "도면 템플릿 적용"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_apply": "Áp dụng mẫu bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_apply": "应用绘图模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_save": "Save as Drawing Template"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_save": "حفظ كقالب لوحة"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_save": "Als Zeichnungsvorlage speichern"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_save": "שמירה כתבנית שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_save": "図面テンプレートとして保存"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_save": "도면 템플릿으로 저장"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_save": "Lưu thành mẫu bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_save": "另存为绘图模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.template_for_new_part_drawings": "Template for new part drawings",
|
||||
"ui.template_for_new_assembly_drawings": "Template for new assembly drawings"
|
||||
},
|
||||
"ar": {
|
||||
"ui.template_for_new_part_drawings": "قالب لوحات القطع الجديدة",
|
||||
"ui.template_for_new_assembly_drawings": "قالب لوحات التجميعات الجديدة"
|
||||
},
|
||||
"de": {
|
||||
"ui.template_for_new_part_drawings": "Vorlage für neue Teilezeichnungen",
|
||||
"ui.template_for_new_assembly_drawings": "Vorlage für neue Baugruppenzeichnungen"
|
||||
},
|
||||
"he": {
|
||||
"ui.template_for_new_part_drawings": "תבנית לשרטוטי חלקים חדשים",
|
||||
"ui.template_for_new_assembly_drawings": "תבנית לשרטוטי מכלולים חדשים"
|
||||
},
|
||||
"ja": {
|
||||
"ui.template_for_new_part_drawings": "新しい部品図面のテンプレート",
|
||||
"ui.template_for_new_assembly_drawings": "新しいアセンブリ図面のテンプレート"
|
||||
},
|
||||
"ko": {
|
||||
"ui.template_for_new_part_drawings": "새 부품 도면의 템플릿",
|
||||
"ui.template_for_new_assembly_drawings": "새 어셈블리 도면의 템플릿"
|
||||
},
|
||||
"vi": {
|
||||
"ui.template_for_new_part_drawings": "Mẫu cho bản vẽ chi tiết mới",
|
||||
"ui.template_for_new_assembly_drawings": "Mẫu cho bản vẽ cụm lắp ráp mới"
|
||||
},
|
||||
"zh": {
|
||||
"ui.template_for_new_part_drawings": "新零件绘图的模板",
|
||||
"ui.template_for_new_assembly_drawings": "新装配体绘图的模板"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.drawing_template_list": "List Drawing Templates"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.drawing_template_list": "سرد قوالب اللوحات"
|
||||
},
|
||||
"de": {
|
||||
"cmd.drawing_template_list": "Zeichnungsvorlagen auflisten"
|
||||
},
|
||||
"he": {
|
||||
"cmd.drawing_template_list": "רשימת תבניות שרטוט"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.drawing_template_list": "図面テンプレートを一覧表示"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.drawing_template_list": "도면 템플릿 목록"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.drawing_template_list": "Liệt kê mẫu bản vẽ"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.drawing_template_list": "列出绘图模板"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -833,6 +833,8 @@
|
||||
"cmd.appearance_pick_up": "拾取外观",
|
||||
"cmd.appearance_replace": "替换外观",
|
||||
"cmd.assembly_bom": "物料清单",
|
||||
"cmd.assembly_bom_item_set": "更改项目编号",
|
||||
"cmd.assembly_bom_renumber": "重新编号项目",
|
||||
"cmd.assembly_mass_properties": "质量属性",
|
||||
"cmd.body.print_face": "打印底面",
|
||||
"cmd.body_rename": "重命名实体",
|
||||
@@ -932,6 +934,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": "恢复检查点",
|
||||
@@ -1029,6 +1061,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": "固定",
|
||||
@@ -2345,6 +2378,7 @@
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中",
|
||||
"prefs.display": "显示",
|
||||
"prefs.drawings": "绘图",
|
||||
"prefs.edges": "边缘与着色",
|
||||
"prefs.extend": "扩展",
|
||||
"prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。",
|
||||
@@ -4981,6 +5015,7 @@
|
||||
"ui.fine": "精细",
|
||||
"ui.finest": "最精细",
|
||||
"ui.finish_sketch": "完成草图",
|
||||
"ui.first_angle": "第一角投影",
|
||||
"ui.fit": "适合",
|
||||
"ui.fix_unfix": "固定/取消固定",
|
||||
"ui.flat": "平面",
|
||||
@@ -5285,6 +5320,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": "一阶矩",
|
||||
@@ -5470,6 +5506,7 @@
|
||||
"ui.text": "文本",
|
||||
"ui.theme": "主题",
|
||||
"ui.thickness": "厚度",
|
||||
"ui.third_angle": "第三角投影",
|
||||
"ui.thread": "螺纹",
|
||||
"ui.tie_ends": "约束",
|
||||
"ui.tie_ends_tip": "约束全部未约束端点",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
| assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.ttf | The Noto Project Authors (Noto Sans Arabic 2.013, hinted) | https://github.com/notofonts/notofonts.github.io (fonts/NotoSansArabic/hinted/ttf), unmodified; licence text in assets/fonts/noto-sans-arabic/OFL.txt; the web app's font for a drawing's Arabic text | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-arabic/OFL.txt | The Noto Project Authors | Unmodified accompanying font licence (https://github.com/notofonts/arabic) | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-kr/NotoSansKR-Regular.otf | Adobe (Noto Sans KR 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/KR), unmodified; licence text in assets/fonts/noto-sans-kr/OFL.txt; the web app's font for a drawing's Korean text | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-kr/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-sc/NotoSansSC-Regular.otf | Adobe (Noto Sans SC 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/SC), unmodified; licence text in assets/fonts/noto-sans-sc/OFL.txt; the web app's font for a drawing's simplified Chinese text | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-sc/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-tc/NotoSansTC-Regular.otf | Adobe (Noto Sans TC 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/TC), unmodified; licence text in assets/fonts/noto-sans-tc/OFL.txt; the web app's font for a drawing's traditional Chinese text | SIL OFL 1.1 |
|
||||
| assets/fonts/noto-sans-tc/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
|
||||
@@ -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 |
|
||||
@@ -29,6 +29,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],
|
||||
@@ -45,6 +57,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)
|
||||
}
|
||||
@@ -136,6 +153,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 {
|
||||
@@ -144,7 +165,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 {
|
||||
@@ -188,21 +210,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),
|
||||
@@ -324,6 +341,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))
|
||||
@@ -374,6 +407,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.
|
||||
@@ -440,6 +512,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
|
||||
}
|
||||
|
||||
/// Is `c` (or one of its ancestors) marked out of the History bar (#10393)? The root (0)
|
||||
/// never is: its own features always show.
|
||||
pub fn component_history_hidden(&self, mut c: u64) -> bool {
|
||||
@@ -503,7 +590,7 @@ impl Document {
|
||||
!self.occurrences.iter().any(|o| o.name == name)
|
||||
};
|
||||
let k = std::iter::once(next).chain(1..).find(free).unwrap_or(next);
|
||||
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,964 @@
|
||||
//! 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 and their members stay inside
|
||||
//! this crate: a balloon reads its row's item ([`Bom::item_of`]), and 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)]
|
||||
pub 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`).
|
||||
pub(crate) fn row_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<(Vec<u32>, &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), with its item
|
||||
/// numbers from the top.
|
||||
fn row_at(&self, at: &[usize]) -> Option<(Vec<u32>, &Row)> {
|
||||
let (first, rest) = at.split_first()?;
|
||||
let mut row = self.rows.get(*first)?;
|
||||
let mut item = vec![row.item];
|
||||
for &i in rest {
|
||||
row = row.children.get(i)?;
|
||||
item.push(row.item);
|
||||
}
|
||||
Some((item, row))
|
||||
}
|
||||
|
||||
/// The item ("1.2") of the row a placement is counted in, by its occurrence path from the top of
|
||||
/// the design: a balloon's, which is cut to start below the assembly listed. A path that doesn't
|
||||
/// run through that assembly is [`Unlisted::NotFound`]; through one of its placements, whichever,
|
||||
/// it reads the same row. `tree` is as for `row_of`.
|
||||
pub fn item_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<String, Unlisted> {
|
||||
let below = tree.below(self.source, path).ok_or(Unlisted::NotFound)?;
|
||||
self.row_of(tree, below).map(|(item, _)| item_text(&item))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// What of `path`, from the top of the design, runs below `source`'s first placement on it.
|
||||
fn below<'p>(&self, source: u64, path: &'p [u64]) -> Option<&'p [u64]> {
|
||||
if source == 0 {
|
||||
return Some(path);
|
||||
}
|
||||
let mut component = 0;
|
||||
for (i, id) in path.iter().enumerate() {
|
||||
component = self.contents(component).iter().find(|p| p.occurrence == *id)?.member.component;
|
||||
if component == source {
|
||||
return path.get(i + 1..);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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, what it holds listed under
|
||||
/// it (of an inseparable one, its purchased parts).
|
||||
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) if shape.folded => Step { inside: contents(Walk::PurchasedOnly), kit: true, ..row(Holds::All) },
|
||||
(BomType::Inseparable, Walk::Listed) => match view {
|
||||
View::PartsOnly => Step { inside: contents(Walk::PurchasedOnly), ..row(Holds::AllButPurchased) },
|
||||
View::FirstLevel | View::AllLevels => row(Holds::All),
|
||||
},
|
||||
(BomType::Normal, Walk::Listed) if shape.folded => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
|
||||
(BomType::Normal, Walk::Listed) => match view {
|
||||
View::PartsOnly => Step { row: !shape.contents || shape.bodies, inside: contents(Walk::Listed), kit: false, holds: Holds::Own },
|
||||
View::FirstLevel => row(Holds::All),
|
||||
View::AllLevels => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a placement of `bom_type` inside a part adds to what the part's row stands for: a
|
||||
/// reference adds nothing, and what the list shows beside the row (what `classify` lists inside
|
||||
/// an inseparable part) isn't weighed with it.
|
||||
fn adds(holds: Holds, bom_type: BomType) -> bool {
|
||||
match holds {
|
||||
Holds::Own => false,
|
||||
Holds::All => bom_type != BomType::Reference,
|
||||
Holds::AllButPurchased => bom_type != BomType::Reference && !classify(View::PartsOnly, Walk::PurchasedOnly, bom_type, Shape::default()).row,
|
||||
}
|
||||
}
|
||||
|
||||
/// One placement a list counts in a row.
|
||||
struct Entry {
|
||||
member: Member,
|
||||
holds: Holds,
|
||||
/// The folded placement (a position in the entries) whose children it is among.
|
||||
kit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Every placement below `request.source` that `request` counts in a row, in one walk under one
|
||||
/// budget, and with `members` each one's occurrence path (else an empty path each).
|
||||
fn walk(tree: &Tree, request: &Request, members: bool) -> Result<(Vec<Entry>, Vec<Vec<u64>>)> {
|
||||
struct Frame {
|
||||
component: u64,
|
||||
depth: usize,
|
||||
/// Kept only with `members`.
|
||||
path: Vec<u64>,
|
||||
walk: Walk,
|
||||
kit: Option<usize>,
|
||||
}
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
let mut paths: Vec<Vec<u64>> = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
let mut stack = vec![Frame { component: request.source, depth: 0, path: Vec::new(), walk: Walk::Listed, kit: None }];
|
||||
while let Some(frame) = stack.pop() {
|
||||
for placed in tree.contents(frame.component) {
|
||||
visited += 1;
|
||||
if frame.depth >= MAX_PLACEMENT_DEPTH || visited > MAX_PLACEMENTS {
|
||||
return Err(too_big());
|
||||
}
|
||||
let component = placed.member.component;
|
||||
// What a folded part holds is listed as parts only lists it, and isn't folded again; in
|
||||
// all levels what any row holds is listed in all levels again.
|
||||
let (view, folds) = match frame.kit {
|
||||
Some(_) if request.view == View::AllLevels => (View::AllLevels, false),
|
||||
Some(_) => (View::PartsOnly, false),
|
||||
None => (request.view, request.fold),
|
||||
};
|
||||
let numbered = tree.components.get(&component).is_some_and(|c| c.metadata.part_number().is_some());
|
||||
let shape = Shape {
|
||||
contents: tree.children.contains_key(&component),
|
||||
bodies: tree.bodies.contains_key(&component),
|
||||
folded: folds && numbered,
|
||||
open_purchased: request.purchased_contents,
|
||||
};
|
||||
let step = classify(view, frame.walk, placed.member.bom_type, shape);
|
||||
let path = if members { [frame.path.as_slice(), &[placed.occurrence]].concat() } else { Vec::new() };
|
||||
let here = entries.len();
|
||||
if step.row {
|
||||
entries.push(Entry { member: placed.member, holds: step.holds, kit: frame.kit });
|
||||
paths.push(path.clone());
|
||||
}
|
||||
if let Some(walk) = step.inside {
|
||||
let kit = if step.kit { Some(here) } else { frame.kit };
|
||||
stack.push(Frame { component, depth: frame.depth + 1, path, walk, kit });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((entries, paths))
|
||||
}
|
||||
|
||||
impl Tree<'_> {
|
||||
/// Component `id` (0: the design) as a title block shows it (#3875): what one of it weighs, as
|
||||
/// a list weighs the assembly ([`Total::mass`]: its own bodies and all it holds but references),
|
||||
/// and what that is made of, as its row would say. Each is worked out once for the tree.
|
||||
pub fn component(&mut self, id: u64) -> Result<(Mass, Vec<String>)> {
|
||||
if id != 0 && !self.components.contains_key(&id) {
|
||||
return Err(DocError::Unknown(format!("component {id}")));
|
||||
}
|
||||
let mass = agree(&[self.weigh(id, Holds::All, 0)?]);
|
||||
Ok((mass, self.made_of(id, Holds::All, 0)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows `request` lists, with the assembly's total; with `request.mass`, each row and the
|
||||
/// assembly weighed.
|
||||
pub fn list(tree: &mut Tree, request: &Request) -> Result<Bom> {
|
||||
list_with(tree, request, false)
|
||||
}
|
||||
|
||||
/// [`list`], with each row's members when asked (what item numbers and balloons will follow rows
|
||||
/// by, and what `row_of` reads), so a list made for its rows alone copies no paths.
|
||||
pub 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,922 @@
|
||||
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<(Vec<u32>, &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));
|
||||
}
|
||||
|
||||
/// A balloon's item reads the row its part is counted in, by a path from the top of the design: in
|
||||
/// all levels with the rows it is under, in a component's list only below that component, and the
|
||||
/// reasons there is none stay apart.
|
||||
#[test]
|
||||
fn item_of_reads_a_balloons_row() {
|
||||
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 all = list_with(&mut tree, &Request { view: View::AllLevels, ..Request::default() }, true).unwrap();
|
||||
let (washer, _) = all.row_of(&tree, &path(&["Sub", "Washer"])).unwrap();
|
||||
assert_eq!(washer.len(), 2, "under its subassembly");
|
||||
assert_eq!(all.item_of(&tree, &path(&["Sub", "Washer"])), Ok(item_text(&washer)));
|
||||
assert!(all.item_of(&tree, &path(&["Sub", "Washer"])).unwrap().contains('.'));
|
||||
|
||||
let sub = doc.find_component("Sub").unwrap();
|
||||
let of_sub = list_with(&mut tree, &Request { source: sub, ..Request::default() }, true).unwrap();
|
||||
assert_eq!(of_sub.item_of(&tree, &path(&["Sub", "Washer"])), Ok("1".to_string()), "cut to start below Sub");
|
||||
assert_eq!(of_sub.item_of(&tree, &path(&["Sub"])), Err(Unlisted::NoRow), "the assembly listed has no row");
|
||||
assert_eq!(of_sub.item_of(&tree, &path(&["Bolt"])), Err(Unlisted::NotFound), "not through Sub");
|
||||
assert_eq!(of_sub.item_of(&tree, &[]), Err(Unlisted::NotFound));
|
||||
|
||||
assert_eq!(all.item_of(&tree, &path(&["Fixture", "Pin"])), Err(Unlisted::Reference));
|
||||
assert_eq!(all.item_of(&tree, &[999]), Err(Unlisted::NotFound));
|
||||
assert_eq!(all.item_of(&tree, &vec![at["Bolt"]; 65]), Err(Unlisted::NotFound));
|
||||
assert!(of_sub.item_of(&tree, &vec![at["Sub"]; 65]).is_err());
|
||||
assert_eq!(all.item_of(&tree, &[]), Err(Unlisted::NoRow));
|
||||
}
|
||||
@@ -12,6 +12,9 @@ pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solidco
|
||||
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 {
|
||||
@@ -38,7 +41,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)
|
||||
{
|
||||
@@ -53,7 +56,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))
|
||||
@@ -91,7 +94,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))
|
||||
}))
|
||||
|
||||
@@ -207,7 +207,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.
|
||||
@@ -230,17 +230,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();
|
||||
@@ -248,7 +263,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}");
|
||||
|
||||
@@ -504,12 +504,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.
|
||||
@@ -3223,6 +3218,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
|
||||
@@ -3510,6 +3511,7 @@ impl Document {
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
drawings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3650,29 +3652,38 @@ impl Document {
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// Only part properties changed (the fields `with_part_properties_of` names): nothing to
|
||||
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
|
||||
/// Only part properties changed (the fields `with_part_properties_of` names), and perhaps
|
||||
/// drawings with them, as placing a parts list saves its numbers: nothing to evaluate. The
|
||||
/// same whole-Document proof as [`Document::differs_only_in_render`].
|
||||
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
|
||||
previous.with_part_properties_of(self).is_some_and(|normalized| normalized == *self)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
out.drawings.clone_from(&other.drawings);
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -3688,6 +3699,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`.)
|
||||
@@ -3798,12 +3835,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
|
||||
@@ -3828,17 +3865,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.
|
||||
@@ -3846,6 +3891,17 @@ impl Document {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
}
|
||||
|
||||
/// The frame `body`'s geometry is in, placed through the occurrence path `path` (empty: its
|
||||
/// component's own placement): the placement, then the body's offset. One placement, without
|
||||
/// expanding every assembly instance.
|
||||
pub fn body_frame_at(&self, body: &crate::ModelBody, path: &[u64]) -> Result<crate::Mat> {
|
||||
let mut frame = EmbossFace::component_frame(self, self.model_body_component(body), path)?;
|
||||
if let Some(offset) = self.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a
|
||||
//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs
|
||||
//! ([`DRAWING`]); format 1 is this record.
|
||||
//! A drawing (#3890): sheets of paper that draw a design. A drawing lives in the file of the design
|
||||
//! it draws ([`Document::drawings`](crate::Document::drawings)): a design container keeps each in
|
||||
//! its own entry, a plain JSON design inline. Each is a record of its own family,
|
||||
//! `"format": "solidcraft-drawing/N"`, versioned by the same code as designs ([`DRAWING`]), so a
|
||||
//! change to the record is a drawing change, not a design one.
|
||||
//!
|
||||
//! - **Sources** are the designs it draws: an id and the design file's path, relative to the
|
||||
//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked
|
||||
//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw
|
||||
//! it is not saved here.
|
||||
//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered
|
||||
//! at, the component it documents, and whether it is left out of the sheet count or of printing.
|
||||
//! The border is a copy of the profile it was made with, never a name, so a later built-in never
|
||||
//! changes a saved drawing.
|
||||
//! - Each drawing has an **id**, unique among its design's drawings, and a design lists its
|
||||
//! drawings by name, then id ([`in_design`]): nothing to merge when two people add drawings.
|
||||
//! - **Sources** are the designs it draws. For now a drawing draws the design it is in: at most one
|
||||
//! source, an id and nothing else. What the design was when the drawing last saw it is not saved
|
||||
//! here.
|
||||
//! - **Sheets**: the paper, a copy of its drawing border and of its title block with the text typed
|
||||
//! over its cells' values, the resolution its pictures are rendered at, the component it
|
||||
//! documents, and whether it is left out of the sheet count or of printing. The border and the
|
||||
//! block are copies of what they were made with, never names, so a later built-in never changes a
|
||||
//! saved drawing.
|
||||
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
|
||||
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
|
||||
//! interface's is. **Properties** follow [`Metadata`]'s rules.
|
||||
//! - The **font** its text is set in: a font family's name, never a file, so the drawing travels
|
||||
//! (none: the built-in font; [`solidcraft_sketch::check_family`]).
|
||||
//! - **Views** of its designs on its sheets, base and projected ([`view`]), and the **projection**
|
||||
//! (first or third angle) every projected view follows.
|
||||
//! - **Dimensions** on its views, each measuring named corners or edges of the view's model
|
||||
//! ([`dimension`]).
|
||||
//! - **Balloons** on its views, each pointing at a placed part by a named edge of it ([`balloon`]).
|
||||
//! - **Images** on its sheets, each file kept once however many places it sits ([`image`]).
|
||||
//! - **Tables** on its sheets: parts lists and revision tables ([`table`]).
|
||||
//! - **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 +35,59 @@
|
||||
//! never gives a removed object's id again ([`Drawing::allocate_id`]).
|
||||
//! - Every reference between records is an id and must resolve: a file with one that doesn't is
|
||||
//! damaged and refused, never patched.
|
||||
//! - 1 to [`MAX_SHEETS`] sheets and at most [`MAX_SOURCES`] sources; names of 1 to
|
||||
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
|
||||
//! - 1 to [`MAX_SHEETS`] sheets; names of 1 to [`MAX_NAME_BYTES`] bytes.
|
||||
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
|
||||
//! order, maps sorted.
|
||||
//! order, maps sorted, and lists of records in a fixed order (sheets as arranged; later
|
||||
//! records by id), never the order they were added in. Records listed by id read in that order
|
||||
//! whatever order a file lists them in: the order means nothing, so it is never damage.
|
||||
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
|
||||
//!
|
||||
//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the
|
||||
//! family's format check, every number in range, the upgrade, the typed record, then
|
||||
//! [`Drawing::validate`].
|
||||
//! [`Drawing::from_json`] reads a drawing in this order: at most [`MAX_DRAWING_BYTES`] of text,
|
||||
//! the family's format check, every number in range, the upgrade, the typed record with its views,
|
||||
//! dimensions, balloons, images and notes by id, then [`Drawing::validate`] and each image's file
|
||||
//! ([`Drawing::check_image_files`]); [`Drawing::from_value`] the same from the format check on.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::Aabb2;
|
||||
use solidcraft_geom::cell::Projection;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
|
||||
use solidcraft_geom::revision::RevisionRecord;
|
||||
use solidcraft_geom::title_block::TitleBlock;
|
||||
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::{self, IdSource};
|
||||
use crate::{DocError, Metadata, Result, expr};
|
||||
|
||||
pub mod balloon;
|
||||
pub mod by_id;
|
||||
pub mod dimension;
|
||||
pub mod fields;
|
||||
pub mod image;
|
||||
pub mod note;
|
||||
pub mod table;
|
||||
pub mod template;
|
||||
pub mod view;
|
||||
|
||||
use balloon::Balloon;
|
||||
use by_id::ById;
|
||||
use dimension::Dimension;
|
||||
use image::{ImageFile, SheetImage};
|
||||
use note::{Anchor, Note};
|
||||
use table::SheetTable;
|
||||
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 +111,60 @@ pub struct Drawing {
|
||||
/// otherwise.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub format_pending: Vec<String>,
|
||||
/// Unique among its design's drawings: a design container's entry for it is named by it.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The designs the drawing draws.
|
||||
/// The designs the drawing draws: for now at most one, the design it is in.
|
||||
pub sources: Vec<Source>,
|
||||
/// The drawing's properties and other tools' data, kept as they are.
|
||||
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
|
||||
pub properties: Metadata,
|
||||
/// The drawing's properties and other tools' data, kept as they are, behind an `Arc` as the
|
||||
/// sheets are, so an undo step shares them while they don't change.
|
||||
#[serde(default, skip_serializing_if = "no_properties")]
|
||||
pub properties: Arc<Metadata>,
|
||||
#[serde(default)]
|
||||
pub units: Units,
|
||||
/// First or third angle: what every projected view shows, and the title block's symbol.
|
||||
#[serde(default)]
|
||||
pub projection: Projection,
|
||||
/// The language of the drawing's content, a BCP 47 tag (`en-GB`).
|
||||
pub language: String,
|
||||
/// The font family its text is set in, by name; empty: the built-in font.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub font: String,
|
||||
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
|
||||
pub sheets: Vec<Arc<Sheet>>,
|
||||
/// By id ([`Drawing::add_views`]), each behind its own `Arc`, as the sheets are.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub views: Vec<Arc<View>>,
|
||||
/// By id ([`Drawing::add_dimensions`]), each behind its own `Arc`, as the views are.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dimensions: Vec<Arc<Dimension>>,
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub balloons: ById<Balloon>,
|
||||
/// The image files, once each however many places they sit.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub images: ById<ImageFile>,
|
||||
/// Where they sit on the sheets.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub sheet_images: ById<SheetImage>,
|
||||
/// The saved tables on its sheets: parts lists and revision tables.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub tables: ById<SheetTable>,
|
||||
/// The notes on its sheets and in its views.
|
||||
#[serde(default, skip_serializing_if = "ById::is_empty")]
|
||||
pub notes: ById<Note>,
|
||||
/// Behind an `Arc` as the sheets are, so an undo step shares it while it doesn't change.
|
||||
#[serde(default, skip_serializing_if = "no_revisions")]
|
||||
pub revisions: Arc<RevisionRecord>,
|
||||
}
|
||||
|
||||
/// A design the drawing draws.
|
||||
/// A design the drawing draws: the design the drawing is in. Views and sheets name it by its id,
|
||||
/// so a drawing of other designs can add sources of another kind later.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Source {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// The design file: relative to the drawing's folder once the drawing is saved, else as given
|
||||
/// (`/` separates folders).
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// The length unit a drawing gives its measurements in.
|
||||
@@ -119,6 +189,14 @@ pub struct Sheet {
|
||||
pub sheet: drawing_sheet::Sheet,
|
||||
/// A copy of the drawing border's profile (`None`: no drawing border).
|
||||
pub border: Option<DrawingBorderProfile>,
|
||||
/// A copy of its title block's definition (`None`: no title block), on the drawing border's
|
||||
/// lower-right corner, so only a sheet with a border has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title_block: Option<TitleBlock>,
|
||||
/// Text shown in place of a title block cell's value, by the cell's name: the value is still
|
||||
/// worked out, so it can be shown beside it.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub title_block_overrides: BTreeMap<String, String>,
|
||||
/// The resolution its pictures (its shaded views) are rendered at, in dots per inch.
|
||||
#[serde(default = "default_dpi")]
|
||||
pub dpi: f64,
|
||||
@@ -149,49 +227,150 @@ pub struct Excluded {
|
||||
pub print: bool,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
/// Where the sheet's drawing goes, in paper millimetres: inside its drawing border, or the
|
||||
/// whole paper when it has none. Nothing on a sheet is saved against its border, so a change
|
||||
/// of paper regrows the border and its zones by itself.
|
||||
pub fn drawing_space(&self) -> Result<Aabb2> {
|
||||
match &self.border {
|
||||
Some(border) => Ok(border.layout(&self.sheet).map_err(from_sheet)?.drawing_border),
|
||||
None => Ok(Aabb2 { min: Vec2::ZERO, max: self.sheet.size_mm().map_err(from_sheet)? }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where its title block sits, in paper millimetres (none without one), once its drawing border
|
||||
/// and the block on it lay out on the paper: the rule a sheet is held to, as drawing them uses it.
|
||||
pub fn title_block_outline(&self) -> Result<Option<Aabb2>> {
|
||||
let border = self.border.as_ref().map(|border| border.layout(&self.sheet)).transpose().map_err(from_sheet)?;
|
||||
let Some(block) = &self.title_block else { return Ok(None) };
|
||||
let border = border.ok_or_else(|| DocError::Invalid("its title block sits on a drawing border, and it has none".into()))?;
|
||||
Ok(Some(block.layout(&border).map_err(|e| DocError::Invalid(e.to_string()))?.outline))
|
||||
}
|
||||
|
||||
/// Gives the sheet `block` (`None`: no title block). Text typed over a cell stays while `block`
|
||||
/// has a text cell of that name; answers the cells whose typed text is dropped.
|
||||
pub fn set_title_block(&mut self, block: Option<TitleBlock>) -> Vec<String> {
|
||||
let kept = |name: &String| fields::overridable(block.as_ref(), name);
|
||||
let lost = self.title_block_overrides.keys().filter(|name| !kept(name)).cloned().collect();
|
||||
self.title_block_overrides.retain(|name, _| kept(name));
|
||||
self.title_block = block;
|
||||
lost
|
||||
}
|
||||
}
|
||||
|
||||
/// What is on one of a drawing's sheets ([`Drawing::on_sheet`]), each kind by id.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OnSheet {
|
||||
pub views: Vec<u64>,
|
||||
/// The dimensions in its views.
|
||||
pub dimensions: Vec<u64>,
|
||||
/// The balloons in its views.
|
||||
pub balloons: Vec<u64>,
|
||||
/// The images placed on it.
|
||||
pub images: Vec<u64>,
|
||||
/// The revision tags on it.
|
||||
pub tags: Vec<u64>,
|
||||
/// The tables on it.
|
||||
pub tables: Vec<u64>,
|
||||
/// The notes on it and in its views.
|
||||
pub notes: Vec<u64>,
|
||||
}
|
||||
|
||||
impl OnSheet {
|
||||
/// Nothing is on the sheet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.views.is_empty()
|
||||
&& self.dimensions.is_empty()
|
||||
&& self.balloons.is_empty()
|
||||
&& self.images.is_empty()
|
||||
&& self.tags.is_empty()
|
||||
&& self.tables.is_empty()
|
||||
&& self.notes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dpi() -> f64 {
|
||||
DEFAULT_DPI
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// A drawing of no designs yet, named `name` and written in `language`, with one sheet named
|
||||
/// `sheet`: the paper `paper` with a copy of `border`, its id from `id_source`.
|
||||
/// `sheet`: the paper `paper` with a copy of `border`. Its id, and then its sheet's, come from
|
||||
/// `id_source`, the drawing's differing from every one in `taken` (its design's drawings').
|
||||
pub fn new(
|
||||
name: &str,
|
||||
language: &str,
|
||||
sheet: &str,
|
||||
paper: drawing_sheet::Sheet,
|
||||
border: Option<DrawingBorderProfile>,
|
||||
taken: &BTreeSet<u64>,
|
||||
id_source: &mut IdSource,
|
||||
) -> Result<Drawing> {
|
||||
let id = id_source.next(&BTreeSet::new())?;
|
||||
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
|
||||
let id = id_source.next(taken)?;
|
||||
let sheet_id = id_source.next(&BTreeSet::from([id]))?;
|
||||
let first = Sheet {
|
||||
id: sheet_id,
|
||||
name: sheet.into(),
|
||||
sheet: paper,
|
||||
border,
|
||||
title_block: None,
|
||||
title_block_overrides: BTreeMap::new(),
|
||||
dpi: DEFAULT_DPI,
|
||||
documents: None,
|
||||
excluded: Excluded::default(),
|
||||
};
|
||||
let drawing = Drawing {
|
||||
format: DRAWING.format_string(DRAWING.current()),
|
||||
format_pending: DRAWING.pending_names(),
|
||||
id,
|
||||
name: name.into(),
|
||||
sources: Vec::new(),
|
||||
properties: Metadata::default(),
|
||||
properties: Arc::default(),
|
||||
units: Units::default(),
|
||||
projection: Projection::default(),
|
||||
language: language.into(),
|
||||
font: String::new(),
|
||||
sheets: vec![Arc::new(first)],
|
||||
views: Vec::new(),
|
||||
dimensions: Vec::new(),
|
||||
balloons: ById::default(),
|
||||
images: ById::default(),
|
||||
sheet_images: ById::default(),
|
||||
tables: ById::default(),
|
||||
notes: ById::default(),
|
||||
revisions: Arc::default(),
|
||||
};
|
||||
drawing.validate()?;
|
||||
Ok(drawing)
|
||||
}
|
||||
|
||||
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
|
||||
/// Read a drawing's JSON (any format this build knows; older ones are upgraded). A drawing
|
||||
/// that breaks a rule of the record is damaged and refused, never patched.
|
||||
pub fn from_json(s: &str) -> Result<Drawing> {
|
||||
if s.len() > MAX_DRAWING_BYTES {
|
||||
return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20)));
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
let v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
Drawing::from_value(v)
|
||||
}
|
||||
|
||||
/// Read a drawing from its JSON value, as a plain JSON design holds it: [`Drawing::from_json`]
|
||||
/// once the text is parsed.
|
||||
pub fn from_value(mut v: Value) -> Result<Drawing> {
|
||||
let (version, applied) = DRAWING.take_version(&mut v)?;
|
||||
DRAWING.check_numbers(&v, "properties")?;
|
||||
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
let mut d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
let (views, dimensions) = (std::mem::take(&mut d.views), std::mem::take(&mut d.dimensions));
|
||||
d.add_views(views);
|
||||
d.add_dimensions(dimensions);
|
||||
d.balloons.put_in_order();
|
||||
d.images.put_in_order();
|
||||
d.sheet_images.put_in_order();
|
||||
d.tables.put_in_order();
|
||||
d.notes.put_in_order();
|
||||
d.validate().map_err(damaged)?;
|
||||
d.check_image_files().map_err(damaged)?;
|
||||
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
|
||||
}
|
||||
|
||||
@@ -211,21 +390,105 @@ impl Drawing {
|
||||
id_source.next(&self.ids().collect())
|
||||
}
|
||||
|
||||
/// The position of the sheet `named` names: by id, else by a name only one sheet has.
|
||||
pub fn sheet_index(&self, named: &str) -> Result<usize> {
|
||||
if let Some(i) = ids::decode(named).and_then(|id| self.sheets.iter().position(|s| s.id == id)) {
|
||||
return Ok(i);
|
||||
}
|
||||
let mut called = self.sheets.iter().enumerate().filter(|(_, s)| s.name == named).map(|(i, _)| i);
|
||||
match (called.next(), called.next()) {
|
||||
(Some(i), None) => Ok(i),
|
||||
(Some(_), Some(_)) => Err(DocError::Invalid(format!("more than one sheet is called `{named}`: give its id"))),
|
||||
(None, _) => Err(DocError::Invalid(format!("no sheet `{named}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` names an object a sheet shows: a view, a dimension or a balloon.
|
||||
pub fn has_object(&self, id: u64) -> bool {
|
||||
self.view(id).is_some() || self.dimension(id).is_some() || self.balloons.by_id(id).is_some() || self.notes.by_id(id).is_some()
|
||||
}
|
||||
|
||||
/// What is on sheet `id`: its views and the dimensions and balloons in them, and the images placed on it.
|
||||
/// Each later kind of record on a sheet adds its own here, and goes with the sheet
|
||||
/// ([`Drawing::remove_sheet`]).
|
||||
pub fn on_sheet(&self, id: u64) -> OnSheet {
|
||||
// The views are listed by id, so the sheet's are too, and so are its images, revision tags, tables and notes.
|
||||
let mut on = self.with_contents(self.views.iter().filter(|v| v.sheet == id).map(|v| v.id).collect());
|
||||
on.images = self.sheet_images.iter().filter(|i| i.sheet == id).map(|i| i.id).collect();
|
||||
on.tags = self.revisions.tags().iter().filter(|t| t.sheet == id).map(|t| t.id).collect();
|
||||
on.tables = self.tables.iter().filter(|t| t.sheet == id).map(|t| t.id).collect();
|
||||
let in_views = |n: &Note| n.anchor.view().is_some_and(|v| on.views.binary_search(&v).is_ok());
|
||||
on.notes = self.notes.iter().filter(|n| n.anchor == Anchor::Sheet(id) || in_views(n)).map(|n| n.id).collect();
|
||||
on
|
||||
}
|
||||
|
||||
/// `views`, listed by id, with the dimensions and balloons in them. An image sits on a sheet, never in a
|
||||
/// view, so there are none.
|
||||
pub fn with_contents(&self, views: Vec<u64>) -> OnSheet {
|
||||
let dimensions = self.dimensions.iter().filter(|d| views.binary_search(&d.view).is_ok()).map(|d| d.id).collect();
|
||||
let balloons = self.balloons.iter().filter(|b| views.binary_search(&b.view).is_ok()).map(|b| b.id).collect();
|
||||
let notes = self.notes.iter().filter(|n| n.anchor.view().is_some_and(|v| views.binary_search(&v).is_ok())).map(|n| n.id).collect();
|
||||
OnSheet { views, dimensions, balloons, images: Vec::new(), tags: Vec::new(), tables: Vec::new(), notes }
|
||||
}
|
||||
|
||||
/// Takes sheet `id` out with everything on it ([`Drawing::on_sheet`]), and answers what that
|
||||
/// was. A view on another sheet projected from one of its views is left without its parent,
|
||||
/// which [`Drawing::validate`] refuses: what becomes of it is the caller's to decide first.
|
||||
pub fn remove_sheet(&mut self, id: u64) -> OnSheet {
|
||||
let on = self.on_sheet(id);
|
||||
self.sheets.retain(|s| s.id != id);
|
||||
self.remove_objects(&on);
|
||||
on
|
||||
}
|
||||
|
||||
/// Takes `objects` out, each list by id: the one rule a sheet's delete and a selection's delete
|
||||
/// remove by, with the image files nothing uses any more. As in [`Drawing::remove_sheet`], what is
|
||||
/// left must still keep the record's rules.
|
||||
pub fn remove_objects(&mut self, objects: &OnSheet) {
|
||||
self.views.retain(|v| objects.views.binary_search(&v.id).is_err());
|
||||
self.dimensions.retain(|d| objects.dimensions.binary_search(&d.id).is_err());
|
||||
self.balloons.retain(|b| objects.balloons.binary_search(&b.id).is_err());
|
||||
self.sheet_images.retain(|i| objects.images.binary_search(&i.id).is_err());
|
||||
self.drop_unused_images();
|
||||
self.tables.retain(|t| objects.tables.binary_search(&t.id).is_err());
|
||||
self.notes.retain(|n| objects.notes.binary_search(&n.id).is_err());
|
||||
// A balloon outlives the list it named, and goes back to the lists it could read.
|
||||
for balloon in self.balloons.iter_mut() {
|
||||
if balloon.parts_list.is_some_and(|list| objects.tables.binary_search(&list).is_ok()) {
|
||||
Arc::make_mut(balloon).parts_list = None;
|
||||
}
|
||||
}
|
||||
if !objects.tags.is_empty() {
|
||||
Arc::make_mut(&mut self.revisions).delete_tags(&objects.tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
|
||||
/// own here.
|
||||
fn ids(&self) -> impl Iterator<Item = u64> {
|
||||
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
|
||||
std::iter::once(self.id)
|
||||
.chain(self.sources.iter().map(|s| s.id))
|
||||
.chain(self.sheets.iter().map(|s| s.id))
|
||||
.chain(self.views.iter().map(|v| v.id))
|
||||
.chain(self.dimensions.iter().map(|d| d.id))
|
||||
.chain(self.balloons.iter().map(|b| b.id))
|
||||
.chain(self.images.iter().map(|i| i.id))
|
||||
.chain(self.sheet_images.iter().map(|i| i.id))
|
||||
.chain(self.tables.iter().map(|t| t.id))
|
||||
.chain(self.notes.iter().map(|n| n.id))
|
||||
.chain(self.revisions.ids())
|
||||
}
|
||||
|
||||
/// The record's rules (the module's notes): what reading a file checks, and what every edit
|
||||
/// The record's rules (the module's notes): what reading a drawing checks, and what every edit
|
||||
/// keeps.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
|
||||
require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
|
||||
require(self.sources.len() <= 1, || "a drawing draws only the design it is in".into())?;
|
||||
check_name(&self.name, "the drawing's")?;
|
||||
require(is_language_tag(&self.language), || {
|
||||
format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)")
|
||||
})?;
|
||||
solidcraft_sketch::check_family(&self.font).map_err(|e| DocError::Invalid(format!("the drawing's font: {e}")))?;
|
||||
if let Units::Fixed { unit } = &self.units {
|
||||
// A length unit of the expression unit table, written as the table writes it.
|
||||
let length = unit.trim() == unit.as_str() && expr::unit_info(unit).is_some_and(|(kind, _)| kind == expr::Kind::Length);
|
||||
@@ -236,25 +499,38 @@ 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()))?;
|
||||
require(self.revisions.tags().iter().all(|t| self.sheets.iter().any(|s| s.id == t.sheet)), || {
|
||||
"a revision tag is on one of the drawing's sheets".into()
|
||||
})?;
|
||||
self.check_views()?;
|
||||
self.check_dimensions()?;
|
||||
self.check_tables()?;
|
||||
self.check_balloons()?;
|
||||
self.check_images()?;
|
||||
self.check_notes()
|
||||
}
|
||||
|
||||
/// A sheet's own rules, and that the source it documents is one of the drawing's.
|
||||
fn check_sheet(&self, sheet: &Sheet) -> Result<()> {
|
||||
check_name(&sheet.name, "its")?;
|
||||
sheet.sheet.size_mm().map_err(from_sheet)?;
|
||||
if let Some(border) = &sheet.border {
|
||||
border.validate().map_err(from_sheet)?;
|
||||
sheet.title_block_outline()?;
|
||||
if let Some(block) = &sheet.title_block {
|
||||
fields::check_block(block)?;
|
||||
}
|
||||
// Each override names a text cell of its block, and its text is a cell's.
|
||||
for name in sheet.title_block_overrides.keys() {
|
||||
let named = || format!("its title block has no text cell `{name}` to override");
|
||||
require(fields::overridable(sheet.title_block.as_ref(), name), named)?;
|
||||
}
|
||||
let texts = sheet.title_block_overrides.values().map(String::as_str);
|
||||
solidcraft_geom::cell::check_text(texts).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
|
||||
match sheet.documents {
|
||||
Some(SheetComponent { source, .. }) => {
|
||||
@@ -264,6 +540,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,178 @@
|
||||
//! 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, worked out and never saved: the one it names,
|
||||
//! else its sheet's only one, else the drawing's only one ([`Drawing::parts_list_for`]). It keeps the
|
||||
//! balloon whatever becomes of the row. A parts list's editor shows how many balloons point at each of
|
||||
//! its rows ([`balloons_by_row`]), worked out the same way.
|
||||
//!
|
||||
//! 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`]); the list it names is one of the drawing's parts lists; 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::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::drawing_view::is_paper_coordinate;
|
||||
use solidcraft_geom::paper::MAX_SIDE_MM;
|
||||
|
||||
use super::by_id::Record;
|
||||
use super::dimension::{Lookup, ModelEdge, Status, check_edge};
|
||||
use super::table::{Kind, PartsList, SheetTable};
|
||||
use super::{Drawing, require};
|
||||
use crate::bom::{Bom, Tree, item_text};
|
||||
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],
|
||||
/// The parts list it names, when it names one.
|
||||
#[serde(default, with = "crate::ids::option", skip_serializing_if = "Option::is_none")]
|
||||
pub parts_list: Option<u64>,
|
||||
}
|
||||
|
||||
impl Balloon {
|
||||
/// The part it numbers: the occurrences its edge's body is placed through, from the top.
|
||||
pub fn occurrence(&self) -> &[u64] {
|
||||
&self.edge.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Balloon {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The parts list balloon `b` reads, by its id: the one it names, else its sheet's only one, else
|
||||
/// the drawing's only one. With none to go by, the lists it could name, by id.
|
||||
pub fn parts_list_for(&self, b: &Balloon) -> std::result::Result<(u64, PartsList), Vec<u64>> {
|
||||
let list = |t: &SheetTable| match t.kind {
|
||||
Kind::PartsList(list) => Some((t.id, list)),
|
||||
Kind::Revision => None,
|
||||
};
|
||||
let lists: Vec<(u64, u64, PartsList)> = self.tables.iter().filter_map(|t| list(t).map(|(id, l)| (id, t.sheet, l))).collect();
|
||||
if let Some(named) = lists.iter().find(|(id, ..)| Some(*id) == b.parts_list) {
|
||||
return Ok((named.0, named.2));
|
||||
}
|
||||
let sheet = self.view(b.view).map(|v| v.sheet);
|
||||
let mut on_sheet = lists.iter().filter(|(_, on, _)| Some(*on) == sheet);
|
||||
if let (Some(only), None) = (on_sheet.next(), on_sheet.next()) {
|
||||
return Ok((only.0, only.2));
|
||||
}
|
||||
match lists.as_slice() {
|
||||
[only] => Ok((only.0, only.2)),
|
||||
_ => Err(lists.iter().map(|(id, ..)| *id).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The balloons that read each parts list ([`Self::parts_list_for`]), by the list's id: each
|
||||
/// balloon's list worked out once. A balloon with no list to go by reads none.
|
||||
pub fn balloons_by_list(&self) -> HashMap<u64, Vec<&Balloon>> {
|
||||
let mut by_list: HashMap<u64, Vec<&Balloon>> = HashMap::new();
|
||||
for b in &self.balloons {
|
||||
if let Ok((list, _)) = self.parts_list_for(b) {
|
||||
by_list.entry(list).or_default().push(b);
|
||||
}
|
||||
}
|
||||
by_list
|
||||
}
|
||||
|
||||
/// The balloons' rules (the module's notes). The views and the tables are checked first, so they
|
||||
/// are in id order here.
|
||||
pub(super) fn check_balloons(&self) -> Result<()> {
|
||||
self.balloons.check(MAX_BALLOONS, "balloon", |b| self.check_balloon(b))
|
||||
}
|
||||
|
||||
fn check_balloon(&self, b: &Balloon) -> Result<()> {
|
||||
require(self.view(b.view).is_some(), || format!("it is on view {}, which the drawing doesn't have", ids::encode(b.view)))?;
|
||||
if let Some(list) = b.parts_list {
|
||||
let named = || format!("it names table {}, which isn't one of the drawing's parts lists", ids::encode(list));
|
||||
require(self.tables.by_id(list).is_some_and(|t| matches!(t.kind, Kind::PartsList(_))), named)?;
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many of `balloons`, those that read one parts list ([`Drawing::balloons_by_list`]), point at
|
||||
/// each of its rows, by item ("1.2"): every row of `listed` (that list, made from `tree` with its
|
||||
/// members) in its order, 0 included. A balloon whose row is lost counts for none.
|
||||
pub fn balloons_by_row(balloons: &[&Balloon], listed: &Bom, tree: &Tree) -> Vec<(String, usize)> {
|
||||
let mut counts: HashMap<String, usize> = HashMap::new();
|
||||
for b in balloons {
|
||||
if let Ok(item) = listed.item_of(tree, b.occurrence()) {
|
||||
*counts.entry(item).or_default() += 1;
|
||||
}
|
||||
}
|
||||
listed
|
||||
.rows_deep()
|
||||
.iter()
|
||||
.map(|(item, _)| {
|
||||
let item = item_text(item);
|
||||
let n = counts.get(&item).copied().unwrap_or(0);
|
||||
(item, n)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A balloon as the model has it now ([`Document::resolve_balloons`]): never saved.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Resolved {
|
||||
pub status: Status,
|
||||
/// Where its arrow ends, the middle of its edge, in the design's frame: when the edge was found.
|
||||
pub point: Option<Vec3>,
|
||||
/// Its edge was found by exact history, as one edge.
|
||||
pub exact: bool,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The balloons `wanted` of `drawing` (one of this design's drawings) as `model` (this
|
||||
/// design's) has them now: each one's edge found again by name, and the middle of it. A body is
|
||||
/// looked up and its edge names worked out once for the call, however many balloons name it.
|
||||
/// `Err` only for an id the drawing doesn't have.
|
||||
pub fn resolve_balloons(&self, model: &Model, drawing: &Drawing, wanted: &[u64]) -> Result<Vec<Resolved>> {
|
||||
let mut lookup = Lookup::new(self, model);
|
||||
wanted
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let b = drawing.balloons.by_id(*id).ok_or_else(|| DocError::Unknown(format!("balloon {}", ids::encode(*id))))?;
|
||||
let (status, point, exact) = lookup.middle(&b.edge);
|
||||
Ok(Resolved { status, point, exact })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "balloon_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
use solidcraft_geom::drawing_view::NamedView;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::table::{Kind, SheetTable};
|
||||
use crate::drawing::view::{View, ViewKind, ViewPlace};
|
||||
use crate::drawing::{OnSheet, Sheet, Source};
|
||||
use crate::drawing_view::{ViewDirection, ViewOverrides};
|
||||
use crate::format::DRAWING;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const SEED: u64 = 3874;
|
||||
|
||||
fn edge(name: &str, path: Vec<u64>) -> ModelEdge {
|
||||
ModelEdge { body: "Bolt".into(), origin_feature: 4, name: name.into(), path }
|
||||
}
|
||||
|
||||
/// A drawing of one design on one A3 sheet, its front view at (150, 150), and on the view two
|
||||
/// balloons: one on a bolt placed straight in the design (occurrence 3), one on a bolt inside a
|
||||
/// sub-assembly (occurrence 7 in 3). Their ids, in the order they were added.
|
||||
fn ballooned() -> (Drawing, [u64; 2]) {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Bolted", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let source = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id: source });
|
||||
let (view, sheet) = (d.allocate_id(&mut id_source).unwrap(), d.sheets[0].id);
|
||||
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
|
||||
d.add_views([Arc::new(View {
|
||||
id: view,
|
||||
sheet,
|
||||
kind,
|
||||
place: ViewPlace::At([150.0, 150.0]),
|
||||
settings: ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() },
|
||||
reference_shown_percent: 0.0,
|
||||
})]);
|
||||
let mut added = [0; 2];
|
||||
for ((path, at), id) in [(vec![3], [40.0, 25.0]), (vec![3, 7], [-35.5, 60.0])].into_iter().zip(&mut added) {
|
||||
*id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.balloons.add([Arc::new(Balloon { id: *id, view, edge: edge("F1:side|F2:head", path), at, parts_list: None })]);
|
||||
}
|
||||
d.validate().unwrap();
|
||||
(d, added)
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Balloon `id` in a drawing's file.
|
||||
fn balloon<'a>(v: &'a mut Value, id: &str) -> &'a mut Value {
|
||||
v["balloons"].as_array_mut().unwrap().iter_mut().find(|x| x["id"] == id).unwrap()
|
||||
}
|
||||
|
||||
/// Why `d`'s file, changed by `edit`, is refused, or "read".
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
/// A balloon saves its view, its edge by name with the part's occurrence path, and its place, and
|
||||
/// nothing worked out. Balloons save by id and read back as saved, the same bytes again; a file
|
||||
/// that lists them in another order reads by id. An older drawing reads with none, and a drawing
|
||||
/// with none writes none.
|
||||
#[test]
|
||||
fn balloons_save_what_they_point_at_and_read_back_as_saved() {
|
||||
let (d, added) = ballooned();
|
||||
let [placed, nested] = added.map(ids::encode);
|
||||
let view = ids::encode(d.views[0].id);
|
||||
let mut v = file(&d);
|
||||
assert_eq!(
|
||||
*balloon(&mut v, &nested),
|
||||
json!({
|
||||
"id": nested, "view": view,
|
||||
"edge": {"body": "Bolt", "origin_feature": 4, "name": "F1:side|F2:head", "path": [3, 7]},
|
||||
"at": [-35.5, 60.0]
|
||||
})
|
||||
);
|
||||
assert_eq!(balloon(&mut v, &placed)["edge"]["path"], json!([3]));
|
||||
assert_eq!(d.balloons.by_id(added[1]).unwrap().occurrence(), [3, 7], "the part it numbers is its edge's path");
|
||||
let listed: Vec<u64> = v["balloons"].as_array().unwrap().iter().map(|x| ids::decode(x["id"].as_str().unwrap()).unwrap()).collect();
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_eq!(listed, by_id);
|
||||
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!(reopened.to_json().unwrap(), d.to_json().unwrap());
|
||||
let mut other_order = v.clone();
|
||||
other_order["balloons"].as_array_mut().unwrap().reverse();
|
||||
assert_eq!(Drawing::from_json(&other_order.to_string()).unwrap(), d);
|
||||
let mut unsorted = d.clone();
|
||||
unsorted.balloons.reverse();
|
||||
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its balloons by id");
|
||||
let mut none = d.clone();
|
||||
none.balloons.clear();
|
||||
assert_eq!(file(&none).get("balloons"), None, "no balloons, none written");
|
||||
let older = json!({
|
||||
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
|
||||
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
|
||||
});
|
||||
assert!(Drawing::from_json(&older.to_string()).unwrap().balloons.is_empty());
|
||||
}
|
||||
|
||||
/// What reading refuses, each rule by name, on both sides of every cap: the view a balloon is on,
|
||||
/// its edge's names, feature and path (a placed part's: 1 to 64 occurrences, none of them 0), its
|
||||
/// place on paper, its id, how many there are, and fields it doesn't know.
|
||||
#[test]
|
||||
fn the_balloons_rules_are_kept_on_reading() {
|
||||
let (d, added) = ballooned();
|
||||
let [placed, nested] = added.map(ids::encode);
|
||||
let rule = |id: &str, rule: &str| format!("damaged drawing: balloon {id}: {rule}");
|
||||
let nothing = ids::encode(99);
|
||||
let on = format!("it is on view {nothing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["view"] = json!(nothing)), rule(&placed, &on));
|
||||
// The edge keeps a dimension's reference rules.
|
||||
let names = "a name in a reference is 1 to 512 bytes, without control characters";
|
||||
let body = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["body"] = json!(name));
|
||||
let edge_name = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["name"] = json!(name));
|
||||
for fits in ["x".repeat(512), "é".repeat(256)] {
|
||||
assert_eq!([body(&fits), edge_name(&fits)], ["read", "read"], "{} bytes", fits.len());
|
||||
}
|
||||
for broken in ["x".repeat(513), String::new(), "two\nlines".into()] {
|
||||
assert_eq!([body(&broken), edge_name(&broken)], [rule(&nested, names), rule(&nested, names)], "{} bytes", broken.len());
|
||||
}
|
||||
let feature = rule(&placed, "a reference names the feature that made its body");
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["edge"]["origin_feature"] = json!(0)), feature);
|
||||
// A placed part's path: 1 to 64 occurrences, none of them 0. A design's own body is no part.
|
||||
let path = |p: Value| refused(&d, |v| balloon(v, &nested)["edge"]["path"] = p);
|
||||
let deep = rule(&nested, "an occurrence path is at most 64 occurrences, none of them 0");
|
||||
let own = rule(&nested, "it points at a placed part: its edge's body is placed through an occurrence");
|
||||
assert_eq!(
|
||||
[path(json!([3])), path(json!(vec![3; 64])), path(json!(vec![3; 65])), path(json!([3, 0])), path(json!([]))],
|
||||
["read".to_string(), "read".into(), deep.clone(), deep, own.clone()]
|
||||
);
|
||||
let no_path = refused(&d, |v| {
|
||||
balloon(v, &nested)["edge"].as_object_mut().unwrap().remove("path");
|
||||
});
|
||||
assert_eq!(no_path, own, "no path");
|
||||
// Its place is on paper.
|
||||
let paper = rule(&placed, "its place is at most 5000 mm on paper");
|
||||
let at = |at: [f64; 2]| refused(&d, |v| balloon(v, &placed)["at"] = json!(at));
|
||||
assert_eq!([at([5000.0, -5000.0]), at([5000.001, 0.0]), at([0.0, -5000.001])], ["read".to_string(), paper.clone(), paper]);
|
||||
assert!(refused(&d, |v| balloon(v, &placed)["at"] = json!([1.0])).starts_with("damaged drawing: "), "two numbers");
|
||||
// Its id is the drawing's: never another object's.
|
||||
let view_id = ids::encode(d.views[0].id);
|
||||
assert_eq!(refused(&d, |v| balloon(v, &placed)["id"] = json!(view_id)), format!("damaged drawing: two objects have the id {view_id}"));
|
||||
// Fields it doesn't know, at every level.
|
||||
let unknown: [fn(&mut Value, &str); 3] = [
|
||||
|v, id| balloon(v, id)["item"] = json!(3),
|
||||
|v, id| balloon(v, id)["edge"]["point"] = json!([0.0, 0.0, 0.0]),
|
||||
|v, id| balloon(v, id)["shape"] = json!("circle"),
|
||||
];
|
||||
for (i, edit) in unknown.into_iter().enumerate() {
|
||||
assert!(refused(&d, |v| edit(v, &placed)).starts_with("damaged drawing: "), "{i}");
|
||||
}
|
||||
// At most MAX_BALLOONS balloons.
|
||||
let many = |n: usize| {
|
||||
let mut v = file(&d);
|
||||
let one = balloon(&mut v, &placed).clone();
|
||||
let copies = (1..=n as u64).map(|i| {
|
||||
let mut copy = one.clone();
|
||||
copy["id"] = json!(ids::encode(i));
|
||||
copy
|
||||
});
|
||||
v["balloons"] = Value::Array(copies.collect());
|
||||
Drawing::from_json(&v.to_string()).map(|d| d.balloons.len()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(many(MAX_BALLOONS), Ok(MAX_BALLOONS));
|
||||
assert_eq!(many(MAX_BALLOONS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_BALLOONS} balloons")));
|
||||
}
|
||||
|
||||
/// A sheet's balloons are its views' and go with it; a new id never takes a balloon's.
|
||||
#[test]
|
||||
fn a_sheets_balloons_go_with_it() {
|
||||
let (mut d, added) = ballooned();
|
||||
let sheet = d.sheets[0].id;
|
||||
let on = d.on_sheet(sheet);
|
||||
let mut by_id = added.to_vec();
|
||||
by_id.sort_unstable();
|
||||
assert_eq!(on.balloons, by_id);
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
for _ in 0..64 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
assert!(!added.contains(&id), "a new id never takes a balloon's");
|
||||
}
|
||||
let second = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheets.push(Arc::new(crate::drawing::Sheet { id: second, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }));
|
||||
assert_eq!(d.remove_sheet(sheet), on);
|
||||
d.validate().unwrap();
|
||||
assert!(d.balloons.is_empty() && d.views.is_empty());
|
||||
}
|
||||
|
||||
/// A parts list on `d`'s sheet `sheet`, with a fresh id.
|
||||
fn add_list(d: &mut Drawing, sheet: u64, id: u64) {
|
||||
use crate::drawing::SheetComponent;
|
||||
use crate::drawing::table::{MassFormat, PartsList};
|
||||
let of = SheetComponent { source: d.sources[0].id, component: 0 };
|
||||
let list =
|
||||
PartsList { of, view: Default::default(), group: Default::default(), fold: false, purchased_contents: false, mass: MassFormat::default() };
|
||||
let kind = Kind::PartsList(list);
|
||||
d.tables.add([Arc::new(SheetTable { id, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
|
||||
}
|
||||
|
||||
/// With no link a balloon reads its sheet's only list, else the drawing's only one, else none, with
|
||||
/// the choices listed; a revision table is no choice. Its link wins. Saved only when it names one.
|
||||
#[test]
|
||||
fn a_balloon_reads_the_list_it_names_else_the_only_one() {
|
||||
let (mut d, [id, _]) = ballooned();
|
||||
let sheet = d.sheets[0].id;
|
||||
let read = |d: &Drawing| d.parts_list_for(d.balloons.by_id(id).unwrap()).map(|(list, _)| list);
|
||||
assert_eq!(read(&d), Err(vec![]));
|
||||
let saved: Value = serde_json::from_str(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(saved["balloons"][0].get("parts_list"), None);
|
||||
|
||||
let other = 1 << 40;
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
d.sheets.push(Arc::new(Sheet { id: other, name: "Other".into(), sheet: a4, ..(*d.sheets[0]).clone() }));
|
||||
add_list(&mut d, other, 1 << 41);
|
||||
assert_eq!(read(&d), Ok(1 << 41), "the drawing's only one");
|
||||
add_list(&mut d, sheet, 1 << 42);
|
||||
assert_eq!(read(&d), Ok(1 << 42), "the sheet's only one comes first");
|
||||
add_list(&mut d, sheet, 1 << 43);
|
||||
assert_eq!(read(&d), Err(vec![1 << 41, 1 << 42, 1 << 43]), "the choices");
|
||||
assert!(d.balloons_by_list().is_empty(), "with none to go by, a balloon reads no list");
|
||||
let kind = Kind::Revision;
|
||||
d.tables.add([Arc::new(SheetTable { id: 1 << 44, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
|
||||
assert_eq!(read(&d).unwrap_err().len(), 3, "a revision table is no choice");
|
||||
|
||||
let at = d.balloons.index_of(id).unwrap();
|
||||
Arc::make_mut(&mut d.balloons[at]).parts_list = Some(1 << 41);
|
||||
assert_eq!(read(&d), Ok(1 << 41), "its link wins");
|
||||
let by_list = d.balloons_by_list();
|
||||
assert_eq!((by_list.len(), by_list[&(1 << 41)].iter().map(|b| b.id).collect::<Vec<_>>()), (1, vec![id]), "the other reads none");
|
||||
d.validate().unwrap();
|
||||
assert_eq!(Drawing::from_json(&d.to_json().unwrap()).unwrap(), d);
|
||||
|
||||
// The list goes, by its own delete or its sheet's: the balloon stays and reads what it can.
|
||||
d.remove_objects(&OnSheet { tables: vec![1 << 41], ..Default::default() });
|
||||
assert_eq!(d.balloons.by_id(id).unwrap().parts_list, None);
|
||||
d.validate().unwrap();
|
||||
assert_eq!(read(&d).unwrap_err(), vec![1 << 42, 1 << 43]);
|
||||
}
|
||||
|
||||
/// A link names one of the drawing's parts lists.
|
||||
#[test]
|
||||
fn a_balloons_link_is_to_a_parts_list() {
|
||||
let (mut d, [id, _]) = ballooned();
|
||||
let sheet = d.sheets[0].id;
|
||||
let kind = Kind::Revision;
|
||||
d.tables.add([Arc::new(SheetTable { id: 1 << 44, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
|
||||
let at = d.balloons.index_of(id).unwrap();
|
||||
for bad in [1 << 44, 99] {
|
||||
Arc::make_mut(&mut d.balloons[at]).parts_list = Some(bad);
|
||||
assert!(d.validate().unwrap_err().to_string().contains("parts lists"), "{bad}");
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(&Drawing { balloons: Default::default(), ..d.clone() }.to_json().unwrap()).unwrap();
|
||||
v["balloons"] = json!([{"id": "x", "view": "x", "edge": {}, "at": [0.0, 0.0], "parts_list": 7}]);
|
||||
assert!(Drawing::from_value(v).is_err(), "not an id");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! A drawing's list of one kind of record: each behind its own `Arc`, so an undo step shares what it
|
||||
//! didn't change, and by id, whatever order they came in. It holds what every such list does, so a
|
||||
//! kind's own file holds only its own rules.
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::require;
|
||||
use crate::{DocError, Result, ids};
|
||||
|
||||
/// A record a drawing lists by its id.
|
||||
pub trait Record {
|
||||
fn id(&self) -> u64;
|
||||
}
|
||||
|
||||
/// The records of one kind, by id. It reads as the `Vec` it is, and a file holds them as one.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ById<T>(Vec<Arc<T>>);
|
||||
|
||||
impl<T> Default for ById<T> {
|
||||
fn default() -> Self {
|
||||
ById(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for ById<T> {
|
||||
type Target = Vec<Arc<T>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for ById<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a ById<T> {
|
||||
type Item = &'a Arc<T>;
|
||||
type IntoIter = std::slice::Iter<'a, Arc<T>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ById<T> {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Record> ById<T> {
|
||||
/// Adds `more`, each where its id puts it.
|
||||
pub fn add(&mut self, more: impl IntoIterator<Item = Arc<T>>) {
|
||||
self.0.extend(more);
|
||||
self.put_in_order();
|
||||
}
|
||||
|
||||
/// Puts what a file listed in another order where each id puts it.
|
||||
pub(super) fn put_in_order(&mut self) {
|
||||
self.0.sort_by_key(|record| record.id());
|
||||
}
|
||||
|
||||
/// Where record `id` is in the list, found by halves.
|
||||
pub fn index_of(&self, id: u64) -> Option<usize> {
|
||||
self.0.binary_search_by_key(&id, |record| record.id()).ok()
|
||||
}
|
||||
|
||||
/// Record `id`.
|
||||
pub fn by_id(&self, id: u64) -> Option<&T> {
|
||||
self.index_of(id).and_then(|at| self.0.get(at)).map(|record| &**record)
|
||||
}
|
||||
|
||||
/// The list's rules, each named by `word`: at most `most` records, by id, each keeping `rule`.
|
||||
pub(super) fn check(&self, most: usize, word: &str, rule: impl Fn(&T) -> Result<()>) -> Result<()> {
|
||||
require(self.0.len() <= most, || format!("a drawing has at most {most} {word}s"))?;
|
||||
require(self.0.is_sorted_by_key(|record| record.id()), || format!("a drawing lists its {word}s by id"))?;
|
||||
self.0.iter().try_for_each(|record| rule(record).map_err(|e| DocError::Invalid(format!("{word} {}: {e}", ids::encode(record.id())))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "by_id_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Thing(u64);
|
||||
|
||||
impl Record for Thing {
|
||||
fn id(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn things(ids: &[u64]) -> ById<Thing> {
|
||||
let mut list = ById::default();
|
||||
list.add(ids.iter().map(|id| Arc::new(Thing(*id))));
|
||||
list
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_go_where_their_ids_put_them_and_are_found_there() {
|
||||
let mut list = things(&[30, 10]);
|
||||
list.add([Arc::new(Thing(20))]);
|
||||
assert_eq!(list.iter().map(|t| t.0).collect::<Vec<_>>(), [10, 20, 30]);
|
||||
assert_eq!((list.index_of(20), list.by_id(30).map(|t| t.0)), (Some(1), Some(30)));
|
||||
assert_eq!((list.index_of(25), list.by_id(25)), (None, None));
|
||||
assert!(ById::<Thing>::default().is_empty() && !list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_holds_the_plain_list() {
|
||||
let list = things(&[1, 2]);
|
||||
assert_eq!(serde_json::to_value(&list).unwrap(), json!([1, 2]));
|
||||
assert_eq!(serde_json::from_value::<ById<Thing>>(json!([1, 2])).unwrap(), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rules_name_the_kind_and_the_record() {
|
||||
let fine = |_: &Thing| Ok(());
|
||||
things(&[1, 2]).check(2, "thing", fine).unwrap();
|
||||
assert!(things(&[1, 2, 3]).check(2, "thing", fine).is_err_and(|e| e.to_string().contains("at most 2 things")));
|
||||
let mut out_of_order = things(&[1, 2]);
|
||||
out_of_order.reverse();
|
||||
assert!(out_of_order.check(2, "thing", fine).is_err_and(|e| e.to_string().contains("lists its things by id")));
|
||||
out_of_order.put_in_order();
|
||||
out_of_order.check(2, "thing", fine).unwrap();
|
||||
let odd = |t: &Thing| require(t.0.is_multiple_of(2), || "it is odd".into());
|
||||
let e = things(&[2, 3]).check(2, "thing", odd).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("thing {}: it is odd", ids::encode(3))), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A lost edge's leads, from its body's face names: the blend face that rounds it and a piece of that
|
||||
/// face (a later cut's), then the edges they bound, then the faces of the feature that made it, at most
|
||||
/// 16, no face twice. Another fillet's face rounds another edge, so it leads nowhere. A `~k` on the
|
||||
/// name finds the same blend; a name no blend rounds leads to its makers' faces only, as a face's
|
||||
/// does, and a name that doesn't read, or whose feature made nothing here, leads nowhere.
|
||||
#[test]
|
||||
fn lost_leads_list_the_blend_then_its_edges_then_the_makers_faces() {
|
||||
let blends = ["F5:blend:F1:+z|F1:-y", "F5:blend:F1:+z|F1:-y#[F1:+x,F7:+z]", "F6:blend:F1:+x|F1:+z"];
|
||||
let mut faces: Vec<String> = ["F1:+z", "F1:-y", "F1:+x"].into_iter().chain(blends).map(String::from).collect();
|
||||
faces.extend((0..40).map(|k| format!("F1:face:{k}")));
|
||||
let leads = Leads::new(BODY, &faces);
|
||||
// Edges 0, 1, 3 and 5 bound the blend or its piece; 2 and 4 don't.
|
||||
let edge_faces = [vec![0, 3], vec![1, 3], vec![0, 2], vec![3, 4], vec![2, 5], vec![4, 1]];
|
||||
let lead_face = |index: usize| Candidate::Face { body: BODY.into(), index };
|
||||
let lead_edge = |index: usize| Candidate::Edge { body: BODY.into(), index };
|
||||
let box_faces = |n: usize| [0, 1, 2].into_iter().chain(6..).take(n).map(lead_face);
|
||||
let mut expected = vec![lead_face(3), lead_face(4), lead_edge(0), lead_edge(1), lead_edge(3), lead_edge(5)];
|
||||
expected.extend(box_faces(10));
|
||||
let found = leads.for_edge("F1:+z|F1:-y", &edge_faces);
|
||||
assert_eq!(found, expected);
|
||||
assert_eq!(found.len(), MAX_CANDIDATES);
|
||||
assert_eq!(leads.for_edge("F1:+z|F1:-y~2", &edge_faces), expected, "a numbered edge's blend");
|
||||
let makers: Vec<Candidate> = box_faces(MAX_CANDIDATES).collect();
|
||||
assert_eq!(leads.for_edge("F1:-y|F1:+x", &edge_faces), makers, "no blend rounds it");
|
||||
assert_eq!(leads.for_face("F1:+z"), makers, "a face's");
|
||||
assert!(leads.for_edge("F1:+z|", &edge_faces).is_empty(), "a name that doesn't read");
|
||||
assert!(leads.for_edge("F9:+z|F9:-y", &edge_faces).is_empty(), "a feature that made nothing here");
|
||||
}
|
||||
|
||||
/// Quarter arcs of radius `r` round `c` in the plane z = 0, from `from` degrees, as a tessellated edge.
|
||||
fn arc_at(c: [f64; 2], r: f64, from: f64) -> (usize, Vec<Vec3>) {
|
||||
let at = |d: f64| Vec3::new(c[0] + r * (from + d).to_radians().cos(), c[1] + r * (from + d).to_radians().sin(), 0.0);
|
||||
(0, (0..=9).map(|i| at(f64::from(i) * 10.0)).collect())
|
||||
}
|
||||
|
||||
/// Pieces are one circle only when every one lies on it: another centre or radius, however near, is
|
||||
/// another circle, and a straight piece isn't a circle. Pieces too short to read a circle from alone
|
||||
/// are read together, so short arcs of one circle are it and of two circles aren't; a piece of only
|
||||
/// two points could be a chord, and no pieces are none.
|
||||
#[test]
|
||||
fn pieces_are_one_circle_only_when_they_all_lie_on_it() {
|
||||
let c = [40.0, 15.0];
|
||||
let first = arc_at(c, 6.0, 0.0);
|
||||
let found = one_circle(&[first.clone(), arc_at(c, 6.0, 180.0)]).unwrap();
|
||||
assert!(found.dist(Vec3::new(40.0, 15.0, 0.0)) < 1e-9, "{found:?}");
|
||||
assert!(one_circle(std::slice::from_ref(&first)).is_some());
|
||||
assert!(one_circle(&[first.clone(), arc_at([40.0 + 1e-9, 15.0], 6.0, 180.0)]).is_some(), "rounding is the same circle");
|
||||
assert!(one_circle(&[first.clone(), arc_at([40.0 + 1e-3, 15.0], 6.0, 180.0)]).is_none(), "another centre");
|
||||
assert!(one_circle(&[first.clone(), arc_at(c, 6.0 + 1e-3, 180.0)]).is_none(), "another radius");
|
||||
let above: Vec<Vec3> = arc_at(c, 6.0, 180.0).1.iter().map(|p| Vec3::new(p.x, p.y, 5.0)).collect();
|
||||
assert!(one_circle(&[first.clone(), (1, above)]).is_none(), "the same circle moved along its axis");
|
||||
let tilted: Vec<Vec3> = arc_at(c, 6.0, 180.0).1.iter().map(|p| Vec3::new(40.0, p.y, p.x - 40.0)).collect();
|
||||
assert!(one_circle(&[first.clone(), (1, tilted)]).is_none(), "round another axis");
|
||||
let line: Vec<Vec3> = (0..5).map(|i| Vec3::new(f64::from(i), 0.0, 0.0)).collect();
|
||||
assert!(one_circle(&[first.clone(), (1, line)]).is_none(), "a straight piece");
|
||||
assert!(one_circle(&[first.clone(), (1, first.1[..3].to_vec())]).is_some(), "too short to read, but on it");
|
||||
let off: Vec<Vec3> = first.1[..3].iter().map(|p| Vec3::new(p.x + 1e-3, p.y, p.z)).collect();
|
||||
assert!(one_circle(&[first.clone(), (1, off)]).is_none(), "too short to read, and off it");
|
||||
assert!(one_circle(&[first.clone(), (1, first.1[..2].to_vec())]).is_none(), "two points could be a chord");
|
||||
let short = |c: [f64; 2]| (1, arc_at(c, 6.0, 180.0).1[..3].to_vec());
|
||||
let three = (0, first.1[..3].to_vec());
|
||||
assert!(one_circle(&[three.clone(), short(c)]).is_some(), "short arcs of one circle, read together");
|
||||
assert!(one_circle(&[three, short([45.0, 15.0])]).is_none(), "short arcs of two circles of one radius");
|
||||
assert!(one_circle(&[]).is_none());
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! A title block's fields (#3875): the keys each source has, and what they read. Stateless: the
|
||||
//! drawing gives its own fields, its sheets' and its newest revision's, and the caller hands in the
|
||||
//! component a sheet documents with what the model says of it (its material and mass, worked out
|
||||
//! by the bill of materials). A key its source doesn't have is refused when a block is given or
|
||||
//! read; a value that isn't there, or is empty, shows [`MISSING`]. Sheet tables' cells (B3) read
|
||||
//! the same fields.
|
||||
//!
|
||||
//! - **component:** `name`, `part_number`, `description`, `revision` (its part properties),
|
||||
//! `material`, `mass`, and `properties.<name>` (its free-text properties, such as a finish);
|
||||
//! - **drawing:** `name`, `number` (its `part_number` property: the drawing's identification
|
||||
//! number; without one, the name of the component the sheet documents), `organization`, `author`,
|
||||
//! `project`, and `properties.<key>` (any of its properties);
|
||||
//! - **sheet:** `name`, `number`, `count`: sheets left out of the count are neither counted nor
|
||||
//! numbered, and such a sheet shows neither;
|
||||
//! - **revision:** the newest revision row's fields ([`Field`]);
|
||||
//! - **prompt:** any key; answers come with templates, so a prompt shows [`MISSING`] until then.
|
||||
|
||||
use solidcraft_geom::cell::{Content, MISSING, Part, Source};
|
||||
use solidcraft_geom::revision::Field;
|
||||
use solidcraft_geom::title_block::{CellKind, TitleBlock};
|
||||
|
||||
use super::Drawing;
|
||||
use crate::{DocError, Metadata, Result};
|
||||
|
||||
const COMPONENT: [&str; 6] = ["name", "part_number", "description", "revision", "material", "mass"];
|
||||
const DRAWING: [&str; 5] = ["name", "number", "organization", "author", "project"];
|
||||
const SHEET: [&str; 3] = ["name", "number", "count"];
|
||||
/// Before a property's name: the field reads that property.
|
||||
const PROPERTIES: &str = "properties.";
|
||||
|
||||
/// The component a sheet documents, as its fields read it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Component<'a> {
|
||||
pub name: &'a str,
|
||||
/// Its part properties.
|
||||
pub metadata: &'a Metadata,
|
||||
/// Its free-text properties (the design's own: none).
|
||||
pub properties: Option<&'a std::collections::BTreeMap<String, String>>,
|
||||
/// What it is made of, as its bill of materials row would say: none when nothing is assigned.
|
||||
pub material: Option<&'a str>,
|
||||
/// What one of it weighs, written for the sheet: none when it can't be weighed.
|
||||
pub mass: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Whether `source` has the field `key`.
|
||||
pub fn has_key(source: Source, key: &str) -> bool {
|
||||
let property = key.strip_prefix(PROPERTIES).is_some_and(|name| !name.is_empty());
|
||||
match source {
|
||||
Source::Component => COMPONENT.contains(&key) || property,
|
||||
Source::Drawing => DRAWING.contains(&key) || property,
|
||||
Source::Sheet => SHEET.contains(&key),
|
||||
Source::Revision => Field::ALL.iter().any(|f| f.key() == key),
|
||||
Source::Prompt => !key.is_empty(),
|
||||
Source::Row => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `block` has a text cell `name`: what a sheet's override may name.
|
||||
pub fn overridable(block: Option<&TitleBlock>, name: &str) -> bool {
|
||||
block.is_some_and(|block| block.cells.iter().any(|cell| cell.name == name && matches!(cell.kind, CellKind::Text(_))))
|
||||
}
|
||||
|
||||
/// Each field of `block` names a key its source has: what a sheet holds its block to.
|
||||
pub fn check_block(block: &TitleBlock) -> Result<()> {
|
||||
for cell in &block.cells {
|
||||
let CellKind::Text(text) = &cell.kind else { continue };
|
||||
for part in &text.content.0 {
|
||||
if let Part::Field { source, key, .. } = part
|
||||
&& !has_key(*source, key)
|
||||
{
|
||||
let source = serde_json::to_value(source).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(DocError::Invalid(format!("cell `{}`: the {source} has no field `{key}`", cell.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a sheet's fields read: its drawing, its place among the drawing's sheets, and the component
|
||||
/// it documents.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Values<'a> {
|
||||
pub drawing: &'a Drawing,
|
||||
pub sheet: usize,
|
||||
pub component: Option<Component<'a>>,
|
||||
}
|
||||
|
||||
impl Values<'_> {
|
||||
/// `content` as its cell shows it: text as written, each field its value or [`MISSING`].
|
||||
pub fn text(&self, content: &Content) -> String {
|
||||
let part = |part: &Part| match part {
|
||||
Part::Text(text) => text.clone(),
|
||||
Part::Field { source, key, .. } => self.field(*source, key).filter(|v| !v.is_empty()).unwrap_or_else(|| MISSING.into()),
|
||||
};
|
||||
content.0.iter().map(part).collect()
|
||||
}
|
||||
|
||||
/// The field `key` of `source`, when it has a value.
|
||||
fn field(&self, source: Source, key: &str) -> Option<String> {
|
||||
let d = self.drawing;
|
||||
let property = |metadata: &Metadata| key.strip_prefix(PROPERTIES).and_then(|name| metadata.text(name)).map(str::to_string);
|
||||
match source {
|
||||
Source::Drawing => match key {
|
||||
"name" => Some(d.name.clone()),
|
||||
"number" => d.properties.part_number().or(self.component.map(|c| c.name)).map(str::to_string),
|
||||
"organization" | "author" | "project" => d.properties.text(key).map(str::to_string),
|
||||
_ => property(&d.properties),
|
||||
},
|
||||
Source::Sheet => {
|
||||
let sheet = d.sheets.get(self.sheet)?;
|
||||
if sheet.excluded.count {
|
||||
return (key == "name").then(|| sheet.name.clone());
|
||||
}
|
||||
let counted = |s: &&std::sync::Arc<super::Sheet>| !s.excluded.count;
|
||||
match key {
|
||||
"name" => Some(sheet.name.clone()),
|
||||
"number" => Some((d.sheets.iter().take(self.sheet).filter(counted).count() + 1).to_string()),
|
||||
"count" => Some(d.sheets.iter().filter(counted).count().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Source::Revision => {
|
||||
// The newest row in the sheet's scope: the drawing's, or the sheet's own once rows are per sheet.
|
||||
let scope = if d.revisions.per_sheet { Some(d.sheets.get(self.sheet)?.id) } else { None };
|
||||
d.revisions.newest(scope)?.field(key).map(str::to_string)
|
||||
}
|
||||
Source::Component => {
|
||||
let c = self.component?;
|
||||
match key {
|
||||
"name" => Some(c.name.to_string()),
|
||||
"material" => c.material.map(str::to_string),
|
||||
"mass" => c.mass.map(str::to_string),
|
||||
"part_number" | "description" | "revision" => c.metadata.text(key).map(str::to_string),
|
||||
_ => key.strip_prefix(PROPERTIES).and_then(|name| c.properties?.get(name)).cloned(),
|
||||
}
|
||||
}
|
||||
Source::Prompt | Source::Row => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fields_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::cell::Format;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
|
||||
use solidcraft_geom::title_block::default_block;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::Sheet;
|
||||
use crate::ids::IdSource;
|
||||
use crate::metadata::Owner;
|
||||
|
||||
/// A drawing of three A3 sheets, each with the built-in block.
|
||||
fn drawing() -> Drawing {
|
||||
let mut ids = IdSource::seeded(3875);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Bracket drawing", "en", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut ids).unwrap();
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block = Some(default_block());
|
||||
for n in 2..=3 {
|
||||
let id = d.allocate_id(&mut ids).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet { id, name: format!("Sheet {n}"), ..(*d.sheets[0]).clone() }));
|
||||
}
|
||||
d.validate().unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn field(source: Source, key: &str) -> Part {
|
||||
Part::Field { source, key: key.into(), format: Format::default() }
|
||||
}
|
||||
|
||||
fn shown(d: &Drawing, sheet: usize, component: Option<Component>, parts: Vec<Part>) -> String {
|
||||
Values { drawing: d, sheet, component }.text(&Content(parts))
|
||||
}
|
||||
|
||||
/// The drawing's own fields: its name, its number (its part number property), its organization
|
||||
/// and any property; a property taken away shows nothing, never the value it had.
|
||||
#[test]
|
||||
fn the_drawings_fields() {
|
||||
let mut d = drawing();
|
||||
let properties = Arc::make_mut(&mut d.properties);
|
||||
properties.set(Owner::Design, "part_number", json!("DRW-0042")).unwrap();
|
||||
properties.set(Owner::Design, "organization", json!("Tangency Labs")).unwrap();
|
||||
let parts = vec![
|
||||
field(Source::Drawing, "name"),
|
||||
Part::Text(" / ".into()),
|
||||
field(Source::Drawing, "number"),
|
||||
Part::Text(" / ".into()),
|
||||
field(Source::Drawing, "organization"),
|
||||
];
|
||||
assert_eq!(shown(&d, 0, None, parts.clone()), "Bracket drawing / DRW-0042 / Tangency Labs");
|
||||
Arc::make_mut(&mut d.properties).remove("organization");
|
||||
assert_eq!(shown(&d, 0, None, parts), "Bracket drawing / DRW-0042 / ");
|
||||
assert_eq!(shown(&d, 0, None, vec![field(Source::Drawing, "properties.acme.finish")]), "");
|
||||
// Without a number of its own, the drawing is numbered by the component its sheet documents,
|
||||
// by name; without either, nothing.
|
||||
let metadata = Metadata::default();
|
||||
let bracket = Component { name: "Bracket", metadata: &metadata, properties: None, material: None, mass: None };
|
||||
let number = || vec![field(Source::Drawing, "number")];
|
||||
assert_eq!(shown(&d, 0, Some(bracket), number()), "DRW-0042", "its own number first");
|
||||
Arc::make_mut(&mut d.properties).set(Owner::Design, "part_number", json!("")).unwrap();
|
||||
assert_eq!(shown(&d, 0, Some(bracket), number()), "Bracket");
|
||||
assert_eq!(shown(&d, 0, None, number()), "");
|
||||
}
|
||||
|
||||
/// Sheets are numbered among the counted ones: the second of three is "2/3". A sheet left out of
|
||||
/// the count shows neither, and the sheets after it close up.
|
||||
#[test]
|
||||
fn sheets_are_numbered_among_the_counted_ones() {
|
||||
let mut d = drawing();
|
||||
let sheet = || vec![field(Source::Sheet, "number"), Part::Text("/".into()), field(Source::Sheet, "count")];
|
||||
assert_eq!(shown(&d, 1, None, sheet()), "2/3");
|
||||
Arc::make_mut(&mut d.sheets[1]).excluded.count = true;
|
||||
assert_eq!([0, 1, 2].map(|at| shown(&d, at, None, sheet())), ["1/2", "/", "2/2"]);
|
||||
assert_eq!(shown(&d, 1, None, vec![field(Source::Sheet, "name")]), "Sheet 2");
|
||||
}
|
||||
|
||||
/// The revision fields read the newest row; with none, nothing.
|
||||
#[test]
|
||||
fn revision_fields_read_the_newest_row() {
|
||||
let mut d = drawing();
|
||||
let date = || vec![field(Source::Revision, "revision"), Part::Text(" ".into()), field(Source::Revision, "date")];
|
||||
assert_eq!(shown(&d, 0, None, date()), " ");
|
||||
let revisions = Arc::make_mut(&mut d.revisions);
|
||||
for (id, day) in [(901, "2026-10-01"), (902, "2026-10-30")] {
|
||||
revisions.add(None, id).unwrap();
|
||||
revisions.set(id, Field::Date, day).unwrap();
|
||||
}
|
||||
assert_eq!(shown(&d, 0, None, date()), "2 2026-10-30");
|
||||
}
|
||||
|
||||
/// A component's fields: its name and part properties, its free-text properties, and the material
|
||||
/// and mass the caller worked out; without a component, or a value, or with an empty one, nothing.
|
||||
#[test]
|
||||
fn the_components_fields() {
|
||||
let d = drawing();
|
||||
let mut metadata = Metadata::default();
|
||||
metadata.set(Owner::Component, "part_number", json!("BRK-7")).unwrap();
|
||||
let properties = BTreeMap::from([("finish".to_string(), "Zinc plated".to_string())]);
|
||||
let bracket = Component { name: "Bracket", metadata: &metadata, properties: Some(&properties), material: Some("Steel"), mass: Some("0.39 kg") };
|
||||
let keys = ["name", "part_number", "material", "mass", "properties.finish", "description"];
|
||||
let read = |component| keys.map(|key| shown(&d, 0, component, vec![field(Source::Component, key)]));
|
||||
assert_eq!(read(Some(bracket)), ["Bracket", "BRK-7", "Steel", "0.39 kg", "Zinc plated", ""]);
|
||||
let unweighed = Component { material: None, mass: None, properties: None, ..bracket };
|
||||
assert_eq!(read(Some(unweighed)), ["Bracket", "BRK-7", "", "", "", ""]);
|
||||
assert_eq!(read(None), [""; 6]);
|
||||
let blank = BTreeMap::from([("finish".to_string(), String::new())]);
|
||||
let finish = vec![field(Source::Component, "properties.finish")];
|
||||
assert_eq!(shown(&d, 0, Some(Component { properties: Some(&blank), ..bracket }), finish), "");
|
||||
}
|
||||
|
||||
/// A field its source doesn't have is refused when a block is given and when a drawing is read,
|
||||
/// naming the cell; so is an override of a cell the block doesn't have or that shows no text, or
|
||||
/// one with a control character. A sheet without overrides saves none.
|
||||
#[test]
|
||||
fn unknown_fields_and_overrides_are_refused() {
|
||||
let mut block = default_block();
|
||||
let CellKind::Text(text) = &mut block.cells[8].kind else { panic!("the mass cell has text") };
|
||||
text.content = Content(vec![field(Source::Component, "weight")]);
|
||||
assert_eq!(check_block(&block).unwrap_err().to_string(), "cell `mass`: the component has no field `weight`");
|
||||
let mut d = drawing();
|
||||
let saved = d.to_json().unwrap();
|
||||
assert!(!saved.contains("title_block_overrides"));
|
||||
let mut v: serde_json::Value = serde_json::from_str(&saved).unwrap();
|
||||
v["sheets"][0]["title_block"] = serde_json::to_value(&block).unwrap();
|
||||
let refusal = Drawing::from_json(&v.to_string()).unwrap_err().to_string();
|
||||
assert!(refusal.contains("cell `mass`: the component has no field `weight`"), "{refusal}");
|
||||
// Overrides: a cell's name, a cell's text.
|
||||
Arc::make_mut(&mut d.sheets[0]).title_block_overrides.insert("material".into(), "See note 3".into());
|
||||
let saved = d.to_json().unwrap();
|
||||
assert_eq!(Drawing::from_json(&saved).unwrap(), d);
|
||||
let refusals =
|
||||
[("weight", "x", "no text cell `weight`"), ("projection", "x", "no text cell `projection`"), ("material", "a\u{7}", "control character")];
|
||||
for (cell, text, why) in refusals {
|
||||
let mut wrong = d.clone();
|
||||
Arc::make_mut(&mut wrong.sheets[0]).title_block_overrides.insert(cell.into(), text.into());
|
||||
let refusal = wrong.validate().unwrap_err().to_string();
|
||||
assert!(refusal.contains(why), "{refusal}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Pending drawing-format changes
|
||||
|
||||
One file per change to what a drawing file saves that no release has shipped yet. Drawings
|
||||
(`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
|
||||
One file per change to what a drawing saves (in its design's file) that no release has shipped
|
||||
yet. Drawings (`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
|
||||
(`crates/doc/src/format.rs`, `Family`), from their own first format, 1. Adding a file here is the
|
||||
whole format bump: never edit the `DRAWING` family in `format.rs`, another change's file or a
|
||||
`../v<N>/` folder.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! A balloon can name the parts list it reads (`parts_list`, #3874), saved only when it does. An older
|
||||
//! balloon names none and reads its sheet's only list, else its drawing's. The last release refuses a
|
||||
//! drawing with this format's number.
|
||||
@@ -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,3 @@
|
||||
//! A drawing's dimension can measure between two flat faces (`faces`, #4892): each saved by its
|
||||
//! face's name, with the direction to measure along. An older drawing has none. The last release
|
||||
//! refuses a drawing saved with one, as a newer format.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A drawing's dimensions save their last good value (`last_good`, W19d, #4896) when they have one:
|
||||
//! what the dimension last read, so one that can't be measured any more still says what it was
|
||||
//! after the design is reopened. An older drawing has none, and each dimension has one again from
|
||||
//! its next read. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Drawings save their dimensions (`dimensions`, #4896 W19b): each on one of the drawing's views,
|
||||
//! measuring named corners, circles' and arcs' centres, edges or faces of the view's model (a
|
||||
//! linear distance, an edge's length, an angle, a circle's or an arc's radius or diameter, a chain
|
||||
//! of distances along one line, a cylinder's diameter between its silhouettes, or the distance
|
||||
//! between two cylinders' silhouettes), with how far its line sits from them and where its text
|
||||
//! sits on paper, its decimals and any text shown instead of its number. An older drawing has no
|
||||
//! dimensions. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawings save the font their text is set in (`font`, #4890): a font family's name, never a
|
||||
//! file. An older drawing has none, and is set in the built-in font. The last release refuses a
|
||||
//! drawing saved with one, as a newer format.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Drawings save their images (#5426): `images` holds each file once, with its format, pixel size and
|
||||
//! bytes (base64), and `sheet_images` each place one sits, with its sheet, its rectangle and the file's
|
||||
//! id. The file's path is never saved. An older drawing has neither. The last release refuses a drawing
|
||||
//! saved with them, as a newer format.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A drawing lives in the file of the design it draws (#3890): it has an `id`, unique among its
|
||||
//! design's drawings, and at most one source, the design it is in, which has an `id` and no `path`.
|
||||
//! No step: no release saved a drawing before this, and a drawing file of its own is no longer
|
||||
//! read, so an older drawing is refused as damaged. The last release refuses a design that holds a
|
||||
//! drawing (the design's newer format number).
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawings save their notes (`notes`, #4896): each on a sheet, a view, or an edge or corner of a view's
|
||||
//! model, with its text and fields, its place and its text height. A leader is never saved. An older
|
||||
//! drawing has no notes. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! A revision tag saves the sheet it sits on and its place there, `sheet` and `at`. No command wrote a
|
||||
//! tag before, so no step rewrites older drawings. The last release refuses a drawing with this
|
||||
//! format's number.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! An issued revision saves the design it was issued from as `model_version: {path, change}`, opaque
|
||||
//! text from the version history, on its issued copy only. It was a string that no command ever
|
||||
//! wrote, so no step rewrites older drawings. A row's issued copy keeps the row's id, one copy for
|
||||
//! each row. The last release refuses a drawing with this format's number.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Drawings save their revisions (`revisions`, #3874): one revision record for the whole drawing,
|
||||
//! its rows (revision, description, designer, checker, date, hidden) in the order they were added,
|
||||
//! each with its id. An older drawing has no revisions. The last release refuses a drawing saved
|
||||
//! with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawing sheets save the text typed over their title block's cells (`title_block_overrides`,
|
||||
//! #3875), by cell name. An older drawing's sheets have none. The last release refuses a drawing
|
||||
//! saved with one, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawings save their tables (`tables`, #3874): each on one of the drawing's sheets, a parts list or
|
||||
//! a revision table, with its definition and its place. Rows are never saved. An older drawing has no
|
||||
//! tables. The last release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Drawing sheets save a copy of their title block's definition (`title_block`, #3875 W18): its size,
|
||||
//! its two line widths and its cells, on the drawing border's lower-right corner. An older drawing's
|
||||
//! sheets have no title block. The last release refuses a drawing saved with one, as a newer format.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Drawings save their views (`views`, #4816): base views of their designs and the views projected
|
||||
//! from them, on their sheets, each with the settings it sets itself; and the projection every
|
||||
//! projected view follows (`projection`: `first` or `third` angle). An older drawing has no views,
|
||||
//! and its projection is third angle. The last release refuses a drawing saved with them, as a
|
||||
//! newer format.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! A table's column can be left off paper (`printed`, #3874), saved only when it is: a new parts list's
|
||||
//! ballooned column, for its editor alone. An older drawing's columns are all printed. The last release
|
||||
//! refuses a drawing with this format's number.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A drawing's views save what they show (`settings.occurrences` and `settings.hide`, #4816): the
|
||||
//! occurrence paths each draws and leaves out, followed down its projected views until one sets its
|
||||
//! own. An older drawing has neither, so each view draws every body the design places. The last
|
||||
//! release refuses a drawing saved with them, as a newer format.
|
||||
@@ -0,0 +1,156 @@
|
||||
//! A drawing's images (W38e, #5426): a PNG or JPEG placed on a sheet, such as a logo or a photo.
|
||||
//!
|
||||
//! - **A file and its placements are two records.** A logo sits on every sheet: [`Drawing::images`]
|
||||
//! holds it once, and each [`SheetImage`] in [`Drawing::sheet_images`] says where one sits. A file
|
||||
//! goes with the last thing that uses it ([`Drawing::images_in_use`]).
|
||||
//! - **The bytes** travel in the drawing's record (base64, as a canvas's do), so the design opens
|
||||
//! without the original file, and the file's path is never saved.
|
||||
//! - **The caps** are checked from the file's bytes and header, before anything decodes its pixels:
|
||||
//! a small file can claim to hold more pixels than memory has. The header can lie in the record
|
||||
//! too, so reading a drawing compares the two ([`Drawing::check_image_files`]); an edit, which only
|
||||
//! ever adds a checked file, doesn't decode them again.
|
||||
//!
|
||||
//! The rules, beside the record's: a file is used; a placement names a file the drawing has, and is
|
||||
//! on one of its sheets, in a rectangle on paper with a size; at most [`MAX_IMAGES`] files of at most
|
||||
//! [`MAX_IMAGE_BYTES`] and [`MAX_IMAGE_PIXELS`] each, and [`MAX_IMAGES_BYTES`] between them; at most
|
||||
//! [`MAX_SHEET_IMAGES`] placements; each list by id.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::drawing_view::is_paper_coordinate;
|
||||
use solidcraft_geom::paper::{MAX_IMAGE_PIXELS, MAX_SIDE_MM};
|
||||
|
||||
use super::by_id::Record;
|
||||
use super::{Drawing, require};
|
||||
use crate::canvas::{base64_decode, base64_encode, image_info};
|
||||
use crate::{DocError, Result, ids};
|
||||
|
||||
/// The most image files a drawing may have.
|
||||
pub const MAX_IMAGES: usize = 1_000;
|
||||
/// The most placements of them a drawing may have.
|
||||
pub const MAX_SHEET_IMAGES: usize = 1_000;
|
||||
/// The largest file an image may be.
|
||||
pub const MAX_IMAGE_BYTES: usize = 4 << 20;
|
||||
/// The most a drawing's image files may be between them: two of the largest and some, whose 12 MiB
|
||||
/// of text leaves room under [`super::MAX_DRAWING_BYTES`] for the rest of the record.
|
||||
pub const MAX_IMAGES_BYTES: usize = 9 << 20;
|
||||
|
||||
/// One image file of a drawing, however many places it sits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImageFile {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// `png` or `jpeg`, as the file's header says.
|
||||
pub format: String,
|
||||
/// Its width and height in pixels, as the file's header says.
|
||||
pub pixels: [u32; 2],
|
||||
/// The file, base64.
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// One place an image file sits, on a sheet of a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SheetImage {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
#[serde(with = "crate::ids")]
|
||||
pub sheet: u64,
|
||||
/// Where it sits, `[x0, y0, x1, y1]` in millimetres from the sheet's lower-left corner.
|
||||
pub rect_mm: [f64; 4],
|
||||
/// The file it shows, by id.
|
||||
#[serde(with = "crate::ids")]
|
||||
pub image: u64,
|
||||
}
|
||||
|
||||
/// The length of `bytes` bytes in base64.
|
||||
const fn encoded(bytes: usize) -> usize {
|
||||
bytes.div_ceil(3) * 4
|
||||
}
|
||||
|
||||
/// What a file's bytes and header say it is, when its caps allow it. Nothing is decoded.
|
||||
fn file_info(bytes: &[u8]) -> Result<(&'static str, [u32; 2])> {
|
||||
require(bytes.len() <= MAX_IMAGE_BYTES, || format!("the file is over {} MiB", MAX_IMAGE_BYTES >> 20))?;
|
||||
let (format, pixels) = image_info(bytes).ok_or_else(|| DocError::Invalid("the file isn't a PNG or JPEG".into()))?;
|
||||
let [w, h] = pixels;
|
||||
require(u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, || format!("the file has {w} by {h} pixels, over {MAX_IMAGE_PIXELS}"))?;
|
||||
Ok((format, pixels))
|
||||
}
|
||||
|
||||
impl ImageFile {
|
||||
/// The image file of a file's `bytes`, when its caps allow it: nothing is decoded.
|
||||
pub fn from_file(id: u64, bytes: &[u8]) -> Result<ImageFile> {
|
||||
let (format, pixels) = file_info(bytes)?;
|
||||
Ok(ImageFile { id, format: format.into(), pixels, data: base64_encode(bytes) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for ImageFile {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for SheetImage {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The image files something in the drawing uses: the one list of what keeps a file. Anything new
|
||||
/// that shows a file adds its own here.
|
||||
pub fn images_in_use(&self) -> BTreeSet<u64> {
|
||||
self.sheet_images.iter().map(|placement| placement.image).collect()
|
||||
}
|
||||
|
||||
/// Takes out each image file nothing uses now.
|
||||
pub(super) fn drop_unused_images(&mut self) {
|
||||
let used = self.images_in_use();
|
||||
self.images.retain(|file| used.contains(&file.id));
|
||||
}
|
||||
|
||||
pub(super) fn check_images(&self) -> Result<()> {
|
||||
// The placements first: one that names no file says so, before its file reads as unused.
|
||||
self.sheet_images.check(MAX_SHEET_IMAGES, "placed image", |placement| self.check_sheet_image(placement))?;
|
||||
let used = self.images_in_use();
|
||||
self.images.check(MAX_IMAGES, "image", |file| {
|
||||
check_image(file)?;
|
||||
require(used.contains(&file.id), || "nothing uses it".into())
|
||||
})?;
|
||||
let total = self.images.iter().fold(0, |total: usize, i| total.saturating_add(i.data.len()));
|
||||
require(total <= encoded(MAX_IMAGES_BYTES), || format!("a drawing's images are at most {} MiB between them", MAX_IMAGES_BYTES >> 20))
|
||||
}
|
||||
|
||||
fn check_sheet_image(&self, i: &SheetImage) -> Result<()> {
|
||||
require(self.images.by_id(i.image).is_some(), || format!("it shows image {}, which the drawing doesn't have", ids::encode(i.image)))?;
|
||||
require(self.sheets.iter().any(|s| s.id == i.sheet), || format!("it is on sheet {}, which the drawing doesn't have", ids::encode(i.sheet)))?;
|
||||
let [x0, y0, x1, y1] = i.rect_mm;
|
||||
let on_paper = [x0, y0, x1, y1].into_iter().all(is_paper_coordinate);
|
||||
require(on_paper && x0 < x1 && y0 < y1, || format!("its rectangle has a size and is at most {MAX_SIDE_MM} mm on paper"))
|
||||
}
|
||||
|
||||
/// Each image's bytes are the file its record says: what reading a drawing checks once, as it
|
||||
/// decodes the base64, so that nothing later trusts a size the file doesn't have.
|
||||
pub(super) fn check_image_files(&self) -> Result<()> {
|
||||
self.images.iter().try_for_each(|i| {
|
||||
let bytes = base64_decode(&i.data).ok_or_else(|| DocError::Invalid(format!("image {}: its file isn't base64", ids::encode(i.id))))?;
|
||||
let (format, pixels) = file_info(&bytes).map_err(|e| DocError::Invalid(format!("image {}: {e}", ids::encode(i.id))))?;
|
||||
require(format == i.format && pixels == i.pixels, || format!("image {}: its record and its file disagree", ids::encode(i.id)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's own rules, from its record alone: what every edit keeps, without decoding it.
|
||||
fn check_image(i: &ImageFile) -> Result<()> {
|
||||
require(matches!(i.format.as_str(), "png" | "jpeg"), || "it is a PNG or a JPEG".into())?;
|
||||
let [w, h] = i.pixels;
|
||||
require(w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS, || format!("it has 1 to {MAX_IMAGE_PIXELS} pixels"))?;
|
||||
require(i.data.len() <= encoded(MAX_IMAGE_BYTES), || format!("its file is at most {} MiB", MAX_IMAGE_BYTES >> 20))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
|
||||
|
||||
use super::*;
|
||||
use crate::ids::IdSource;
|
||||
|
||||
const RECT: [f64; 4] = [10.0, 20.0, 60.0, 45.0];
|
||||
|
||||
/// A PNG's header, saying these pixels: all a cap reads.
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
b.extend(w.to_be_bytes());
|
||||
b.extend(h.to_be_bytes());
|
||||
b.extend([8, 6, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A JPEG's start, saying these pixels.
|
||||
fn jpeg(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8];
|
||||
b.extend(h.to_be_bytes());
|
||||
b.extend(w.to_be_bytes());
|
||||
b.extend([0; 12]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A drawing on one A3 sheet with one image of `bytes` placed on it: the drawing, the file's id and
|
||||
/// the placement's.
|
||||
fn pictured(bytes: &[u8]) -> (Drawing, u64, u64) {
|
||||
let mut id_source = IdSource::seeded(5426);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Pictured", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
|
||||
let (file, placed) = (d.allocate_id(&mut id_source).unwrap(), d.allocate_id(&mut id_source).unwrap());
|
||||
d.images.add([Arc::new(ImageFile::from_file(file, bytes).unwrap())]);
|
||||
d.sheet_images.add([Arc::new(SheetImage { id: placed, sheet: d.sheets[0].id, rect_mm: RECT, image: file })]);
|
||||
d.validate().unwrap();
|
||||
(d, file, placed)
|
||||
}
|
||||
|
||||
/// A copy of the drawing's first sheet, as a sheet of its own.
|
||||
fn drawing_sheet_copy(d: &Drawing, id: u64) -> crate::drawing::Sheet {
|
||||
crate::drawing::Sheet { id, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_saved_with_its_bytes_and_reads_back_the_same() {
|
||||
let bytes = png(640, 480);
|
||||
let (d, image, placed) = pictured(&bytes);
|
||||
let saved = d.to_json().unwrap();
|
||||
let read = Drawing::from_json(&saved).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert_eq!(read.to_json().unwrap(), saved);
|
||||
let found = read.images.by_id(image).unwrap();
|
||||
assert_eq!((found.format.as_str(), found.pixels), ("png", [640, 480]));
|
||||
assert_eq!(base64_decode(&found.data).unwrap(), bytes);
|
||||
assert_eq!(read.sheet_images.by_id(placed).map(|i| (i.rect_mm, i.image)), Some((RECT, image)));
|
||||
assert!(read.images.by_id(placed).is_none() && read.sheet_images.by_id(image).is_none());
|
||||
}
|
||||
|
||||
/// One file sits on two sheets and is saved once. Its bytes count once towards the caps, so a logo on
|
||||
/// every sheet costs what one does.
|
||||
#[test]
|
||||
fn one_file_on_two_sheets_is_saved_once() {
|
||||
let mut logo = png(10, 10);
|
||||
logo.resize(1 << 20, 0);
|
||||
let (mut d, image, _) = pictured(&logo);
|
||||
let first = d.sheets[0].id;
|
||||
let mut id_source = IdSource::seeded(4);
|
||||
let second = d.allocate_id(&mut id_source).unwrap();
|
||||
let sheet = drawing_sheet_copy(&d, second);
|
||||
d.sheets.push(Arc::new(sheet));
|
||||
for n in 0..20u64 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheet_images.add([Arc::new(SheetImage { id, sheet: second, rect_mm: [n as f64, 0.0, n as f64 + 5.0, 5.0], image })]);
|
||||
}
|
||||
d.validate().unwrap();
|
||||
assert_eq!((d.images.len(), d.sheet_images.len()), (1, 21));
|
||||
let read = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(read, d);
|
||||
assert!(d.to_json().unwrap().len() < 2 << 20, "the bytes are there once");
|
||||
assert_eq!(d.images_in_use(), BTreeSet::from([image]));
|
||||
|
||||
// The first sheet's going leaves the file for the second's, and the second's takes it.
|
||||
d.remove_sheet(first);
|
||||
assert_eq!((d.images.len(), d.sheet_images.len()), (1, 20));
|
||||
d.validate().unwrap();
|
||||
d.remove_sheet(second);
|
||||
assert!(d.images.is_empty() && d.sheet_images.is_empty());
|
||||
}
|
||||
|
||||
/// A file goes with its last placement, and nothing is left to save.
|
||||
#[test]
|
||||
fn a_file_goes_with_its_last_placement() {
|
||||
let (mut d, _, placed) = pictured(&png(1, 1));
|
||||
let (sheet, other) = (d.sheets[0].id, d.allocate_id(&mut IdSource::seeded(4)).unwrap());
|
||||
let empty = drawing_sheet_copy(&d, other);
|
||||
d.sheets.push(Arc::new(empty));
|
||||
assert_eq!(d.on_sheet(sheet).images, [placed]);
|
||||
assert!(!d.on_sheet(sheet).is_empty() && d.on_sheet(other).is_empty());
|
||||
assert_eq!(d.remove_sheet(sheet).images, [placed]);
|
||||
assert!(d.images.is_empty() && d.sheet_images.is_empty());
|
||||
d.validate().unwrap();
|
||||
let v = file(&d);
|
||||
assert!(v.get("images").is_none() && v.get("sheet_images").is_none(), "{v}");
|
||||
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d, "an older drawing has neither");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stay_apart_across_files_and_placements() {
|
||||
let (d, image, placed) = pictured(&png(1, 1));
|
||||
let taken = d.ids().collect::<BTreeSet<_>>();
|
||||
assert!(taken.contains(&image) && taken.contains(&placed));
|
||||
let mut twin = d.clone();
|
||||
Arc::make_mut(&mut twin.sheet_images[0]).id = image;
|
||||
assert!(twin.validate().is_err_and(|e| e.to_string().contains("two objects")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caps_are_read_from_the_file_before_anything_decodes_it() {
|
||||
let make = |b: &[u8]| ImageFile::from_file(1, b).map(|i| i.pixels).map_err(|e| e.to_string());
|
||||
assert_eq!(make(&png(8_000, 8_000)), Ok([8_000, 8_000]));
|
||||
assert_eq!(make(&jpeg(8_000, 8_000)), Ok([8_000, 8_000]));
|
||||
// The limits, one over, and sizes whose product would overflow 32 bits.
|
||||
for (w, h) in [(8_001, 8_000), (64_000_001, 1), (1, 64_000_001), (65_536, 65_536), (u32::MAX, u32::MAX)] {
|
||||
assert!(make(&png(w, h)).is_err_and(|e| e.contains("pixels")), "{w} by {h}");
|
||||
}
|
||||
assert!(make(&jpeg(u16::MAX, u16::MAX)).is_err_and(|e| e.contains("pixels")));
|
||||
let not_images: [&[u8]; 6] = [b"", b"GIF89a", b"\x89PNG", &png(0, 5), &png(5, 0), &jpeg(0, 5)];
|
||||
for not_image in not_images {
|
||||
assert!(make(not_image).is_err_and(|e| e.contains("PNG or JPEG")), "{not_image:?}");
|
||||
}
|
||||
let mut full = png(10, 10);
|
||||
full.resize(MAX_IMAGE_BYTES, 0);
|
||||
assert_eq!(make(&full), Ok([10, 10]));
|
||||
full.push(0);
|
||||
assert!(make(&full).is_err_and(|e| e.contains("MiB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_lies_is_refused_on_reading() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
let with = |key: &'static str, value: Value| move |v: &mut Value| v["images"][0][key] = value;
|
||||
assert!(refused(&d, with("pixels", json!([1, 1]))).contains("disagree"));
|
||||
assert!(refused(&d, with("format", "jpeg".into())).contains("disagree"));
|
||||
assert!(refused(&d, with("format", "gif".into())).contains("PNG or a JPEG"));
|
||||
assert!(refused(&d, with("data", "!!!".into())).contains("base64"));
|
||||
assert!(refused(&d, with("data", base64_encode(&png(9_000, 9_000)).into())).contains("pixels"));
|
||||
assert!(refused(&d, with("data", "".into())).contains("PNG or JPEG"));
|
||||
assert!(refused(&d, with("pixels", json!([0, 10]))).contains("pixels"));
|
||||
assert!(refused(&d, with("pixels", json!([65_536, 65_536]))).contains("pixels"));
|
||||
assert!(refused(&d, with("path", "C:/secret.png".into())).contains("path"), "an unknown field is damage");
|
||||
}
|
||||
|
||||
/// A placement names a file and a sheet the drawing has, and a file nothing places isn't kept.
|
||||
#[test]
|
||||
fn a_placement_and_its_file_name_each_other() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
let placed = |key: &'static str, value: Value| move |v: &mut Value| v["sheet_images"][0][key] = value;
|
||||
assert!(refused(&d, placed("sheet", ids::encode(1 << 41).into())).contains("sheet"));
|
||||
assert!(refused(&d, placed("image", ids::encode(1 << 41).into())).contains("doesn't have"));
|
||||
assert!(refused(&d, |v| v["sheet_images"] = json!([])).contains("nothing uses it"));
|
||||
assert!(refused(&d, |v| v["images"] = json!([])).contains("doesn't have"));
|
||||
assert!(refused(&d, placed("data", "AAAA".into())).contains("data"), "the file's bytes aren't the placement's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_is_on_paper_and_has_a_size() {
|
||||
let (d, ..) = pictured(&png(10, 10));
|
||||
for rect in [
|
||||
[60.0, 20.0, 10.0, 45.0],
|
||||
[10.0, 45.0, 60.0, 20.0],
|
||||
[10.0, 20.0, 10.0, 45.0],
|
||||
[10.0, 20.0, 60.0, 20.0],
|
||||
[1e300, 20.0, 2e300, 45.0],
|
||||
[f64::NAN; 4],
|
||||
] {
|
||||
let mut bad = d.clone();
|
||||
Arc::make_mut(&mut bad.sheet_images[0]).rect_mm = rect;
|
||||
assert!(bad.validate().is_err_and(|e| e.to_string().contains("rectangle")), "{rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawings_images_keep_their_counts() {
|
||||
let (d, image, placed) = pictured(&png(1, 1));
|
||||
// A file and something to use it, ids after the first ones.
|
||||
let again = |d: &mut Drawing, n: u64, data: &str| {
|
||||
let mut file = (*d.images[0]).clone();
|
||||
file.id = image + 2 * n;
|
||||
file.data = data.into();
|
||||
let use_of = SheetImage { id: placed + 2 * n, sheet: d.sheets[0].id, rect_mm: RECT, image: file.id };
|
||||
d.images.add([Arc::new(file)]);
|
||||
d.sheet_images.add([Arc::new(use_of)]);
|
||||
};
|
||||
// One file's cap and the cap between them, checked on every edit from the text's length alone.
|
||||
let mut big = d.clone();
|
||||
Arc::make_mut(&mut big.images[0]).data = "A".repeat(encoded(MAX_IMAGE_BYTES) + 1);
|
||||
assert!(big.validate().is_err_and(|e| e.to_string().contains("its file")));
|
||||
let mut many = d.clone();
|
||||
let full = "A".repeat(encoded(MAX_IMAGE_BYTES));
|
||||
Arc::make_mut(&mut many.images[0]).data.clone_from(&full);
|
||||
again(&mut many, 1, &full);
|
||||
let room = encoded(MAX_IMAGES_BYTES) - 2 * full.len();
|
||||
again(&mut many, 2, &"A".repeat(room));
|
||||
many.validate().unwrap();
|
||||
again(&mut many, 3, "A");
|
||||
assert!(many.validate().is_err_and(|e| e.to_string().contains("between them")));
|
||||
let mut crowd = d.clone();
|
||||
(1..=MAX_IMAGES as u64).for_each(|n| again(&mut crowd, n, "AAAA"));
|
||||
assert!(crowd.validate().is_err_and(|e| e.to_string().contains("at most")));
|
||||
let mut placements = d.clone();
|
||||
let logo = placements.sheet_images[0].clone();
|
||||
let copies = (1..=MAX_SHEET_IMAGES as u64).map(|n| Arc::new(SheetImage { id: placed + 2 * n, ..(*logo).clone() }));
|
||||
placements.sheet_images.add(copies.collect::<Vec<_>>());
|
||||
assert!(placements.validate().is_err_and(|e| e.to_string().contains("at most")));
|
||||
let mut twins = d.clone();
|
||||
let twin = twins.sheet_images[0].clone();
|
||||
twins.sheet_images.push(twin);
|
||||
assert!(twins.validate().is_err());
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! A drawing's notes (W19e, #4896): text on a sheet, in its fields and its words, that may point at
|
||||
//! the model with one leader.
|
||||
//!
|
||||
//! - **The anchor** says what a note belongs to: a sheet, a view, or an edge or a corner of a view's
|
||||
//! model, saved by name as a dimension's is ([`super::dimension`]). It is one type so that other
|
||||
//! symbols placed on a drawing can use it too.
|
||||
//! - **The place** is on paper: in millimetres from the sheet's lower-left corner for a note on a
|
||||
//! sheet, and from the view's centre for any other, so that note moves with its view.
|
||||
//! - **The content** is the title block's text with fields ([`Content`]), so a sheet's number works
|
||||
//! in a note. It reads what a note can name: never an answer given when a block was placed, or a
|
||||
//! table's row.
|
||||
//! - **The leader** is worked out, never saved, from where the anchor stands with the model now
|
||||
//! ([`Document::resolve_notes`]). A lost reference keeps the note and its text where they are,
|
||||
//! without a leader.
|
||||
//!
|
||||
//! The rules, beside the record's: its sheet or view is one of the drawing's; an edge or a corner
|
||||
//! keeps a dimension's reference rules; its place is on paper ([`is_paper_coordinate`]); its text
|
||||
//! height is [`CAP_MM`]; at most [`MAX_NOTES`] notes, listed by id.
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::cell::{Content, Part, Source};
|
||||
use solidcraft_geom::drawing_view::is_paper_coordinate;
|
||||
use solidcraft_geom::paper::MAX_SIDE_MM;
|
||||
|
||||
use super::by_id::Record;
|
||||
use super::dimension::{Lookup, ModelEdge, ModelPoint, Status, check_edge, check_point};
|
||||
use super::{Drawing, require};
|
||||
use crate::{DocError, Document, Model, Result, ids};
|
||||
|
||||
/// The most notes a drawing may have.
|
||||
pub const MAX_NOTES: usize = 10_000;
|
||||
/// The text heights a note may set, in millimetres of paper.
|
||||
pub const CAP_MM: RangeInclusive<f64> = 0.5..=100.0;
|
||||
|
||||
/// What a note belongs to.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Anchor {
|
||||
/// A sheet, by its id.
|
||||
Sheet(#[serde(with = "crate::ids")] u64),
|
||||
/// A view, by its id.
|
||||
View(#[serde(with = "crate::ids")] u64),
|
||||
/// An edge of a view's model: its leader ends on the edge's middle.
|
||||
Edge {
|
||||
#[serde(with = "crate::ids")]
|
||||
view: u64,
|
||||
edge: ModelEdge,
|
||||
},
|
||||
/// A corner or a centre of a view's model: its leader ends there.
|
||||
Corner {
|
||||
#[serde(with = "crate::ids")]
|
||||
view: u64,
|
||||
point: ModelPoint,
|
||||
},
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// The view it moves with, if any.
|
||||
pub fn view(&self) -> Option<u64> {
|
||||
match self {
|
||||
Anchor::Sheet(_) => None,
|
||||
Anchor::View(view) | Anchor::Edge { view, .. } | Anchor::Corner { view, .. } => Some(*view),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same anchor on view `view`, when it is on one.
|
||||
pub fn on_view(&self, view: u64) -> Anchor {
|
||||
let mut anchor = self.clone();
|
||||
if let Anchor::View(on) | Anchor::Edge { view: on, .. } | Anchor::Corner { view: on, .. } = &mut anchor {
|
||||
*on = view;
|
||||
}
|
||||
anchor
|
||||
}
|
||||
}
|
||||
|
||||
/// One note of a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Note {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
pub anchor: Anchor,
|
||||
/// Where it sits, in millimetres of paper from its sheet's corner, or from its view's centre.
|
||||
pub at: [f64; 2],
|
||||
pub content: Content,
|
||||
/// The height of its capitals on paper, when it sets its own.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cap_mm: Option<f64>,
|
||||
}
|
||||
|
||||
impl Record for Note {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The notes' rules (the module's notes). The views are checked first, so they are in id order
|
||||
/// here.
|
||||
pub(super) fn check_notes(&self) -> Result<()> {
|
||||
self.notes.check(MAX_NOTES, "note", |n| self.check_note(n))
|
||||
}
|
||||
|
||||
fn check_note(&self, n: &Note) -> Result<()> {
|
||||
match &n.anchor {
|
||||
Anchor::Sheet(sheet) => {
|
||||
require(self.sheets.iter().any(|s| s.id == *sheet), || {
|
||||
format!("it is on sheet {}, which the drawing doesn't have", ids::encode(*sheet))
|
||||
})?;
|
||||
}
|
||||
Anchor::View(_) => {}
|
||||
Anchor::Edge { edge, .. } => check_edge(edge)?,
|
||||
Anchor::Corner { point, .. } => check_point(point)?,
|
||||
}
|
||||
if let Some(view) = n.anchor.view() {
|
||||
require(self.view(view).is_some(), || format!("it is on view {}, which the drawing doesn't have", ids::encode(view)))?;
|
||||
}
|
||||
require(!n.content.0.is_empty(), || "it has some text".into())?;
|
||||
n.content.validate().map_err(|e| DocError::Invalid(e.0.into()))?;
|
||||
let can_read = |part: &Part| !matches!(part, Part::Field { source: Source::Prompt | Source::Row, .. });
|
||||
require(n.content.0.iter().all(can_read), || "its fields read the drawing, its sheet, its component or its newest revision".into())?;
|
||||
let [x, y] = n.at;
|
||||
require(is_paper_coordinate(x) && is_paper_coordinate(y), || format!("its place is at most {MAX_SIDE_MM} mm on paper"))?;
|
||||
require(n.cap_mm.is_none_or(|cap| CAP_MM.contains(&cap)), || format!("its text height is {} to {} mm", CAP_MM.start(), CAP_MM.end()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A note as the model has it now ([`Document::resolve_notes`]): never saved.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Resolved {
|
||||
/// Always [`Status::Ok`] for a note on a sheet or a view, which reads no model.
|
||||
pub status: Status,
|
||||
/// Where its leader ends, in the design's frame: when it has one.
|
||||
pub point: Option<Vec3>,
|
||||
/// What it points at was found by exact history, as one edge.
|
||||
pub exact: bool,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The notes `wanted` of `drawing` (one of this design's drawings) as `model` (this design's) has
|
||||
/// them now, as [`Document::resolve_balloons`] finds balloons'. `Err` only for an id the drawing
|
||||
/// doesn't have.
|
||||
pub fn resolve_notes(&self, model: &Model, drawing: &Drawing, wanted: &[u64]) -> Result<Vec<Resolved>> {
|
||||
let mut lookup = Lookup::new(self, model);
|
||||
wanted
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let n = drawing.notes.by_id(*id).ok_or_else(|| DocError::Unknown(format!("note {}", ids::encode(*id))))?;
|
||||
Ok(match &n.anchor {
|
||||
Anchor::Sheet(_) | Anchor::View(_) => Resolved { status: Status::Ok, point: None, exact: true },
|
||||
Anchor::Edge { edge, .. } => {
|
||||
let (status, point, exact) = lookup.middle(edge);
|
||||
Resolved { status, point, exact }
|
||||
}
|
||||
Anchor::Corner { point, .. } => match lookup.corner(point) {
|
||||
Ok((at, exact)) => Resolved { status: Status::Ok, point: Some(at), exact },
|
||||
Err(status) => Resolved { status, point: None, exact: false },
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "note_tests.rs"]
|
||||
mod tests;
|
||||