mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e8847512b | |||
| 0fc45a1b07 | |||
| 4ea1435f7c | |||
| 18c8e31397 | |||
| 180510cdf4 | |||
| 7da12cb981 | |||
| add3e2d2c1 | |||
| 5d8bd85b7d | |||
| f0645730d9 | |||
| ba2408d6f4 | |||
| 124aa4cccd | |||
| c47669d406 |
@@ -213,7 +213,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/styles/ui/themes/one-dark.json | SolidCraft contributors | original SolidCraft theme file with colours adapted from the One Dark palette (Atom One Dark by GitHub Atom, https://github.com/atom/atom, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/tokyo-night.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Tokyo Night by enkia, https://github.com/enkia/tokyo-night-vscode-theme, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solarized-light.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Solarized by Ethan Schoonover, https://github.com/altercation/solarized, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solarized-dark.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Solarized by Ethan Schoonover, https://github.com/altercation/solarized, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/nord.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Nord by Sven Greb and the Nord contributors, https://github.com/nordtheme/nord, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/high-contrast.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/graphite.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+3
-4
@@ -4051,15 +4051,16 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcore"
|
||||
version = "1.0.1"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=9856ffee5ab778c6b49f24716e82305683eda1fb#9856ffee5ab778c6b49f24716e82305683eda1fb"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-record"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=9856ffee5ab778c6b49f24716e82305683eda1fb#9856ffee5ab778c6b49f24716e82305683eda1fb"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4283,11 +4284,9 @@ dependencies = [
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+2
-2
@@ -36,10 +36,10 @@ solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# move the rev on its own commit. For a
|
||||
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
|
||||
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "9856ffee5ab778c6b49f24716e82305683eda1fb" }
|
||||
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
|
||||
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "9856ffee5ab778c6b49f24716e82305683eda1fb" }
|
||||
# test tooling: a dev-dependency only (xtask/src/layers.rs)
|
||||
solidcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `kernel-cases --export=DIR`: one kernel rig case as a corpus entry for solidcore's rig. The
|
||||
//! case's commands run as the rig runs them while the kernel's operations are traced
|
||||
//! (`smlib_trace`); the entry holds every operation in execution order, including discarded
|
||||
//! tool construction and handled refusals, each operand that came in as a record (the
|
||||
//! (`smlib_trace`); the entry holds the operation (or the chain of operations) that made the
|
||||
//! bodies the case ends with, each operand that came in as a record given as that record (the
|
||||
//! B-rep record, solidcore-record's JSON), the fix set, the case's expected values and the rig's
|
||||
//! own result. A case whose result no kernel operation alone accounts for is written too, with
|
||||
//! the reasons, so nothing is dropped silently.
|
||||
@@ -98,6 +98,32 @@ impl<'a> Trace<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operations `ends` need, in the order they ran: each body's maker, and its operands' makers
|
||||
/// in turn, back to bodies made from records. Operands made by nothing traced are listed in
|
||||
/// `unknown`.
|
||||
fn chain(t: &Trace<'_>, ends: &[u64], unknown: &mut BTreeSet<u64>) -> Vec<usize> {
|
||||
let mut need = BTreeSet::new();
|
||||
let mut todo: Vec<u64> = ends.to_vec();
|
||||
while let Some(id) = todo.pop() {
|
||||
if t.imports.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
match t.producer.get(&id) {
|
||||
Some(&i) => {
|
||||
if need.insert(i)
|
||||
&& let Some(c) = t.calls.get(i)
|
||||
{
|
||||
todo.extend(c.1.iter().copied());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
unknown.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
need.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The entry of case `dir`, run with result `out`, into `root/<area>/<id>.json`, and the fix set
|
||||
/// into `root/fixes.json`.
|
||||
pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<(), String> {
|
||||
@@ -194,18 +220,14 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
|
||||
}
|
||||
|
||||
let mut unknown = BTreeSet::new();
|
||||
let mut available: BTreeSet<u64> = t.imports.keys().copied().collect();
|
||||
for (_, inputs, _, outputs) in &t.calls {
|
||||
unknown.extend(inputs.iter().filter(|id| !available.contains(id)).copied());
|
||||
if let Ok(made) = outputs {
|
||||
available.extend(made.iter().copied());
|
||||
}
|
||||
}
|
||||
unknown.extend(ends.iter().filter(|id| !available.contains(id)).copied());
|
||||
let mut order = chain(&t, &ends, &mut unknown);
|
||||
if !unknown.is_empty() {
|
||||
why_not.push(format!("operands made by nothing traced: {unknown:?}"));
|
||||
}
|
||||
// Operand records: every body a call takes, or the case ends with, that came in as a record.
|
||||
if let Some(i) = failed_call.filter(|i| !order.contains(i)) {
|
||||
order.push(i);
|
||||
}
|
||||
// Operand records: every body the chain takes, or the case ends with, that came in as a record.
|
||||
let mut bodies = BTreeMap::new();
|
||||
let given = |id: u64, bodies: &mut BTreeMap<String, Value>| {
|
||||
if let Some((record, trusted, splines)) = t.imports.get(&id) {
|
||||
@@ -213,21 +235,14 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
|
||||
}
|
||||
};
|
||||
let mut calls: Vec<Value> = Vec::new();
|
||||
for (i, (op, inputs, params, outputs)) in t.calls.iter().enumerate() {
|
||||
for (op, inputs, params, outputs) in order.iter().filter_map(|&i| t.calls.get(i)) {
|
||||
for &id in inputs.iter() {
|
||||
given(id, &mut bodies);
|
||||
}
|
||||
let mut c = json!({"op": op, "inputs": inputs.iter().map(|i| format!("b{i}")).collect::<Vec<_>>(), "params": params});
|
||||
match outputs {
|
||||
Ok(made) => c["outputs"] = json!(made.iter().map(|i| format!("b{i}")).collect::<Vec<_>>()),
|
||||
Err(e) => {
|
||||
c["error"] = json!(e);
|
||||
// The application handled this refusal. Replay must continue to any later
|
||||
// calls and grade the final bodies, even when this was the last attempt.
|
||||
if failed_call != Some(i) {
|
||||
c["continued"] = json!(true);
|
||||
}
|
||||
}
|
||||
Err(e) => c["error"] = json!(e),
|
||||
}
|
||||
calls.push(c);
|
||||
}
|
||||
@@ -275,134 +290,6 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_record() -> Brep {
|
||||
Brep { version: 4, tolerance: 1e-6, regions: Vec::new(), shells: Vec::new(), faces: Vec::new(), edges: Vec::new(), vertices: Vec::new() }
|
||||
}
|
||||
|
||||
fn call(op: &'static str, inputs: &[u64], outputs: Result<Vec<u64>, String>) -> Event {
|
||||
Event::Call { op, inputs: inputs.to_vec(), params: json!({}), outputs }
|
||||
}
|
||||
|
||||
fn exported_case(name: &str, events: Vec<Event>, ran: Result<Vec<(String, Brep)>, String>) -> Value {
|
||||
let root = std::env::temp_dir().join(format!("solidcraft-export-{}-{name}", std::process::id()));
|
||||
let dir = root.join("tests/kernel-cases/traces").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("expected.json"), "{}").unwrap();
|
||||
let output = root.join("export");
|
||||
write(&output, dir.to_str().unwrap(), &json!({"status": "pass"}), Capture { events, ran: Some(ran) }).unwrap();
|
||||
let entry = output.join("traces").join(format!("{name}.json"));
|
||||
serde_json::from_str(&std::fs::read_to_string(entry).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_tool_construction_is_exported_in_execution_order() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"discarded-tool",
|
||||
vec![call("extrude", &[], Ok(vec![1])), call("extrude", &[], Ok(vec![2])), Event::Export { id: 1, record: Box::new(record.clone()) }],
|
||||
Ok(vec![("Part".into(), record)]),
|
||||
);
|
||||
assert_eq!(e["calls"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(e["calls"][0]["outputs"], json!(["b1"]));
|
||||
assert_eq!(e["calls"][1]["outputs"], json!(["b2"]));
|
||||
assert_eq!(e["traced_ops"], json!(["extrude", "extrude"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handled_refusals_are_exported_without_becoming_terminal() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"handled-refusal",
|
||||
vec![
|
||||
call("extrude", &[], Ok(vec![1])),
|
||||
call("fillet", &[1], Err("too large".into())),
|
||||
call("fillet", &[1], Ok(vec![2])),
|
||||
Event::Export { id: 2, record: Box::new(record.clone()) },
|
||||
],
|
||||
Ok(vec![("Part".into(), record)]),
|
||||
);
|
||||
assert_eq!(e["calls"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(e["calls"][1]["error"], "too large");
|
||||
assert_eq!(e["calls"][1]["continued"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handled_last_attempt_keeps_the_retained_body_as_the_result() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"handled-last-attempt",
|
||||
vec![
|
||||
call("extrude", &[], Ok(vec![1])),
|
||||
call("fillet", &[1], Err("too large".into())),
|
||||
Event::Export { id: 1, record: Box::new(record.clone()) },
|
||||
],
|
||||
Ok(vec![("Part".into(), record)]),
|
||||
);
|
||||
assert_eq!(e["calls"][1]["continued"], true);
|
||||
assert_eq!(e["result"], json!([{"body": "Part", "made": "b1"}]));
|
||||
assert_eq!(e["operation_expressible"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_final_refusal_still_ends_replay() {
|
||||
let e = exported_case(
|
||||
"terminal-refusal",
|
||||
vec![
|
||||
call("extrude", &[], Ok(vec![1])),
|
||||
call("fillet", &[1], Err("too large".into())),
|
||||
call("fillet", &[1], Err("no room".into())),
|
||||
call("copy", &[1], Ok(vec![2])),
|
||||
],
|
||||
Err("no room".into()),
|
||||
);
|
||||
assert_eq!(e["calls"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(e["calls"][1]["continued"], true);
|
||||
assert!(e["calls"][2].get("continued").is_none());
|
||||
assert_eq!(e["outcome"]["kernel_error"], "no room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_operations_still_require_their_operand_records() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"missing-tool-operand",
|
||||
vec![call("extrude", &[], Ok(vec![1])), call("copy", &[99], Ok(vec![2])), Event::Export { id: 1, record: Box::new(record.clone()) }],
|
||||
Ok(vec![("Part".into(), record)]),
|
||||
);
|
||||
assert_eq!(e["operation_expressible"], false);
|
||||
assert!(e["why_not"].as_array().unwrap().iter().any(|why| why.as_str().unwrap().contains("99")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_operations_keep_their_imported_operands() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"imported-tool-operand",
|
||||
vec![
|
||||
call("extrude", &[], Ok(vec![1])),
|
||||
Event::Import { id: 9, record: Box::new(record.clone()), trusted: false, keeping_splines: true },
|
||||
call("copy", &[9], Ok(vec![2])),
|
||||
Event::Export { id: 1, record: Box::new(record.clone()) },
|
||||
],
|
||||
Ok(vec![("Part".into(), record.clone())]),
|
||||
);
|
||||
assert_eq!(e["bodies"]["b9"], json!({"record": record, "trusted": false, "keeping_splines": true}));
|
||||
assert_eq!(e["calls"][1]["inputs"], json!(["b9"]));
|
||||
assert_eq!(e["operation_expressible"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_output_cannot_supply_an_earlier_discarded_operation() {
|
||||
let record = empty_record();
|
||||
let e = exported_case(
|
||||
"future-tool-operand",
|
||||
vec![call("copy", &[9], Ok(vec![2])), call("extrude", &[], Ok(vec![9])), Event::Export { id: 9, record: Box::new(record.clone()) }],
|
||||
Ok(vec![("Part".into(), record)]),
|
||||
);
|
||||
assert_eq!(e["operation_expressible"], false);
|
||||
assert!(e["why_not"].as_array().unwrap().iter().any(|why| why.as_str().unwrap().contains("9")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_area_is_the_path_under_kernel_cases() {
|
||||
assert_eq!(area_and_id("tests/kernel-cases/booleans/1894-x").unwrap(), ("booleans".into(), "1894-x".into()));
|
||||
|
||||
@@ -62,7 +62,7 @@ struct Command {
|
||||
}
|
||||
|
||||
/// `run`'s lines in the usage.
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--profile-out trace.json] [--json-out FILE] [--allow-errors] [--validation each_step|each_feature|off]\n [--param-check off|warn|strict] [--quality standard|fine|finest]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--quality: how closely an STL, OBJ or 3MF --out follows curved faces, as file.export's quality: a chord of\n 2e-4, 5e-5 or 1e-5 of each body's size, never under 1 µm, default standard)\n (--validation: when SMLib's full check runs on the features computed (diagnostics.validation), default each_feature)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n (--profile also records where the time goes, SMLib's calls, kernel operations, features, commands and import stages,\n and prints the table to stderr: count, total, self time and longest per span; --profile-out FILE writes the spans\n as a Chrome trace for chrome://tracing or ui.perfetto.dev; SOLIDCRAFT_PROFILE=1 does the same for any command)\n";
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--profile-out trace.json] [--json-out FILE] [--allow-errors] [--validation each_step|each_feature|off]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--validation: when SMLib's full check runs on the features computed (diagnostics.validation), default each_feature)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n (--profile also records where the time goes, SMLib's calls, kernel operations, features, commands and import stages,\n and prints the table to stderr: count, total, self time and longest per span; --profile-out FILE writes the spans\n as a Chrome trace for chrome://tracing or ui.perfetto.dev; SOLIDCRAFT_PROFILE=1 does the same for any command)\n";
|
||||
/// `eval`'s lines in the usage.
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] [--profile] [--profile-out trace.json] [--validation each_step|each_feature|off] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n (--profile, --profile-out and --validation as for run)\n";
|
||||
/// `script`'s lines in the usage.
|
||||
@@ -451,12 +451,11 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
known_args(
|
||||
"run",
|
||||
args,
|
||||
&["--in", "--out", "--save", "--json-out", "--param-check", "--profile-out", "--validation", "--quality"],
|
||||
&["--in", "--out", "--save", "--json-out", "--param-check", "--profile-out", "--validation"],
|
||||
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
|
||||
true,
|
||||
)?;
|
||||
let validation = validation_flag("run", args)?;
|
||||
let quality = export_quality("run", args)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
@@ -504,11 +503,7 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
timeline_ok(&s, flags(args, "--out").is_empty())?;
|
||||
}
|
||||
for out in flags(args, "--out") {
|
||||
let mut params = json!({"path": out});
|
||||
if let Some(q) = quality {
|
||||
params["quality"] = json!(q);
|
||||
}
|
||||
s.execute("file.export", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = flag(args, "--save") {
|
||||
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
@@ -549,20 +544,6 @@ fn open_validating(
|
||||
}
|
||||
}
|
||||
|
||||
/// `--quality standard|fine|finest`: how closely the meshes `--out` writes (STL, OBJ, 3MF)
|
||||
/// follow curved faces, as `file.export`'s `quality` (#12270). Checked before anything runs;
|
||||
/// given twice it is refused, since only one quality can apply.
|
||||
fn export_quality<'a>(cmd: &str, args: &'a [String]) -> Result<Option<&'a str>, String> {
|
||||
if flags(args, "--quality").len() > 1 {
|
||||
return Err(format!("{cmd}: --quality is given more than once"));
|
||||
}
|
||||
match flag(args, "--quality") {
|
||||
Some(q) if solidcraft_engine::io::MeshQuality::from_name(q).is_some() => Ok(Some(q)),
|
||||
Some(q) => Err(format!("{cmd}: --quality must be standard, fine or finest, not `{q}`")),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--validation MODE` (`diagnostics.validation`): the default, each_feature, when not given.
|
||||
fn validation_flag(cmd: &str, args: &[String]) -> Result<solidcraft_engine::kernel::Validation, String> {
|
||||
match flag(args, "--validation") {
|
||||
|
||||
@@ -67,9 +67,6 @@ fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
|
||||
(&["--out", "--quiet"][..], "--out needs a value"),
|
||||
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
|
||||
(&["--unknown"][..], "doesn't take --unknown"),
|
||||
(&["--quality"][..], "--quality needs a value"),
|
||||
(&["--quality", "best"][..], "--quality must be standard, fine or finest, not `best`"),
|
||||
(&["--quality", "standard", "--quality", "best"][..], "--quality is given more than once"),
|
||||
];
|
||||
for existing in [false, true] {
|
||||
let saved = dir.0.join(format!("{existing}.solidcraft"));
|
||||
@@ -291,38 +288,6 @@ fn results_still_save_and_export_the_script_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The triangles of a binary STL: the count after its 80-byte header.
|
||||
fn stl_triangles(path: &Path) -> u32 {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
u32::from_le_bytes(bytes[80..84].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// #12270: `run --out part.stl --quality fine` writes the mesh at that quality, as
|
||||
/// `file.export`'s `quality` does, without a script to say so.
|
||||
#[test]
|
||||
fn run_exports_meshes_at_the_quality_asked_for() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.0.join("cylinder.json");
|
||||
std::fs::write(&script, json!({"commands": [{"command": "solid.cylinder", "params": {"diameter": 40, "height": 10}}]}).to_string()).unwrap();
|
||||
let export = |name: &str, quality: Option<&str>| {
|
||||
let stl = dir.0.join(name);
|
||||
let mut command = cli();
|
||||
command.arg("run").arg(&script).arg("--quiet").arg("--out").arg(&stl);
|
||||
if let Some(q) = quality {
|
||||
command.args(["--quality", q]);
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(output.status.success(), "{quality:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
stl_triangles(&stl)
|
||||
};
|
||||
let default = export("default.stl", None);
|
||||
let standard = export("standard.stl", Some("standard"));
|
||||
let fine = export("fine.stl", Some("fine"));
|
||||
let finest = export("finest.stl", Some("finest"));
|
||||
assert_eq!(default, standard);
|
||||
assert!(standard < fine && fine < finest, "standard {standard}, fine {fine}, finest {finest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
let dir = Scratch::new();
|
||||
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
|
||||
"Smooth" => "c_smooth",
|
||||
"Equal" => "c_equal",
|
||||
"Concentric" => "c_concentric",
|
||||
"MidPoint" | "PointAlong" => "c_midpoint",
|
||||
"MidPoint" => "c_midpoint",
|
||||
"Symmetry" => "c_symmetry",
|
||||
"Fix" => "c_fix",
|
||||
_ => return None,
|
||||
|
||||
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "الحجم المسطّح X",
|
||||
"Flat size Y": "الحجم المسطّح Y",
|
||||
"Flipped": "معكوس",
|
||||
"Fold Features": "طي الميزات",
|
||||
"Follow camera (Look At sketches)": "تتبّع الكاميرا (النظر إلى الرسومات)",
|
||||
"Follow inherited appearance": "اتباع المظهر الموروث",
|
||||
"Follow material": "اتباع المادة",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "زجاج - مدخن",
|
||||
"Grid": "الشبكة",
|
||||
"Groove": "أخدود",
|
||||
"Group Features": "تجميع الميزات",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "تغيّر ملف HDR منذ اختياره؛ افحصه واختره مجددًا قبل الحفظ",
|
||||
"HISTORY": "السجل",
|
||||
"Halo under sketch curves": "هالة أسفل منحنيات الرسم",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "موضع المقطع",
|
||||
"Sections": "المقاطع",
|
||||
"Sections (one sketch each)": "المقاطع (رسم واحد لكل منها)",
|
||||
"Select Features": "تحديد الميزات",
|
||||
"Select Other": "تحديد آخر",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "تحديد آخر: أبقِ الزر الأيسر ثابتًا على النموذج (أو انقر بالزر الأيمن › تحديد آخر…) لعرض كل ما تحت المؤشر؛ وانقر مرة أخرى في الموضع نفسه لتحديد العنصر التالي هناك. انقر نقرًا مزدوجًا بالزر الأوسط للانتقال إلى العرض الرئيسي (التفضيلات › التنقل).",
|
||||
"Select Same Look": "تحديد المظهر نفسه",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "حدد الحواف",
|
||||
"Select points of the sketch being edited": "حدد نقاطًا من الرسم الجاري تعديله",
|
||||
"Select the centre the pattern turns around first": "حدد أولًا المركز الذي يدور النمط حوله",
|
||||
"Select the curves to divide first": "حدّد أولًا المنحنيات المراد تقسيمها",
|
||||
"Select the curves to offset first": "حدد أولًا المنحنيات المراد إزاحتها",
|
||||
"Select the mirror line": "حدد خط المرآة",
|
||||
"Select the sketch curves or points to mirror first": "حدد أولًا منحنيات الرسم أو النقاط المراد عكسها",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "صفائح معدنية",
|
||||
"Sheet Rules": "قواعد الصفائح",
|
||||
"Show Agent Cursor": "إظهار مؤشر الوكيل",
|
||||
"Show Each Feature": "إظهار كل ميزة",
|
||||
"Show ghost": "إظهار الطيف",
|
||||
"Show in folder": "إظهار في المجلد",
|
||||
"Show the pointer an AI agent moves (#35)": "إظهار المؤشر الذي يحرّكه وكيل الذكاء الاصطناعي (#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "تحرير سحابة النقاط",
|
||||
"cmd.pointcloud_insert": "إدراج سحابة نقاط",
|
||||
"cmd.pointcloud_list": "سحب النقاط",
|
||||
"cmd.pointcloud_reload": "إعادة تحميل سحابة النقاط",
|
||||
"cmd.render.coat.bind": "حفظ الطلاء الشفاف",
|
||||
"cmd.render.coat.unbind": "إزالة الطلاء الشفاف",
|
||||
"cmd.render.compare.divider": "تحريك فاصل مقارنة التصيير",
|
||||
"cmd.render.result.discard": "تجاهل نتيجة التصيير",
|
||||
"cmd.render.result.select": "تحديد نتيجة التصيير",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "خيارات لوحة الرسم",
|
||||
"cmd.sketch_paste": "لصق عناصر الرسم",
|
||||
"cmd.sketch_point3d": "نقطة ثلاثية الأبعاد",
|
||||
"cmd.sketch_point_divide": "تقسيم المنحنى",
|
||||
"cmd.sketch_polygon_circumscribed": "مضلع محيط",
|
||||
"cmd.sketch_polygon_edge": "مضلع بحافة",
|
||||
"cmd.sketch_polygon_inscribed": "مضلع محاط",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
|
||||
"cmd.view.status_details": "تفاصيل حالة منطقة العرض",
|
||||
"cmd.view.status_items": "عناصر حالة منطقة العرض",
|
||||
"cmd.view_ambient_occlusion": "الإظلال المحيطي",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "قراءة الملف... {percent}%",
|
||||
"file_load.rebuilding": "إعادة بناء الميزة {done} من {total}",
|
||||
"file_load.recovering": "استعادة التصميم",
|
||||
"file_load.reloading_cloud": "جارٍ إعادة تحميل سحابة النقاط",
|
||||
"file_load.step_build": "جارٍ بناء النموذج",
|
||||
"file_load.step_feature": "الميزة {done} من {total}",
|
||||
"file_load.step_part": "الجزء {done} من {total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "ينسخ هندسة الرسم حول نقطة مركز، على دائرة كاملة أو زاوية محددة. استخدمه لدوائر البراغي والأذرع والأسنان.",
|
||||
"help.sketch.pattern.rectangular": "ينسخ هندسة الرسم في صفوف وأعمدة بمسافات محددة. استخدمه لشبكات الثقوب والشقوق وفتحات التهوية.",
|
||||
"help.sketch.point": "يضع نقطة رسم. استخدمها مرجعًا للأبعاد والقيود ومراكز الثقوب أو الميزات اللاحقة.",
|
||||
"help.sketch.point.divide": "يضع نقاطًا تقسّم المنحنيات المختارة إلى أطوال متساوية. تبقى النقاط على كل منحنى وتحافظ على تباعدها عند تغيّره.",
|
||||
"help.sketch.point3d": "يضع نقطة في الفراغ، مع تفعيل الرسم ثلاثي الأبعاد. استخدمها مرجعًا للمنحنيات والمسارات الثلاثية الأبعاد.",
|
||||
"help.sketch.polygon.circumscribed": "يرسم مضلعًا منتظمًا حول دائرة، بحجم يُحدد من مركزها إلى منتصف ضلع. استخدمه عندما يكون العرض بين الضلعين المتقابلين معروفًا، كما في صامولة سداسية.",
|
||||
"help.sketch.polygon.edge": "يرسم مضلعًا منتظمًا من طرفي ضلع واحد. استخدمه عندما يكون طول الضلع وموضعه معروفين.",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt مع السحب بالزر الأيسر يدوّر العرض، وAlt مع الأوسط يُزيحه، وAlt مع الأيمن يكبّره؛ والسحب بالزر الأوسط يُزيح أيضًا",
|
||||
"nav.nx.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه (في NX: الأوسط مع الأيمن)، وCtrl مع الأوسط يكبّره (في NX: الأوسط مع الأيسر)؛ والسحب بالأيمن لا يحرك الكاميرا",
|
||||
"nav.openscad.help": "السحب بالزر الأيسر على مساحة فارغة يدوّر العرض، وShift مع السحب بالأيسر يحدد بمربع، وسحب هندسة الرسم يعدّلها؛ والسحب بالأيمن أو الأوسط يُزيح، وShift مع السحب بالأيمن أو الأوسط يكبّر",
|
||||
"nav.orbit_cursor_wrap.help": "عندما يصل سحب التدوير إلى حافة العرض، ينتقل المؤشر إلى الحافة المقابلة ويستمر التدوير. عند الإيقاف: يتوقف السحب عند حافة الشاشة. لا تأثير له على Wayland أو داخل المتصفح.",
|
||||
"nav.orbit_cursor_wrap.label": "نقل المؤشر إلى الجهة المقابلة أثناء التدوير",
|
||||
"nav.perspective.sketch": "تحرير الرسم يستخدم العرض المتعامد. أنهِ الرسم للتبديل إلى المنظور.",
|
||||
"nav.right_drag_orbits.help": "عند الإيقاف: لا يحرّك السحب بالزر الأيمن الكاميرا، ويفتح الزر الأيمن قائمة السياق فقط. اختيار تعيين أزرار الفأرة SolidWorks يوقفه.",
|
||||
"nav.right_drag_orbits.label": "السحب بالزر الأيمن يدوّر العرض",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "إخفاء رموز القيود عند التصغير",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "تختفي رموز القيود من العرض عندما يُصغَّر الرسم بقدر يجعلها تزحمه. معطّل: تبقى بحجم ثابت على الشاشة عند أي مستوى تكبير.",
|
||||
"prefs.history": "السجل",
|
||||
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
|
||||
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
|
||||
"prefs.history_group_joints": "تجميع المفاصل في السجل",
|
||||
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
|
||||
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
|
||||
"prefs.history_origin_bodies_note": "يبدأ السجل على اليسار أو تحت المستعرض بمجلد الأجسام للمكوّن النشط وبالأصل، مع أيقونات الإظهار والقوائم كما في المستعرض. لا يتغير السجل في الأسفل.",
|
||||
"prefs.history_play_button": "زر تشغيل السجل في عناصر تحكم السجل",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "مقاس العناصر المحددة في الزاوية اليمنى السفلية من العرض: طول الحافة (أو Ø وR للحافة الدائرية)، ومساحة الوجه، وموضع النقطة، والمسافة والزاوية بين عنصرين، ومجموع عدة حواف أو أوجه. النقر ينسخه.",
|
||||
"prefs.remember_dialog_values": "تذكّر قيم مربعات حوار الأوامر",
|
||||
"prefs.remember_dialog_values.help": "معطّل افتراضيًا. عند تفعيله، بعد نجاح الأمر، يُعاد استخدام قيمه وخياراته الآمنة في المرة التالية التي تفتح فيها الأمر نفسه، حتى إغلاق SolidCraft.",
|
||||
"prefs.render_legacy_image_defaults": "استخدام الإعدادات الافتراضية السابقة لصور التصيير",
|
||||
"prefs.render_legacy_image_defaults_help": "الاحتفاظ بسير العمل اليدوي السابق: إعدادات جديدة بالتصيير النقطي على CPU، وأخذ العينات الثابت لتتبع الأشعة على CPU، وفتح النتائج يدويًا. إعادة ملاءمة العرض عند دخول التصيير، وتبديل اللقطات دون استعادة كاميراتها، واستخدام التأطير والتنقل السابقين. تعطيل التحديث التلقائي للصورة المباشرة. لا تتغير الإعدادات المحفوظة.",
|
||||
"prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير",
|
||||
"prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.",
|
||||
"prefs.reports_saved": "{n} محفوظة",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "العمل",
|
||||
"prefs.supersampling_brief": "يرسم العرض بحجم أكبر ثم يصغّره: حاد جدًا لكنه أثقل.",
|
||||
"prefs.tab_accent_note": "لون التمييز يتبع التبويب النشط",
|
||||
"prefs.tab_double_click_renames": "انقر نقرًا مزدوجًا على تبويب التصميم لإعادة تسميته",
|
||||
"prefs.tab_double_click_renames_note": "اكتب الاسم الجديد، ثم اضغط Enter أو انقر في مكان آخر للاحتفاظ به، أو Esc للإلغاء. عند الإيقاف: يؤدي النقر المزدوج على التبويب إلى تكبير النافذة، كما في الجزء الفارغ من الشريط.",
|
||||
"prefs.theme_file": "ملف السمة",
|
||||
"prefs.theme_file_or_paste": "ملف أو لصق",
|
||||
"prefs.theme_follow": "اتباع ملف سمة",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "تعذّر الضم إلى {body}.",
|
||||
"refused.repair": "إصلاح الجزء",
|
||||
"refused.show_face": "إظهار الوجه",
|
||||
"render.adaptive.pilot_progress": "تمريرات تمهيدية تكيفية · {current}/{target}",
|
||||
"render.adaptive.preview_help": "توجّه الصور التمهيدية توزيع العينات ولا تدخل في الصورة النهائية. تستخدم تكرارات التصيير ميزانية منفصلة لكل بكسل؛ ولا يمثّل عدد التكرارات عددًا موحدًا من العينات لكل بكسل.",
|
||||
"render.adaptive.production_progress": "التصيير التكيفي · {current}/{target} تكرارات",
|
||||
"render.adaptive_absolute": "الحد الأدنى المطلق لخطأ RGB",
|
||||
"render.adaptive_coverage": "هدف خطأ التغطية",
|
||||
"render.adaptive_enable": "توزيع العيّنات باستخدام عيّنات استكشافية مستقلة",
|
||||
"render.adaptive_help": "تُهمل العينات الاستطلاعية المستقلة، ثم يُحدد لكل بكسل 16 عينة إنتاج على الأقل حتى قيمة العينات لكل بكسل. تشمل حدود موارد CPU المرحلتين. يوجه تباين RGB والتغطية التوزيع؛ وقد تبقى ضوضاء في البكسلات التي بلغت الحد. يحصي التقرير النهائي العينات الفعلية.",
|
||||
"render.adaptive_help": "على GPU فقط. تُستبعد العيّنات الاستكشافية المستقلة، ثم تُوزَّع 16 عيّنة إنتاجية أو أكثر لكل بكسل حتى قيمة «عيّنات لكل بكسل». يوجّه تباين RGB والتغطية هذا التوزيع؛ قد تفوت المسارات النادرة وقد تبقى البكسلات المحدودة مشوشة. يحصي التقرير النهائي العمل الفعلي. لا ضمان معتمد للتشويش أو للزمن.",
|
||||
"render.adaptive_minimum": "الحد الأدنى للعيّنات الإنتاجية",
|
||||
"render.adaptive_pending": "يتوفر التوزيع التكيفي بعينات استطلاعية مستقلة على CPU وGPU. تتطلب طبقات فرينل الشرطية GPU. لا يضمن التوزيع الإحصائي التقارب.",
|
||||
"render.adaptive_pending": "التوزيع التكيّفي على GPU بعيّنات استكشافية مستقلة وطبقات فرينل الشرطية متاحة. التقارب المعتمد وضمانات الأحداث النادرة والنقل التكيّفي على CPU غير متاحة بعد.",
|
||||
"render.adaptive_pilots": "العيّنات الاستكشافية لكل بكسل",
|
||||
"render.adaptive_relative": "هدف خطأ RGB النسبي",
|
||||
"render.add_ground": "إضافة أرضية ملائمة إلى الإعداد",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "النسبة المعدنية للطبقة",
|
||||
"render.aging_origin": "أصل الحقل · mm العالم",
|
||||
"render.aging_patina": "زنجار نحاسي أخضر",
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً.",
|
||||
"render.aging_refresh_targets": "فحص الأجسام الحالية",
|
||||
"render.aging_roughness": "خشونة الطبقة",
|
||||
"render.aging_rust": "حديد مؤكسد / صدأ",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "جسم مصمت مغلق",
|
||||
"render.appearance_editor.coat": "طبقة شفافة",
|
||||
"render.appearance_editor.cpu_reason": "يتطلب تتبّع الأشعة على المعالج.",
|
||||
"render.appearance_editor.discard_saved_edit": "تجاهل التعديلات البصرية غير المحفوظة",
|
||||
"render.appearance_editor.glass": "الزجاج والامتصاص",
|
||||
"render.appearance_editor.glass_enable": "استخدم زجاجًا فيزيائيًا على هذا الجسم",
|
||||
"render.appearance_editor.glass_note": "يستخدم الزجاج المغلق المسافة الهندسية. لا يمكن أن يشارك جسمًا مع طبقة شفافة أو خرائط.",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "استبدال الخصائص البصرية المحفوظة لهذا الجسم",
|
||||
"render.appearance_editor.revalidate": "إعادة التحقق من هذا الجسم",
|
||||
"render.appearance_editor.save": "حفظ هذه الخصائص البصرية",
|
||||
"render.appearance_editor.save_reason": "لحفظ الزجاج، فعّله على جسم حالي يستخدم CPU أو GPU. للطلاء الشفاف استخدم حفظ الطلاء مع اللقطة. تبقى ملفات الصور المحلية مؤقتة.",
|
||||
"render.appearance_editor.save_reason": "يتطلب حفظ هذه الخصائص البصرية زجاجًا فيزيائيًا على جسم حالي في بطاقة الرسومات. تبقى الطبقة الشفافة وخرائط الصور المحلية مؤقتة.",
|
||||
"render.appearance_editor.saved": "محفوظ مع هذه اللقطة",
|
||||
"render.appearance_editor.saved_state": "هذه الخصائص البصرية محفوظة مع هذه اللقطة.",
|
||||
"render.appearance_editor.scatter_reason": "التشتت الداخلي يتطلب حدًّا لجسم مصمت مغلق.",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "النسبة المعدنية",
|
||||
"render.base_opaque_only": "يحافظ هذا المحرر على دلالات الجسم المعتم. استخدم عناصر التحكم الصريحة للزجاج من أجل النفاذ البصري.",
|
||||
"render.batch_all_shots": "كل اللقطات ({count})",
|
||||
"render.batch_current_settings_help": "يستخدم إعدادات الصورة الحالية مع كاميرا كل لقطة محفوظة، لقطة واحدة في كل مرة.",
|
||||
"render.batch_empty": "احفظ لقطة قبل تصيير كل اللقطات.",
|
||||
"render.batch_limit": "تصيير كل اللقطات محدود بـ 32 لقطة.",
|
||||
"render.batch_owner_limit": "يمكن الاحتفاظ بقوائم لقطات 32 مستندًا كحد أقصى. أكمل قائمة موجودة أو ألغها أولًا.",
|
||||
"render.batch_preparing": "جارٍ إعداد اللقطة التالية",
|
||||
"render.batch_status": "{running} قيد التشغيل · {waiting} في الانتظار",
|
||||
"render.batch_waiting_worker": "في انتظار إعداد التصيير",
|
||||
"render.bloom_enable": "توهج العرض",
|
||||
"render.bloom_help": "توهج إبداعي للإخراج فقط من الإضاءات الخطية الموجبة، يُنشر بثلاث تمريرات صندوقية مُطبّعة. نصف القطر ببكسلات الإخراج. يُضاف في ضوء العرض الخطي قبل sRGB؛ وليس عدسة ولا تأثيرًا حجميًا ولا تحسينًا للتقارب. يبقى EXR الخام دون تغيير.",
|
||||
"render.bloom_opaque": "يتطلب التوهج إخراجًا معتمًا. يُرفض PNG الشفاف بدل تسطيحه بصمت.",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "طلاء",
|
||||
"render.category_plastic": "بلاستيك",
|
||||
"render.category_wood": "خشب",
|
||||
"render.caustics_depth": "عمق مسار تجمّع الضوء",
|
||||
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
|
||||
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_photons": "عدد الفوتونات",
|
||||
"render.caustics_radius": "نصف قطر التجميع",
|
||||
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
|
||||
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
|
||||
"render.check_media_remaining": "لا تزال شبكات الكثافة والإزاحة والتكامل الطيفي الكامل ونقل حالة الاستقطاب غير متاحة.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
|
||||
"render.check_opaque": "الشفافية: أسطح معتمة فقط؛ ألفا الخلفية مدعومة.",
|
||||
"render.check_sorted_alpha": "الشفافية: تقريب ألفا مرتّب.",
|
||||
"render.check_transmission": "الشفافية: نفاذية فيزيائية.",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "نسخ JSON الطلب",
|
||||
"render.children.coverage_error": "خطأ التغطية",
|
||||
"render.children.cpu": "تتبّع الأشعة على المعالج",
|
||||
"render.children.denoise": "تقليل تشويش الصورة",
|
||||
"render.children.denoise": "تقليل التشويش في الصورة النهائية",
|
||||
"render.children.depth": "عمق المسار",
|
||||
"render.children.distortion": "التشويه",
|
||||
"render.children.edge_samples": "عينات الحواف",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "اختر مظهرًا من المظاهر أولًا",
|
||||
"render.chromatic_offset": "إزاحة لونية عند الحواف",
|
||||
"render.clear_stage": "إزالة المنصة المحفوظة",
|
||||
"render.coat_discard": "تجاهل تعديل الطلاء",
|
||||
"render.coat_pending_edit": "لم يُحفظ تعديل الطلاء هذا. احفظه مع اللقطة أو تجاهل التعديل.",
|
||||
"render.coat_remove": "إزالة الطلاء المحفوظ",
|
||||
"render.coat_remove_note": "يزيل طلاء هذا الهدف من اللقطة. تبقى الهندسة والمظاهر والمواد الفيزيائية دون تغيير.",
|
||||
"render.coat_remove_title": "إزالة الطلاء الشفاف المحفوظ؟",
|
||||
"render.coat_revalidate_saved": "تغيّر الطلاء المحفوظ. تجاهل هذا التعديل وأعد فتح الهدف قبل تعديله مجددًا.",
|
||||
"render.coat_save": "حفظ الطلاء مع اللقطة",
|
||||
"render.coat_saved_help": "يضيف الطلاء الشفاف طبقة لامعة إلى الأسطح المعتمة. احفظه مع اللقطة. لا يمكن استخدام الزجاج على الهدف نفسه، ولا تجاوزات الانعكاس المخصص والتباين الاتجاهي على CPU.",
|
||||
"render.compare_divider": "فاصل المقارنة",
|
||||
"render.compare_live": "حي",
|
||||
"render.compare_live_preview": "معاينة حية",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "سقف المثلثات",
|
||||
"render.cpu_budget_workstation": "محطة عمل (مشاهد ثقيلة)",
|
||||
"render.cpu_captured_hdr": "HDR الملتقط لهذه الصورة",
|
||||
"render.cpu_celestial_adopt": "تتبع الأشعة على المعالج مع إضاءة غير مباشرة · عمق المسار {depth} · {spp} عينة لكل بكسل · ظلال متتبعة · بذرة {seed} · عينة أولية واحدة",
|
||||
"render.cpu_celestial_adopt_label": "تتبع الأشعة على المعالج مع إضاءة غير مباشرة",
|
||||
"render.cpu_celestial_path_required": "تستخدم الشمس المحدودة والسماء المتدرجة الإضاءة غير المباشرة على المعالج.",
|
||||
"render.cpu_celestial_switch": "اختر تتبع الأشعة على المعالج مع الإضاءة غير المباشرة للاحتفاظ بهذه الشمس أو السماء.",
|
||||
"render.cpu_celestial_unportable": "أبقِ تتبع المسارات على المعالج الرسومي لإعدادات عينات النفاذ الحالية.",
|
||||
"render.cpu_check_capture_help": "يتحقق «فحص» و«بدء» من بصمة الملف نفسها أو يستخدمان HDR المضمّن. تحتفظ كل صورة بمصدرها الملتقط.",
|
||||
"render.cpu_check_changed": "تغيّرت عناصر التحكم أو النموذج بعد هذا الفحص. افحص مرة أخرى قبل التصيير.",
|
||||
"render.cpu_constant_direct_help": "بدون الإضاءة غير المباشرة، يمكن أن يظهر هذا المصدر الثابت في الانعكاسات وعبر الزجاج. لكنه لا يضيء الأسطح المعتمة. فعّل الإضاءة غير المباشرة لاستخدامه في إضاءة المشهد.",
|
||||
"render.cpu_constant_enable_indirect_plan": "عمق المسار {depth}؛ أخذ عينات تدريجي بمعدل {spp} عينة/بكسل (البذرة {seed})؛ شعاع أولي واحد لكل عينة؛ ظلال متتبعة. يحتفظ بحجم الصورة وميزانية CPU والإعدادات الأخرى.",
|
||||
"render.cpu_constant_help": "الإشعاع الثابت مرئي للأشعة المنعكسة أو النافذة. ولا يوفر إضاءة منتشرة مباشرة.",
|
||||
"render.cpu_custom_directions": "استبدال الإعداد الجاهز بأضواء اتجاهية مخصصة",
|
||||
"render.cpu_denoise_help": "يقلل التشويش في كل تحديث للصورة. قد ينعّم التفاصيل الدقيقة وحواف عمق المجال. يبقى EXR الخام دون تغيير. تستخدم تعديلات الصورة المكتملة صورتها الخام ولا تطبّق تقليل التشويش مرة أخرى.",
|
||||
"render.cpu_direction": "الاتجاه نحو الضوء (X, Y, Z)",
|
||||
"render.cpu_directional": "ضوء اتجاهي",
|
||||
"render.cpu_directional_add": "إضافة ضوء اتجاهي",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "عيّنات إضاءة HDR",
|
||||
"render.cpu_exposure_help": "يجب أن يكون تعريض CPU بين −16 و16 EV. يُحتفظ بتعريض GPU الواقع خارج هذا النطاق حتى تغيّره.",
|
||||
"render.cpu_fixed": "صورة واحدة",
|
||||
"render.cpu_fixed_retained": "تم الاحتفاظ بأخذ العينات الثابت",
|
||||
"render.cpu_glass_enable_path": "تشغيل الإضاءة غير المباشرة",
|
||||
"render.cpu_hdr_help": "ملف Radiance بصيغة .hdr، بانوراما 2:1، حتى 2048 × 1024 و16 MiB. يتطلب CPU ألوانًا أساسية sRGB/D65 صريحة في الملف؛ وتُرفض الألوان الأساسية غير المحددة وملفات EXR.",
|
||||
"render.cpu_hdr_reflection_help": "انعكاسات HDR الخشنة تتطلب أسطح أجسام وأوجه معدنية بالكامل. يُبلغ «فحص» عن التراكيب غير المدعومة.",
|
||||
"render.cpu_indirect_method_required": "اختر تتبع أشعة CPU لتفعيل وضع الإضاءة غير المباشرة هذا.",
|
||||
"render.cpu_key_rim": "رئيسي وحافّي",
|
||||
"render.cpu_last_check": "آخر فحص CPU",
|
||||
"render.cpu_light_help": "حتى ثمانية أضواء اتجاهية. القائمة المخصصة الفارغة تطفئها؛ واختيار إعداد جاهز يعيد أضواءه. الألوان RGB خطية والشدة نسبية، لا بالواط.",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "إعداد الإضاءة الجاهز",
|
||||
"render.cpu_lights": "أضواء CPU",
|
||||
"render.cpu_linear_color": "لون خطي (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "يمكن لتصيير CPU استخدام خرائط الأسطح المعتمة المحفوظة: اختر سطحًا هنا ثم احفظ خرائطه في اللقطة. مع الإضاءة غير المباشرة، يمكن استخدام الزجاج في جسم آخر؛ لا يمكن استخدام الزجاج في الجسم ذي الخرائط. يجب إيقاف الانعكاسات المختارة والتباين الاتجاهي والتأثيرات الإجرائية. تتطلب مسارات ملفات الخرائط المؤقتة GPU.",
|
||||
"render.cpu_material_certification": "يتحقق فحص CPU من هندسة الزجاج المدعومة قبل التصيير. يستخدم الزجاج المغلق المسافة الهندسية.",
|
||||
"render.cpu_neutral": "محايد",
|
||||
"render.cpu_opaque_hint": "يستخدم تتبع الأشعة على CPU خلفية معتمة.",
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
"render.cpu_point_add": "إضافة ضوء نقطي",
|
||||
"render.cpu_point_help": "حتى ثمانية أضواء نقطية تُضاف إلى الإضاءة الاتجاهية المختارة. المسافات بالمليمتر؛ ويجب ألا يتجاوز الحد الأدنى المسافة المرجعية.",
|
||||
"render.cpu_primary_samples": "عيّنات الحواف لكل بكسل",
|
||||
"render.cpu_progressive": "CPU تدريجي",
|
||||
"render.cpu_progressive_adopt": "استخدام أخذ العينات التدريجي",
|
||||
"render.cpu_progressive_adopt_help": "يستخدم أخذ العينات التدريجي عينة كاميرا واحدة لكل تمريرة. يبقى حجم الصورة والأضواء وإعدادات الانعكاس دون تغيير.",
|
||||
"render.cpu_progressive_always": "أخذ العينات التدريجي مفعّل دائمًا",
|
||||
"render.cpu_progressive_help": "تُجمع عينات كاميرا CPU الجديدة ويُعرض كل تمرير مكتمل. تستخدم الانعكاسات المحددة محاولة واحدة لكل عينة كاميرا؛ ويحتفظ الزجاج المعتمد والتباين الاتجاهي المستوي بحدودهما الحالية. تتشارك العينات الاستطلاعية التكيفية والإنتاج ميزانية ملف CPU.",
|
||||
"render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والأضواء المساحية والعوازل وتباين الخواص تتطلب «صورة واحدة».",
|
||||
"render.cpu_progressive_limits": "بحد أقصى 1,048,576 بكسل و16,777,216 عملية تقييم بكسل × عيّنة. تغيير العيّنات لا يغيّر حجم الصورة تلقائيًا أبدًا.",
|
||||
"render.cpu_progressive_reflection_adopt": "اضبط محاولات الانعكاس المحددة على 1 قبل تفعيل أخذ العينات التدريجي.",
|
||||
"render.cpu_progressive_seed": "بذرة عيّنات CPU",
|
||||
"render.cpu_progressive_spp": "عيّنات لكل بكسل",
|
||||
"render.cpu_quality_budget_help": "يغير الحجم وحد عينات الإنتاج والبذرة وملف موارد CPU. تُحفظ الكاميرا والعدسة والإضاءة وعمق GI والتأثيرات.",
|
||||
"render.cpu_quality_progressive_required": "فعّل أخذ العينات التدريجي أدناه قبل اختيار ميزانية CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · حد العينات {samples} · البذرة {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "إضافة انعكاس خشن",
|
||||
"render.cpu_reflection_depth": "عمق الانعكاس (1–8)",
|
||||
"render.cpu_reflection_empty": "لم تُختر أجسام: انعكاسات المشهد متوقفة.",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "تُحفظ إعدادات الضوء والبيئة الرقمية مع الإعداد. تبقى انعكاسات الأجسام وملفات HDR مؤقتة. ويُحتفظ بقيم الأضواء المساحية ومخاريط الأضواء الموجّهة الحالية.",
|
||||
"render.cpu_sampling_mode": "وضع عيّنات CPU",
|
||||
"render.cpu_shadows": "إلقاء الظلال",
|
||||
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100.",
|
||||
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
|
||||
"render.cpu_transparent_help": "يزيل PNG الشفاف الخلفية ويحافظ على تغطية الحواف. يجب أن تبقى عتامة الأجسام والأوجه عند 100%.",
|
||||
"render.cpu_volume_enable_path": "تفعيل الإضاءة غير المباشرة",
|
||||
"render.cpu_volume_enable_path_plan": "تتبع أشعة CPU · عمق المسار {depth} · {spp} عينة/بكسل · بذرة {seed} · ظلال متتبعة. يحتفظ بالغلاف الجوي وميزانية الصورة المحددين.",
|
||||
"render.cpu_volume_help": "يستخدم الغلاف الجوي على CPU الإضاءة غير المباشرة. يحافظ الانطفاء الصفري على الصندوق المحفوظ دون ضباب.",
|
||||
"render.cpu_volume_method_adopt": "تتبع أشعة CPU مع إضاءة غير مباشرة",
|
||||
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
|
||||
"render.cpu_warm_cool": "دافئ وبارد",
|
||||
"render.diag_bodies": "الأجسام المرئية",
|
||||
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "تفاصيل الفحص المسبق",
|
||||
"render.diag_request": "الطلب الحالي بالضبط",
|
||||
"render.diag_triangles": "المثلثات",
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر.",
|
||||
"render.discard_continue": "تجاهل المسودة والمتابعة",
|
||||
"render.draft_confirm_help": "في مسودة التصيير الحالية تعديلات غير مطبّقة. احفظها قبل التبديل، أو تجاهلها صراحةً، أو تابع التحرير.",
|
||||
"render.draft_confirm_title": "الحفاظ على إعدادات التصيير المعدّلة",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "تصدير: PNG للعرض",
|
||||
"render.export_image_label": "تصدير الصورة",
|
||||
"render.export_linear": "تصدير: EXR خطي خام",
|
||||
"render.exr_pending": "يمكن لتتبع الأشعة على CPU وتتبع المسارات على GPU الاحتفاظ بـ EXR خطي خام. الضغط والصور متعددة الطبقات وتمريرات التصيير المساعدة غير متاحة بعد.",
|
||||
"render.exr_pending": "يستطيع GPU الاحتفاظ بـ EXR خطي خام. الضغط والصور متعددة الطبقات وتمريرات التصيير المساعدة غير متاحة بعد.",
|
||||
"render.film_grain": "حبيبات فوتوغرافية",
|
||||
"render.fit": "ملاءمة المنتج",
|
||||
"render.fixed_camera": "الكاميرا",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · رأسي",
|
||||
"render.frame_thirds": "الأثلاث",
|
||||
"render.frame_wide": "16:9 · عريض",
|
||||
"render.glass_abbe": "عدد آبي",
|
||||
"render.glass_dispersion": "التشتت اللوني",
|
||||
"render.glass_film": "تداخل الأغشية الرقيقة",
|
||||
"render.glass_film_ior": "معامل انكسار الغشاء",
|
||||
"render.glass_film_thickness": "سمك الغشاء",
|
||||
"render.glass_model_enable": "نموذج زجاج متقدم",
|
||||
"render.glass_model_help": "تشتت خشونة الزجاج الأشعة النافذة. يستخدم التشتت ولون الغشاء ثلاثة نطاقات طول موجي. تتطلب الصفائح الخشنة إيقاف الظلال النافذة المستقيمة.",
|
||||
"render.glass_roughness": "خشونة الزجاج",
|
||||
"render.gpu_depth": "أقصى عمق للمسار",
|
||||
"render.gpu_options_help": "تُحفظ العيّنات والعمق والبذرة والتعريض مع هذا الإعداد. تُحفظ طريقة الصورة النشطة فقط.",
|
||||
"render.gpu_path_crashed": "أُغلق SolidCraft أثناء إعداده على محوّل الرسوميات هذا في المرة السابقة، على الأرجح داخل برنامج تشغيل الرسوميات، لذا لن يُعاد إعداده تلقائيًا. استخدم تتبع الأشعة على المعالج المركزي بدلًا منه، أو حاول مرة أخرى (يعيد تحديث برنامج التشغيل المحاولة أيضًا).",
|
||||
"render.gpu_path_failed": "متتبع المسارات على GPU غير متاح",
|
||||
"render.gpu_path_first_setup": "قد يستغرق الإعداد الأول على وحدة GPU هذه عدة دقائق؛ ويبقى SolidCraft قابلًا للاستخدام أثناءها. تعيد المرات اللاحقة استخدام المظللات المترجمة حيث يحتفظ بها برنامج التشغيل.",
|
||||
"render.gpu_path_not_started": "لم يبدأ إعداد متتبع المسارات على المعالج الرسومي بعد.",
|
||||
"render.gpu_path_preparing": "متتبع المسارات على GPU: ترجمة المظللات…",
|
||||
"render.gpu_path_retry": "حاول مرة أخرى",
|
||||
"render.gpu_seed": "البذرة العشوائية",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "مركز المنتج",
|
||||
"render.light_size": "الحجم",
|
||||
"render.light_softness": "النعومة",
|
||||
"render.light_space_world": "أضواء بإحداثيات العالم",
|
||||
"render.light_space_world_help": "تستخدم مواضع الأضواء واتجاهاتها إحداثيات العالم. لا يؤدي تدوير الكاميرا حول النموذج إلى تحريك الأضواء.",
|
||||
"render.light_temperature": "درجة حرارة اللون",
|
||||
"render.light_temperature_help": "لون جسم أسود تقريبي؛ يُحفظ كـ RGB خطي.",
|
||||
"render.light_type": "النوع",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "يحتفظ بنسخة أصلية RGBA بفاصلة عائمة 32 بت قبل إزالة التشويش والتعريض وتخطيط الدرجات والمرشحات والخلفية. sRGB/D65 خطي؛ مضروب مسبقًا في التغطية. تُقبل الذاكرة الإضافية صراحةً. بدون ضغط أو تمريرات إضافية.",
|
||||
"render.linear_retain": "الاحتفاظ بـ EXR خطي خام",
|
||||
"render.linear_unavailable": "الإطار المكتمل المختار ليس له EXR محفوظ. فعّل الاحتفاظ قبل التصيير؛ لا يستطيع PNG أقدم استعادة الإشعاع الخام.",
|
||||
"render.live.adaptive": "مباشر · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "تغيّر طلب التصيير أثناء إيقاف التصيير المباشر. ابدأ الصورة النهائية من جديد.",
|
||||
"render.live.final_waiting": "جارٍ تحضير الصورة النهائية · تحرير موارد التصيير المباشر",
|
||||
"render.live.invalid_size": "يتطلب التصيير المباشر عرضًا وارتفاعًا موجبين للإخراج.",
|
||||
"render.live.pause": "إيقاف المباشر مؤقتًا",
|
||||
"render.live.paused": "مباشر · متوقف مؤقتًا",
|
||||
"render.live.paused_outdated": "مباشر · متوقف مؤقتًا · غير محدّث",
|
||||
"render.live.quality_help": "جودة تفاعلية: بحد أقصى 512 بكسل للضلع الأطول. تستخدم المصيّر المحدد ومؤثراته المدعومة. تبقى دقة الصورة النهائية وإعداداتها وتصديرها مستقلة.",
|
||||
"render.live.refused": "التصيير المباشر غير متاح: {reason}",
|
||||
"render.live.resume": "استئناف المباشر",
|
||||
"render.live.samples": "مباشر · {width} × {height} · {current}/{target} عينة",
|
||||
"render.live.source_current": "{method} · مباشر",
|
||||
"render.live.source_outdated": "{method} · الصورة السابقة",
|
||||
"render.live.source_outdated_help": "يعرض آخر صورة ملتقطة أثناء تحديث التصيير المباشر أو عند عدم توفره. قد تختلف عن المشهد أو إعدادات الصورة الحالية.",
|
||||
"render.live.source_settings_help": "افتح «الإخراج» لتعديل المصيّر وإعدادات الصورة.",
|
||||
"render.live.stopped": "توقف التصيير المباشر",
|
||||
"render.live.updating": "مباشر · جودة تفاعلية (حتى 512 بكسل) · جارٍ التحديث",
|
||||
"render.live.waiting_canvas": "في انتظار لوحة التصيير",
|
||||
"render.live.worker_stopped": "توقفت مهمة التقاط التصيير المباشر قبل إرجاع نتيجة",
|
||||
"render.look_add": "إضافة مظهر لوني",
|
||||
"render.look_applied": "تم تطبيق {name}",
|
||||
"render.look_bleach": "مستوحى من تجاوز التبييض",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "طلاء عازل توضيحي فوق المظهر المعتم الحالي. الوزن صفر يترك الأساس دون تغيير.",
|
||||
"render.material_color_map": "صورة اللون الأساسي (sRGB)",
|
||||
"render.material_color_replace": "الخريطة الأساسية تستبدل لون الجسم",
|
||||
"render.material_cpu_glass_only": "تتطلب خرائط ملفات الصور المؤقتة GPU. يمكن لـCPU استخدام خرائط الأسطح المضمنة المحفوظة.",
|
||||
"render.material_cpu_optics_limits": "شغّل الإضاءة غير المباشرة لأشكال الزجاج المغلقة المدعومة والتداخل المتحقق منه والتشتت داخل الزجاج. يمكن للصفائح الرقيقة تمرير الضوء للظلال. وإلا فاستخدم صفيحة مستوية أو صندوقًا محاذيًا للمحاور. تظل الأشكال المتلامسة والظلال النافذة عبر الزجاج المغلق غير مدعومة. تبقى قيمك محفوظة.",
|
||||
"render.material_cpu_saved_runtime_conflict": "لا يمكن الجمع بين البصريات المحفوظة على CPU والارتباطات البصرية المؤقتة السابقة. احفظ البصريات التي راجعتها أو أزل الارتباط المؤقت المتعارض.",
|
||||
"render.material_custom": "بصريات توضيحية مخصصة",
|
||||
"render.material_enable": "استخدام تجاوزات مواد GPU هذه",
|
||||
"render.material_filter": "تصفية أجسام المشهد…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "الصورة العمودية (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "شدة الخريطة العمودية (0–1)",
|
||||
"render.material_origin": "أصل التعيين X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "يتطلب الزجاج الفيزيائي تتبع الأشعة على CPU أو تتبع المسارات على GPU.",
|
||||
"render.material_refresh": "تحديث أجسام المشهد",
|
||||
"render.material_refresh_needed": "تغيّر المشهد. حدّث قائمة الأجسام قبل إضافة هدف.",
|
||||
"render.material_remove": "إزالة هذا التعيين",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "هذا التعيين يخص مراجعة سابقة للمشهد. أعد التحقق من هوية جسمه بالضبط قبل التصيير.",
|
||||
"render.material_roughness": "خشونة الطبقة (0.045–1)",
|
||||
"render.material_roughness_map": "خريطة الخشونة (خطية)",
|
||||
"render.material_saved_edit_pending": "هذه التعديلات البصرية غير محفوظة. احفظ القيم التي راجعتها، أو أزل التعيين المحفوظ، أو تجاهل التعديلات. لا يمكن للارتباطات المؤقتة السابقة والمؤثرات غير المدعومة تجاوز الهدف المحفوظ.",
|
||||
"render.material_shadow": "تمرير أشعة الظل المستقيمة",
|
||||
"render.material_target_missing": "لم يعد ترتيب الجسم واسمه متطابقين. أزل هذا التعيين واختر الجسم المقصود مرة أخرى صراحةً.",
|
||||
"render.material_thickness": "سماكة الصفيحة الرقيقة (mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "تتبع الأشعة على CPU",
|
||||
"render.method_gpu": "تتبع المسارات على GPU",
|
||||
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
|
||||
"render.method_raster": "تصيير نقطي على CPU (تخطيطي)",
|
||||
"render.method_raster": "تنقيط على CPU",
|
||||
"render.navigation_help": "تتحرك الكاميرا كما في التصميم (التفضيلات › التنقل). «حفظ الإعداد» يسجّل الكاميرا الحالية.",
|
||||
"render.nested_enable": "عوازل متداخلة معتمدة",
|
||||
"render.nested_help": "حتى 16 طبقة حاوية؛ يجب أن يكون كل أصل محدبًا وكل فرع داخله تمامًا. التقاطعات والأسطح المتلامسة والصفائح الرقيقة ونفاذ الظل المستقيم تُرفض. حالة وجود الكاميرا في الداخل تتبع التسلسل المعتمد. لا يُنشئ سطح سائل ولا يدمج حدود CAD المتلامسة.",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "إعداد مخرجات EXR غير متاح هنا. تصدير EXR الخطي يتطلب صورة GPU مكتملة بنسخة رئيسية خطية محتفظ بها.",
|
||||
"render.output_fit": "ملاءمة",
|
||||
"render.output_format": "الصيغة",
|
||||
"render.output_format_status": "PNG هو صورة العرض المكتملة بالضبط. يحتفظ EXR الاختياري على CPU أو GPU بالإشعاع الخام الخطي للمشهد بصيغة FLOAT32 قبل مرشحات العرض. لا يستبدل التصدير أبدًا الصورة بذاكرة المعاينة المؤقتة أو بمهمة مختلفة.",
|
||||
"render.output_format_status": "PNG هو صورة العرض المكتملة بالضبط. EXR الاختياري على GPU يحتفظ بالإشعاع الخام الخطي للمشهد بصيغة FLOAT32 قبل مرشحات العرض. التصدير لا يستبدل أبدًا ذاكرة المعاينة المؤقتة أو مهمة أخرى.",
|
||||
"render.output_grading": "تدرّج الألوان",
|
||||
"render.output_image": "الصورة",
|
||||
"render.output_largest": "الأقصى {width} × {height}",
|
||||
"render.output_lock_ratio": "قفل نسبة الأبعاد",
|
||||
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
|
||||
"render.output_over_budget": "الصورة المطلوبة تتجاوز نطاق الموارد المختار. اختر حجمًا أو ملفًا متوافقًا صراحةً؛ لا يحدث تخفيض تلقائي.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_presets_help": "الصيغة والجودة وقائمة الفحص في محرر المخرجات.",
|
||||
"render.output_quality": "الجودة",
|
||||
"render.output_quality_waiting": "بانتظار عقد الجودة",
|
||||
"render.output_ratio_refused": "البُعد المرتبط يتجاوز حد الحجم لهذه الطريقة. يُحتفظ بالحجم السابق؛ ألغِ قفل النسبة أو اختر قيمة أصغر.",
|
||||
"render.output_raytrace_only": "تتطلب مؤثرات الصور تتبع الأشعة على CPU أو تتبع المسارات على GPU.",
|
||||
"render.output_ready": "جاهز للتصيير",
|
||||
"render.output_run_check": "تشغيل الفحص",
|
||||
"render.output_size_limit": "{method}: حتى {dimension} بكسل لكل ضلع و{pixels} بكسل. أكبر حجم مناسب: {width} × {height}.",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "التشبع",
|
||||
"render.post_title": "مرشحات العرض",
|
||||
"render.post_vignette": "التعتيم الطرفي",
|
||||
"render.preparation_cancelled": "أُلغي إعداد التصيير",
|
||||
"render.preparation_checking": "جارٍ التحقق من التصيير",
|
||||
"render.preparation_retirement_failed": "تعذّر تنظيف موارد إعداد التصيير. لا يمكن لهذه النافذة إعداد صورة أخرى.",
|
||||
"render.preparation_retiring": "جارٍ إنهاء إعداد التصيير",
|
||||
"render.preparation_unavailable": "إعداد التصيير غير متاح",
|
||||
"render.presentation_banner": "تعمل مؤثرات إخراج الصورة مع تتبع الأشعة على CPU وGPU. فتح هذه الصفحة لا يغيّر الطريقة مطلقًا.",
|
||||
"render.preview": "المعاينة المدمجة",
|
||||
"render.preview_help": "تظليل نقطي وشفافية ممزوجة. الانكسار وHDRI والتصيير النهائي غير متاحة بعد.",
|
||||
"render.preview_image_settings_help": "تحافظ المعاينة الحية على إضاءة استوديو ثابتة. يُطبّق التعريض وإضاءة الصورة على مهمة الصورة عند «فحص» أو «بدء».",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "الإقرار بإسقاط الميزانية الثابتة وما أُغفل",
|
||||
"render.quality_apply": "استخدام ميزانية العيّنات الثابتة هذه",
|
||||
"render.quality_balanced": "متوازن",
|
||||
"render.quality_budget_help": "يضبط الحجم والعينات والعمق والبذرة وتقليل التشويش. تُحفظ إعدادات الصورة الأخرى.",
|
||||
"render.quality_custom": "مخصص",
|
||||
"render.quality_denoise_off": "تقليل التشويش: متوقف",
|
||||
"render.quality_denoise_on": "تقليل التشويش: مفعّل",
|
||||
"render.quality_draft": "مسودة",
|
||||
"render.quality_high": "عالية",
|
||||
"render.quality_projection_help": "تغيّر هذه المراجعة الدقة وميزانية العينات وعمق المسار والبذرة وتقليل التشويش فقط. تُحفظ الإعدادات الأخرى؛ ولا تثبت الميزانية تقارب الصورة.",
|
||||
"render.quality_projection_help": "إعدادات Q الجاهزة تتضمن متطلبات مستقبلية. هذا الإجراء يُسقط فقط ميزانية PNG مدعومة بعيّنات ثابتة، ويُغفل صراحةً الإيقاف التكيّفي والإخراج الخطي الخام وحدود النفاذ المستقلة.",
|
||||
"render.quality_review": "مراجعة ميزانية العيّنات",
|
||||
"render.quality_summary": "{width} × {height} · {samples} عينة · العمق {depth} · البذرة {seed} · {denoise}",
|
||||
"render.queue_pending": "لا توجد في هذه الواجهة قائمة إنتاج تلقائية ولا مُصيّر للرسوم المتحركة. تبقى الصور الثابتة المكتملة قابلة للتصدير كلٌّ على حدة.",
|
||||
"render.radial_assign": "تعيين مظهر",
|
||||
"render.radial_compare": "مقارنة",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "الإزاحة والتكبير يتبعان تفضيلات التنقل. Space مع السحب يُزيح العرض.",
|
||||
"render.result_open": "فتح الصورة المكتملة في اللوحة الرئيسية.",
|
||||
"render.result_preview": "المعاينة الحية",
|
||||
"render.result_provisional": "صورة مؤقتة",
|
||||
"render.result_provisional": "صورة CPU مؤقتة",
|
||||
"render.result_provisional_help": "نقطة التحقق هذه غير قابلة للتصدير. يحتفظ التصدير بآخر نتيجة مكتملة؛ والإلغاء يعود إلى تلك النتيجة.",
|
||||
"render.result_reduced_display": "قُلّصت معاينة العرض إلى حد الجهاز أو الذاكرة المؤقتة لمنطقة العرض. يحتفظ PNG المحفوظ والتصدير بالدقة الكاملة؛ والفحص الأصلي بنسبة 100% غير متاح لهذا العرض المقلّص.",
|
||||
"render.result_retained": "تُعرض آخر صورة مكتملة؛ ولم يستبدلها تصيير أحدث.",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "ضبط الإطار على الموضوع",
|
||||
"render.shot_invalid_camera": "يجب أن تكون قيم الكاميرا منتهية وضمن النطاق.",
|
||||
"render.shot_lens": "العدسة",
|
||||
"render.shot_lens_cpu_progressive_reason": "فعّل أخذ العينات التدريجي في قسم «أخذ العينات» قبل تمكين عمق المجال على CPU.",
|
||||
"render.shot_lens_enable_reason": "فعّل عمق الميدان لتعديل فتحة العدسة ومسافة التركيز.",
|
||||
"render.shot_lens_gpu_reason": "عمق الميدان يتطلب تتبّع المسار على بطاقة الرسومات.",
|
||||
"render.shot_lens_perspective_reason": "عمق الميدان يتطلب إسقاطًا منظوريًا.",
|
||||
"render.shot_lens_raytrace_reason": "يتوفر عمق المجال مع تتبع الأشعة على CPU وتتبع المسارات على GPU.",
|
||||
"render.shot_look_selection": "النظر إلى التحديد",
|
||||
"render.shot_manager": "اللقطات المحفوظة",
|
||||
"render.shot_new_name": "اسم اللقطة الجديد",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "الأفق · RGB خطي",
|
||||
"render.sky_zenith": "السمت · RGB خطي",
|
||||
"render.soft_focus": "تركيز ناعم",
|
||||
"render.spot_axis": "اتجاه انبعاث الضوء",
|
||||
"render.spot_cone": "مخروط الكشّاف",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
|
||||
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
|
||||
"render.spot_inner": "نصف الزاوية الداخلية",
|
||||
"render.spot_outer": "نصف الزاوية الخارجية",
|
||||
"render.stage_frame": "الإطار",
|
||||
"render.stage_layout.appearance_note": "انقر للتطبيق على التحديد.",
|
||||
"render.stage_layout.studio_note": "انقر للمراجعة قبل التطبيق.",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "تجهيز خطة الإنشاء",
|
||||
"render.stage_panels.projects": "ما يُسقط",
|
||||
"render.stage_panels.quality_ack": "أقرّ بالميزانية الثابتة وما تُغفله",
|
||||
"render.stage_panels.quality_omissions": "لا تضبط هذه الميزانية أهداف الخطأ التكيفي أو حدود النفاذ المستقلة أو قواعد الروليت الروسي أو الاحتفاظ بالأصل الخام، ولا تضمن تقارب الصورة.",
|
||||
"render.stage_panels.quality_omissions": "التوقف التكيّفي وأهداف الخطأ، وعمق نفاذ منفصل، وقواعد الروليت الروسية، والإخراج الخطي الخام، ونسخة EXR الأصلية، وشهادة التقارب.",
|
||||
"render.stage_panels.reference": "مرجع",
|
||||
"render.stage_panels.references": "مراجع",
|
||||
"render.stage_panels.remove_optics": "إزالة البصريات",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "بيئة HDR أصلية · المشهد بأكمله",
|
||||
"render.stage_panels.subtitle_maps": "خرائط مكانية مُعدّة · معاينة مؤقتة",
|
||||
"render.stage_panels.subtitle_physical": "خطة للقراءة فقط · لا تُنشأ مادة ولا تُعيَّن",
|
||||
"render.stage_panels.subtitle_quality": "ميزانية عينات GPU · الحقول المدرجة فقط",
|
||||
"render.stage_panels.subtitle_quality": "ميزانية GPU ثابتة · تُغفل المتطلبات المستقبلية",
|
||||
"render.stage_panels.subtitle_rig": "طقم إضاءة مُعدّ · باعثات وبطاقات سلبية",
|
||||
"render.stage_panels.subtitle_saved_maps": "خرائط مُعدّة · محفوظة مع هذه اللقطة",
|
||||
"render.stage_panels.subtitle_stage": "أرضية ومنصة فيزيائيتان · ملائمتان للمنتج",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "حتى أربعة مستطيلات أحادية الجانب. نصفا الضلعين المتعامدين U وV يحددان الحجم؛ وحاصل U × V يواجه الجهة المضاءة. الإحداثيات بمليمترات فضاء العالم.",
|
||||
"render.studio_center": "المركز X, Y, Z (mm)",
|
||||
"render.studio_denoise": "تقليل التشويش في الصورة النهائية",
|
||||
"render.studio_denoise_help": "تستخدم الصور التدريجية والنهائية مرشحًا يحافظ على الحواف؛ وقد تصبح التفاصيل الدقيقة أنعم. لا يُطبّق المرشح على الزجاج والأسطح ذات الانعكاس الحاد.",
|
||||
"render.studio_denoise_help": "مرشح نهائي يراعي الحواف؛ وقد تلين التفاصيل الدقيقة. الزجاج والأسطح المرآوية الحادة لا يمر عليها المرشح.",
|
||||
"render.studio_direction": "الاتجاه نحو الشمس (X, Y, Z)",
|
||||
"render.studio_environment": "البيئة",
|
||||
"render.studio_environment_bounds": "يجب أن يكون دوران البيئة قيمة محدودة ضمن ±1,000,000 راديان والشدة بين 0 و100.",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "استخدام المعاينة المدمجة",
|
||||
"render.volume_albedo": "بياض التشتت · RGB خطي",
|
||||
"render.volume_anisotropy": "متوسط جيب تمام الطور",
|
||||
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
|
||||
"render.volume_enable": "تفعيل الوسط المشارك",
|
||||
"render.volume_extinction": "معامل الإخماد",
|
||||
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
|
||||
"render.volume_help": "نقل على GPU داخل صندوق محدود واحد، مع توهين بير وتشتت HG على مسارات الكاميرا والمسارات غير المباشرة. يستخدم إضاءة الاستوديو أو HDR أو السماء الصريحة، بما في ذلك توهين الظل. جيب تمام الطور الموجب يشتت إلى الأمام. يجب تعطيل حدود الزجاج وإزالة تشويش السطح صراحةً. الإخماد صفر يترك النقل دون تغيير. ليس غلافًا جويًا قمريًا فيزيائيًا ولا شبكة كثافة ولا انبعاثًا حجميًا ولا نقلًا طيفيًا ولا خاصية مادة معتمدة.",
|
||||
"render.volume_lighting_required": "اختر بيئة أو مجموعة إضاءة محددة لتصيير الغلاف الجوي.",
|
||||
"render.volume_max": "الحدود القصوى · mm العالم",
|
||||
"render.volume_min": "الحدود الدنيا · mm العالم",
|
||||
"render.volume_single_stratum_required": "استخدم عينة نفاذ واحدة لتصيير الغلاف الجوي.",
|
||||
"render.volume_title": "حجم متجانس محدود",
|
||||
"render.wait_add_light": "غير مدعوم بعد: إنشاء الأضواء المسمّاة",
|
||||
"render.wait_batch": "غير مدعوم بعد: قائمة تصيير اللقطات المحفوظة",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "تم تطبيق البُعد",
|
||||
"sketch.dimension_applied_defined": "تم تطبيق البُعد؛ تم تعريف المعامل `{name}`",
|
||||
"sketch.dimension_applied_renamed": "تم تطبيق البُعد؛ أصبح اسم المعامل `{name}`",
|
||||
"sketch.divide_hint": "تقسّم النقاط كل منحنى إلى أطوال متساوية وتحافظ عليها عند تغيّره.",
|
||||
"sketch.dof_left": "تبقّت {count} درجات حرية",
|
||||
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
|
||||
"sketch.draw_curves": "الرسم كـ {linetype}",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "دقيقة",
|
||||
"ui.finest": "الأدق",
|
||||
"ui.finish_sketch": "إنهاء الرسم",
|
||||
"ui.first_dimension_scales": "البُعد الأول يغيّر مقياس الرسم",
|
||||
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في المخطط يغيّر مقياس الرسم الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
|
||||
"ui.fit": "ملاءمة",
|
||||
"ui.fix_unfix": "تثبيت/فك التثبيت",
|
||||
"ui.flat": "مسطّح",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "متصفح العرض المخصص",
|
||||
"ui.heading_cylinder": "أسطوانة",
|
||||
"ui.heading_design": "التصميم",
|
||||
"ui.heading_divide_curve": "تقسيم المنحنى",
|
||||
"ui.heading_draft": "ميل السحب",
|
||||
"ui.heading_edit": "تعديل",
|
||||
"ui.heading_equation_curve": "منحنى معادلة",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "إظهار زر التشغيل",
|
||||
"ui.history_play_finish_action": "أكمل الإجراء الحالي قبل تشغيل السجل",
|
||||
"ui.history_play_roll_back": "أعد مؤشر السجل إلى الخلف قبل التشغيل",
|
||||
"ui.history_scope_all": "إظهار ميزات التصميم كله",
|
||||
"ui.history_scope_all_shown": "ميزات التصميم كله",
|
||||
"ui.history_scope_pick": "حرّر مكوّنًا أو اختر واحدًا في المستعرض لإظهار ميزاته فقط",
|
||||
"ui.history_scope_show": "إظهار ميزات {name} والمكوّنات التي بداخله فقط",
|
||||
"ui.history_scope_shown": "ميزات {name} والمكوّنات التي بداخله",
|
||||
"ui.history_show_hidden": "إظهار المكوّنات المخفية",
|
||||
"ui.history_show_names": "إظهار الأسماء",
|
||||
"ui.history_sketch_listed_under": "مدرج تحت {feature}",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "لا يحتوي الملف على ألوان",
|
||||
"ui.point_cloud_pause": "إيقاف مؤقت",
|
||||
"ui.point_cloud_play": "تشغيل",
|
||||
"ui.point_cloud_reload": "إعادة التحميل من الملف",
|
||||
"ui.point_cloud_single_color": "لون واحد",
|
||||
"ui.point_cloud_size": "حجم النقطة",
|
||||
"ui.point_cloud_units": "وحدات الملف",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "الدليل الثاني",
|
||||
"ui.section_analysis": "تحليل المقطع",
|
||||
"ui.section_size": "حجم المقطع",
|
||||
"ui.segments": "المقاطع",
|
||||
"ui.select": "تحديد",
|
||||
"ui.selectOther": "تحديد آخر…",
|
||||
"ui.selectTangentChain": "تحديد سلسلة مماسية",
|
||||
|
||||
+13
-164
@@ -568,7 +568,6 @@
|
||||
"Select edges": "Kanten auswählen",
|
||||
"Select points of the sketch being edited": "Punkte der bearbeiteten Skizze auswählen",
|
||||
"Select the centre the pattern turns around first": "Zuerst den Mittelpunkt auswählen, um den sich das Muster dreht",
|
||||
"Select the curves to divide first": "Zuerst die zu teilenden Kurven auswählen",
|
||||
"Select the curves to offset first": "Zuerst die zu versetzenden Kurven auswählen",
|
||||
"Select the mirror line": "Die Spiegellinie auswählen",
|
||||
"Select the sketch curves or points to mirror first": "Zuerst die zu spiegelnden Skizzenkurven oder -punkte auswählen",
|
||||
@@ -1081,9 +1080,6 @@
|
||||
"cmd.pointcloud_edit": "Punktwolke bearbeiten",
|
||||
"cmd.pointcloud_insert": "Punktwolke einfügen",
|
||||
"cmd.pointcloud_list": "Punktwolken",
|
||||
"cmd.pointcloud_reload": "Punktwolke neu laden",
|
||||
"cmd.render.coat.bind": "Klarlack speichern",
|
||||
"cmd.render.coat.unbind": "Klarlack entfernen",
|
||||
"cmd.render.compare.divider": "Render-Vergleichstrenner verschieben",
|
||||
"cmd.render.result.discard": "Renderergebnis verwerfen",
|
||||
"cmd.render.result.select": "Renderergebnis auswählen",
|
||||
@@ -1232,7 +1228,6 @@
|
||||
"cmd.sketch_options": "Skizzenpalettenoptionen",
|
||||
"cmd.sketch_paste": "Skizzenelemente einfügen",
|
||||
"cmd.sketch_point3d": "3D-Punkt",
|
||||
"cmd.sketch_point_divide": "Kurve teilen",
|
||||
"cmd.sketch_polygon_circumscribed": "Umgeschriebenes Polygon",
|
||||
"cmd.sketch_polygon_edge": "Polygon über eine Kante",
|
||||
"cmd.sketch_polygon_inscribed": "Eingeschriebenes Polygon",
|
||||
@@ -1310,7 +1305,6 @@
|
||||
"cmd.view.render_browser": "Render-Browser",
|
||||
"cmd.view.render_dock": "Render-Panel verschieben",
|
||||
"cmd.view.render_focus": "Render-Fokusansicht",
|
||||
"cmd.view.render_live": "Live-Rendering pausieren oder fortsetzen",
|
||||
"cmd.view.status_details": "Statusdetails des Ansichtsfensters",
|
||||
"cmd.view.status_items": "Ansichtsstatus-Elemente",
|
||||
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
|
||||
@@ -1478,7 +1472,6 @@
|
||||
"file_load.reading_percent": "Datei wird gelesen... {percent} %",
|
||||
"file_load.rebuilding": "Merkmal {done} von {total} wird neu aufgebaut",
|
||||
"file_load.recovering": "Design wird wiederhergestellt",
|
||||
"file_load.reloading_cloud": "Punktwolke wird neu geladen",
|
||||
"file_load.step_build": "Modell wird aufgebaut",
|
||||
"file_load.step_feature": "Merkmal {done} von {total}",
|
||||
"file_load.step_part": "Teil {done} von {total}",
|
||||
@@ -1718,7 +1711,6 @@
|
||||
"help.sketch.pattern.circular": "Kopiert Skizzengeometrie um einen Mittelpunkt, über einen vollen Kreis oder einen festen Winkel. Für Lochkreise, Speichen und Zähne.",
|
||||
"help.sketch.pattern.rectangular": "Kopiert Skizzengeometrie in Reihen und Spalten mit festen Abständen. Für Raster aus Bohrungen, Schlitzen oder Lüftungsöffnungen.",
|
||||
"help.sketch.point": "Setzt einen Skizzenpunkt. Als Bezug für Maße, Bedingungen, Bohrungsmitten oder spätere Elemente.",
|
||||
"help.sketch.point.divide": "Setzt Punkte, die die gewählten Kurven in gleich lange Abschnitte teilen. Die Punkte bleiben auf jeder Kurve und behalten ihren Abstand, wenn sie sich ändert.",
|
||||
"help.sketch.point3d": "Setzt bei eingeschalteter 3D-Skizze einen Punkt im Raum. Als Bezug für 3D-Kurven und Pfade.",
|
||||
"help.sketch.polygon.circumscribed": "Zeichnet ein regelmäßiges Polygon um einen Kreis, bemessen vom Mittelpunkt zur Mitte einer Seite. Wenn die Schlüsselweite bekannt ist, etwa bei einer Sechskantmutter.",
|
||||
"help.sketch.polygon.edge": "Zeichnet ein regelmäßiges Polygon aus den beiden Enden einer Seite. Wenn Länge und Lage einer Seite bekannt sind.",
|
||||
@@ -2421,10 +2413,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Bindungssymbole bei starkem Herauszoomen ausblenden",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Bindungssymbole verschwinden, wenn die Skizze so weit herausgezoomt ist, dass sie sie überladen würden. Aus: sie behalten bei jedem Zoom eine feste Bildschirmgröße.",
|
||||
"prefs.history": "Verlauf",
|
||||
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
|
||||
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
|
||||
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
|
||||
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
|
||||
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
|
||||
"prefs.history_origin_bodies_note": "Der Verlauf links oder unter dem Browser beginnt mit dem Körper-Ordner der aktiven Komponente und dem Ursprung, mit Augen und Menüs wie im Browser. Der Verlauf unten bleibt unverändert.",
|
||||
"prefs.history_play_button": "Schaltfläche „Verlauf abspielen“ in den Verlaufssteuerelementen",
|
||||
@@ -2565,8 +2553,6 @@
|
||||
"prefs.quick_measure_note": "Zeigt unten rechts in der Ansicht die Größe der Auswahl an: bei einer Kante die Länge, bei einer kreisförmigen Kante zusätzlich Durchmesser (Ø) und Radius (R), bei einer Fläche den Flächeninhalt, bei einem Punkt die Position, bei zwei Elementen Abstand und Winkel sowie bei mehreren Kanten oder Flächen die Gesamtlänge bzw. Gesamtfläche. Ein Klick kopiert den Messwert.",
|
||||
"prefs.remember_dialog_values": "Befehlsdialogwerte merken",
|
||||
"prefs.remember_dialog_values.help": "Standardmäßig aus. Wenn aktiviert, werden nach erfolgreicher Ausführung eines Befehls seine sicheren Werte und Optionen beim nächsten Öffnen desselben Befehls wiederverwendet, bis SolidCraft geschlossen wird.",
|
||||
"prefs.render_legacy_image_defaults": "Bisherige Render-Bildvorgaben verwenden",
|
||||
"prefs.render_legacy_image_defaults_help": "Den bisherigen manuellen Ablauf beibehalten: neue Setups mit CPU-Raster, feste Abtastung beim CPU-Raytracing und manuell geöffnete Ergebnisse. Beim Wechsel zu Render die Ansicht neu einpassen, Aufnahmen ohne Wiederherstellung ihrer Kamera wechseln und die bisherige Bildausrichtung und Navigation verwenden. Automatische Live-Bildrückmeldungen deaktivieren. Gespeicherte Setups bleiben unverändert.",
|
||||
"prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden",
|
||||
"prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.",
|
||||
"prefs.reports_saved": "{n} gespeichert",
|
||||
@@ -2695,8 +2681,6 @@
|
||||
"prefs.structure.work": "Arbeit",
|
||||
"prefs.supersampling_brief": "Rendert die Ansicht größer und verkleinert sie dann: sehr scharf, aber rechenintensiver.",
|
||||
"prefs.tab_accent_note": "Die Akzentfarbe richtet sich nach dem aktiven Tab.",
|
||||
"prefs.tab_double_click_renames": "Entwurfsregisterkarte per Doppelklick umbenennen",
|
||||
"prefs.tab_double_click_renames_note": "Geben Sie den neuen Namen ein und drücken Sie Enter oder klicken Sie woanders hin, um ihn zu übernehmen, oder Esc zum Abbrechen. Aus: Ein Doppelklick auf eine Registerkarte maximiert das Fenster, wie auf dem leeren Teil der Leiste.",
|
||||
"prefs.theme_file": "Designdatei",
|
||||
"prefs.theme_file_or_paste": "Datei oder Einfügen",
|
||||
"prefs.theme_follow": "Einer Designdatei folgen",
|
||||
@@ -2793,15 +2777,12 @@
|
||||
"refused.join": "Konnte nicht mit {body} vereinigt werden.",
|
||||
"refused.repair": "Teil reparieren",
|
||||
"refused.show_face": "Fläche zeigen",
|
||||
"render.adaptive.pilot_progress": "Adaptive Pilotphase · {current}/{target} Durchgänge",
|
||||
"render.adaptive.preview_help": "Pilotbilder bestimmen die Verteilung und sind vom Endbild ausgeschlossen. Produktionsiterationen nutzen ein eigenes Budget pro Pixel; die Iterationszahl ist keine einheitliche Samplezahl pro Pixel.",
|
||||
"render.adaptive.production_progress": "Adaptive Produktion · {current}/{target} Iterationen",
|
||||
"render.adaptive_absolute": "Absolute RGB-Fehleruntergrenze",
|
||||
"render.adaptive_coverage": "Zielwert für den Abdeckungsfehler",
|
||||
"render.adaptive_enable": "Samples mithilfe unabhängiger Pilot-Samples zuteilen",
|
||||
"render.adaptive_help": "Unabhängige Pilotsamples werden verworfen und bestimmen dann mindestens 16 Produktionssamples pro Pixel bis zum Wert Samples pro Pixel. CPU-Ressourcengrenzen umfassen beide Phasen. RGB- und Abdeckungsvarianz steuern die Verteilung; Pixel am Limit können verrauscht bleiben. Der Abschlussbericht zählt die tatsächlichen Samples.",
|
||||
"render.adaptive_help": "Nur GPU. Unabhängige Pilot-Samples werden verworfen. Anschließend werden pro Pixel 16 oder mehr Produktions-Samples zugeteilt, bis zu den Samples pro Pixel. Die Zuteilung richtet sich nach der RGB- und Abdeckungsvarianz; seltene Pfade können übersehen werden, und Pixel, die ihr Limit erreicht haben, können verrauscht bleiben. Der Abschlussbericht zählt den tatsächlichen Aufwand. Keine zertifizierte Garantie für ein bestimmtes Rauschniveau oder eine bestimmte Renderzeit.",
|
||||
"render.adaptive_minimum": "Minimale Produktions-Samples",
|
||||
"render.adaptive_pending": "Adaptive Verteilung mit unabhängigen Pilotsamples ist für CPU und GPU verfügbar. Bedingte Fresnel-Schichten erfordern die GPU. Die statistische Verteilung bestätigt keine Konvergenz.",
|
||||
"render.adaptive_pending": "Die adaptive Zuteilung mit unabhängigen GPU-Pilot-Samples und bedingte Fresnel-Schichten sind verfügbar. Zertifizierte Konvergenz, Garantien für seltene Ereignisse und adaptiver Lichttransport auf der CPU bleiben nicht verfügbar.",
|
||||
"render.adaptive_pilots": "Pilot-Samples pro Pixel",
|
||||
"render.adaptive_relative": "Zielwert für den relativen RGB-Fehler",
|
||||
"render.add_ground": "Add fitted ground to setup",
|
||||
@@ -2824,7 +2805,6 @@
|
||||
"render.aging_metallic": "Metallanteil der Schicht",
|
||||
"render.aging_origin": "Feldursprung · Weltkoordinaten (mm)",
|
||||
"render.aging_patina": "Kupfergrüne Patina",
|
||||
"render.aging_raytrace_help": "Alterung wird beim CPU-Raytracing und GPU-Pathtracing angewendet. Raster bleibt eine schematische Darstellung.",
|
||||
"render.aging_refresh_targets": "Aktuelle Körper prüfen",
|
||||
"render.aging_roughness": "Rauheit der Schicht",
|
||||
"render.aging_rust": "Oxidiertes Eisen / Rost",
|
||||
@@ -2845,7 +2825,6 @@
|
||||
"render.appearance_editor.closed": "Geschlossener Körper",
|
||||
"render.appearance_editor.coat": "Klarlack",
|
||||
"render.appearance_editor.cpu_reason": "Benötigt CPU-Strahlverfolgung.",
|
||||
"render.appearance_editor.discard_saved_edit": "Ungespeicherte Optikänderungen verwerfen",
|
||||
"render.appearance_editor.glass": "Glas und Absorption",
|
||||
"render.appearance_editor.glass_enable": "Physikalisches Glas für diesen Körper verwenden",
|
||||
"render.appearance_editor.glass_note": "Geschlossenes Glas verwendet geometrische Abstände. Es kann nicht mit Klarlack oder Texturen auf demselben Körper kombiniert werden.",
|
||||
@@ -2864,7 +2843,7 @@
|
||||
"render.appearance_editor.replace": "Gespeicherte Optik dieses Körpers ersetzen",
|
||||
"render.appearance_editor.revalidate": "Diesen Körper erneut prüfen",
|
||||
"render.appearance_editor.save": "Diese Optik speichern",
|
||||
"render.appearance_editor.save_reason": "Zum Speichern Glas an einem aktuellen CPU- oder GPU-Körper aktivieren. Für Klarlack „Klarlack in Aufnahme speichern“ verwenden. Lokale Bilddateien bleiben temporär.",
|
||||
"render.appearance_editor.save_reason": "Zum Speichern wird physikalisches Glas auf einem aktuellen GPU-Körper benötigt. Klarlack und lokale Bildtexturen bleiben vorübergehend.",
|
||||
"render.appearance_editor.saved": "Mit dieser Aufnahme gespeichert",
|
||||
"render.appearance_editor.saved_state": "Diese Optik ist mit dieser Aufnahme gespeichert.",
|
||||
"render.appearance_editor.scatter_reason": "Innere Streuung benötigt einen geschlossenen Körper.",
|
||||
@@ -2910,13 +2889,9 @@
|
||||
"render.base_metallic": "Metallanteil",
|
||||
"render.base_opaque_only": "Dieser Editor lässt Körper undurchsichtig. Für optische Transmission verwenden Sie die gesonderten Glaseinstellungen.",
|
||||
"render.batch_all_shots": "Alle Ansichten ({count})",
|
||||
"render.batch_current_settings_help": "Verwendet die aktuellen Bildeinstellungen mit jeder gespeicherten Aufnahmekamera, eine Aufnahme nach der anderen.",
|
||||
"render.batch_empty": "Vor dem Rendern aller Ansichten eine Ansicht speichern.",
|
||||
"render.batch_limit": "Es können höchstens 32 Ansichten gerendert werden.",
|
||||
"render.batch_owner_limit": "Es können höchstens 32 Dokument-Bildfolgen vorgehalten werden. Schließen Sie zuerst eine vorhandene Folge ab oder brechen Sie sie ab.",
|
||||
"render.batch_preparing": "Nächste Aufnahme wird vorbereitet",
|
||||
"render.batch_status": "{running} läuft · {waiting} warten",
|
||||
"render.batch_waiting_worker": "Warten auf die Rendering-Vorbereitung",
|
||||
"render.bloom_enable": "Darstellungs-Bloom",
|
||||
"render.bloom_help": "Kreativer Ausgabeglanz aus linearen Glanzlichtern mit drei normierten Durchläufen. Radius in Ausgabepixeln; Addition vor sRGB. Keine Linsen-, Volumen- oder Konvergenzsimulation. Das rohe EXR bleibt unverändert.",
|
||||
"render.bloom_opaque": "Bloom benötigt eine deckende Ausgabe. Transparentes PNG wird abgelehnt, nicht still abgeflacht.",
|
||||
@@ -2946,16 +2921,6 @@
|
||||
"render.category_paint": "Lack",
|
||||
"render.category_plastic": "Kunststoff",
|
||||
"render.category_wood": "Holz",
|
||||
"render.caustics_depth": "Kaustik-Pfadtiefe",
|
||||
"render.caustics_enable": "Glaskaustiken durch Punkt- und Richtungslichter",
|
||||
"render.caustics_help": "Die Photonenzahl bestimmt Rauschen und Aufwand. Der Sammelradius tauscht scharfe Details gegen eine glattere Schätzung. CPU benötigt indirekte Beleuchtung.",
|
||||
"render.caustics_path_required": "CPU-Kaustiken benötigen indirekte Beleuchtung.",
|
||||
"render.caustics_photons": "Photonen",
|
||||
"render.caustics_radius": "Sammelradius",
|
||||
"render.check_glass_cpu": "Die CPU unterstützt geprüfte ebene Glasscheiben und achsenparallele Boxen. Allgemeine geschlossene Netze, Verschachtelung und innere Streuung erfordern indirekte Beleuchtung. Durch geschlossenes Glas übertragene Schatten bleiben nicht unterstützt.",
|
||||
"render.check_glass_gpu": "Die GPU unterstützt geprüfte getrennte Glasmedien oder ausdrücklich aktivierte geprüfte Verschachtelung. Dafür müssen die äußeren Körper konvex und die inneren streng enthalten sein; Grenzen dürfen sich nicht berühren.",
|
||||
"render.check_media_remaining": "Dichtegitter, Displacement, vollständige spektrale Integration und fortgeführte Polarisation bleiben nicht verfügbar.",
|
||||
"render.check_media_scope": "Aktive innere Streuung benötigt ein geschlossenes Glasmedium ohne gerade Schattenübertragung und ohne Rauschreduzierung. Die GPU benötigt außerdem eine Fresnel-Schicht. Aktive äußere Atmosphäre kann nicht mit Glas oder Oberflächen-Rauschreduzierung kombiniert werden.",
|
||||
"render.check_opaque": "Transparenz: nur undurchsichtige Oberflächen; Hintergrund-Alpha wird unterstützt.",
|
||||
"render.check_sorted_alpha": "Transparenz: Näherung mit sortiertem Alpha.",
|
||||
"render.check_transmission": "Transparenz: physikalische Transmission.",
|
||||
@@ -2974,7 +2939,7 @@
|
||||
"render.children.copy_request": "Anfrage-JSON kopieren",
|
||||
"render.children.coverage_error": "Abdeckungsfehler",
|
||||
"render.children.cpu": "CPU-Raytracing",
|
||||
"render.children.denoise": "Bildrauschen reduzieren",
|
||||
"render.children.denoise": "Rauschen im fertigen Bild reduzieren",
|
||||
"render.children.depth": "Pfadtiefe",
|
||||
"render.children.distortion": "Verzeichnung",
|
||||
"render.children.edge_samples": "Kanten-Samples",
|
||||
@@ -3037,14 +3002,6 @@
|
||||
"render.choose_look": "Zuerst einen Look in der Bibliothek wählen",
|
||||
"render.chromatic_offset": "Chromatischer Kantenversatz",
|
||||
"render.clear_stage": "Remove saved stage",
|
||||
"render.coat_discard": "Klarlackänderung verwerfen",
|
||||
"render.coat_pending_edit": "Diese Klarlackänderung ist ungespeichert. Mit der Aufnahme speichern oder verwerfen.",
|
||||
"render.coat_remove": "Gespeicherten Klarlack entfernen",
|
||||
"render.coat_remove_note": "Entfernt den Klarlack dieses Ziels aus der Aufnahme. Geometrie, Aussehen und physikalische Materialien bleiben unverändert.",
|
||||
"render.coat_remove_title": "Gespeicherten Klarlack entfernen?",
|
||||
"render.coat_revalidate_saved": "Der gespeicherte Klarlack wurde geändert. Diese Änderung verwerfen und das Ziel vor der nächsten Bearbeitung erneut öffnen.",
|
||||
"render.coat_save": "Klarlack in Aufnahme speichern",
|
||||
"render.coat_saved_help": "Klarlack fügt undurchsichtigen Flächen eine glänzende Schicht hinzu. Mit der Aufnahme speichern. Glas kann nicht dasselbe Ziel verwenden. Auf der CPU dürfen auch benutzerdefinierte Reflexions- und Anisotropieüberschreibungen dieses Ziel nicht verwenden.",
|
||||
"render.compare_divider": "Vergleichstrenner",
|
||||
"render.compare_live": "Live",
|
||||
"render.compare_live_preview": "Live-Vorschau",
|
||||
@@ -3078,18 +3035,10 @@
|
||||
"render.cpu_budget_triangles": "Dreiecksobergrenze",
|
||||
"render.cpu_budget_workstation": "Workstation (komplexe Szenen)",
|
||||
"render.cpu_captured_hdr": "Für dieses Bild erfasstes HDR",
|
||||
"render.cpu_celestial_adopt": "CPU-Raytracing mit indirekter Beleuchtung · Pfadtiefe {depth} · {spp} SPP · verfolgte Schatten · Seed {seed} · 1 Primärsample",
|
||||
"render.cpu_celestial_adopt_label": "CPU-Raytracing mit indirekter Beleuchtung",
|
||||
"render.cpu_celestial_path_required": "Endliche Sonne und Verlaufshimmel verwenden indirekte CPU-Beleuchtung.",
|
||||
"render.cpu_celestial_switch": "Wähle CPU-Raytracing mit indirekter Beleuchtung, um diese Sonne oder diesen Himmel beizubehalten.",
|
||||
"render.cpu_celestial_unportable": "Behalte GPU-Pathtracing für die aktuellen Transmissions-Sample-Einstellungen bei.",
|
||||
"render.cpu_check_capture_help": "Prüfen und Starten verifizieren dieselbe Datei-Prüfsumme oder verwenden das eingebettete HDR. Jedes Bild behält seine erfasste Quelle.",
|
||||
"render.cpu_check_changed": "Die Einstellungen oder das Modell haben sich nach dieser Prüfung geändert. Prüfen Sie erneut, bevor Sie rendern.",
|
||||
"render.cpu_constant_direct_help": "Ohne indirekte Beleuchtung kann diese konstante Quelle in Reflexionen und durch Glas sichtbar sein. Sie beleuchtet keine undurchsichtigen Flächen. Schalte indirekte Beleuchtung ein, um sie als Szenenlicht zu nutzen.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Pfadtiefe {depth}; progressives Sampling mit {spp} Samples/Pixel (Seed {seed}); ein Primärstrahl pro Sample; Raytracing-Schatten. Bildgröße, CPU-Budget und andere Einstellungen bleiben erhalten.",
|
||||
"render.cpu_constant_help": "Konstante Strahldichte ist für reflektierte oder durchgelassene Strahlen sichtbar. Sie liefert keine direkte diffuse Beleuchtung.",
|
||||
"render.cpu_custom_directions": "Vorgabe durch eigene Richtungslichter ersetzen",
|
||||
"render.cpu_denoise_help": "Verringert das Rauschen bei jeder Bildaktualisierung. Feine Details und Übergänge der Tiefenschärfe können weicher werden. Die EXR-Rohdaten bleiben unverändert. Nachträgliche Anpassungen eines fertigen Bildes verwenden dessen Rohbild und wenden die Rauschreduzierung nicht erneut an.",
|
||||
"render.cpu_direction": "Richtung zum Licht (X, Y, Z)",
|
||||
"render.cpu_directional": "Richtungslicht",
|
||||
"render.cpu_directional_add": "Richtungslicht hinzufügen",
|
||||
@@ -3101,11 +3050,8 @@
|
||||
"render.cpu_environment_samples": "HDR-Beleuchtungs-Samples",
|
||||
"render.cpu_exposure_help": "Die CPU-Belichtung muss zwischen −16 und 16 EV liegen. Eine GPU-Belichtung außerhalb dieses Bereichs bleibt erhalten, bis Sie sie ändern.",
|
||||
"render.cpu_fixed": "Einzelbild",
|
||||
"render.cpu_fixed_retained": "Feste Abtastung beibehalten",
|
||||
"render.cpu_glass_enable_path": "Indirekte Beleuchtung einschalten",
|
||||
"render.cpu_hdr_help": "Radiance-HDR-Datei (.hdr), Panorama im Format 2:1, bis zu 2048 × 1024 Pixel und 16 MiB. Für die CPU müssen die sRGB/D65-Primärfarben ausdrücklich in der Datei angegeben sein. Dateien ohne Angabe der Primärfarben sowie EXR-Dateien werden abgelehnt.",
|
||||
"render.cpu_hdr_reflection_help": "Raue HDR-Reflexionen erfordern vollständig metallische Körper- und Flächenoberflächen. Die Prüfung meldet nicht unterstützte Kombinationen.",
|
||||
"render.cpu_indirect_method_required": "Wähle CPU-Raytracing, um diesen Modus für indirekte Beleuchtung einzuschalten.",
|
||||
"render.cpu_key_rim": "Haupt- und Konturlicht",
|
||||
"render.cpu_last_check": "Letzte CPU-Prüfung",
|
||||
"render.cpu_light_help": "Bis zu acht Richtungslichter. Eine leere eigene Liste schaltet sie aus; die Wahl einer Vorgabe stellt deren Lichter wieder her. Farben sind lineares RGB, und die Intensität ist ein relativer Wert, keine Leistungsangabe in Watt.",
|
||||
@@ -3117,32 +3063,18 @@
|
||||
"render.cpu_lighting": "Beleuchtungsvorgabe",
|
||||
"render.cpu_lights": "CPU-Lichter",
|
||||
"render.cpu_linear_color": "Lineare Farbe (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "CPU-Renderings können gespeicherte Karten undurchsichtiger Flächen verwenden: Oberfläche wählen und die Karten in der Aufnahme speichern. Mit indirekter Beleuchtung darf ein anderer Körper Glas verwenden; der Körper mit Karten darf kein Glas sein. Ausgewählte Reflexionen, Anisotropie und prozedurale Effekte ausschalten. Temporäre Kartendateipfade verwenden die GPU.",
|
||||
"render.cpu_material_certification": "Die CPU-Prüfung validiert unterstützte Glasgeometrie vor dem Rendern. Geschlossenes Glas verwendet geometrische Abstände.",
|
||||
"render.cpu_neutral": "Neutral",
|
||||
"render.cpu_opaque_hint": "CPU-Raytracing verwendet einen undurchsichtigen Hintergrund.",
|
||||
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
|
||||
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
|
||||
"render.cpu_path_depth": "Pfadtiefe",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_lighting": "Indirekte Beleuchtung",
|
||||
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_point": "Punktlicht",
|
||||
"render.cpu_point_add": "Punktlicht hinzufügen",
|
||||
"render.cpu_point_help": "Bis zu acht Punktlichter, zusätzlich zur gewählten Richtungsbeleuchtung. Abstände in mm; der Mindestabstand darf den Referenzabstand nicht überschreiten.",
|
||||
"render.cpu_primary_samples": "Kanten-Samples pro Pixel",
|
||||
"render.cpu_progressive": "CPU progressiv",
|
||||
"render.cpu_progressive_adopt": "Progressive Abtastung verwenden",
|
||||
"render.cpu_progressive_adopt_help": "Die progressive Abtastung verwendet ein Kamerasample pro Durchlauf. Bildgröße, Lichtquellen und Reflexionseinstellungen bleiben unverändert.",
|
||||
"render.cpu_progressive_always": "Progressive Abtastung ist immer aktiv",
|
||||
"render.cpu_progressive_help": "Neue CPU-Kamerasamples sammeln und jeden vollständigen Durchgang zeigen. Ausgewählte Reflexionen verwenden einen Versuch pro Kamerasample; zertifiziertes Glas und planare Anisotropie behalten ihre bisherigen Grenzen. Adaptive Pilot- und Produktionssamples teilen sich das CPU-Profilbudget.",
|
||||
"render.cpu_progressive_help": "Den vorhandenen CPU-Integrator in Durchgängen mit je einem Primär-Sample akkumulieren. Ausgewählte Reflexionen erfordern ein Sample; Flächenlichter, Dielektrika und Anisotropie erfordern „Einzelbild“.",
|
||||
"render.cpu_progressive_limits": "Höchstens 1.048.576 Pixel und 16.777.216 Auswertungen von Pixel × Sample. Das Ändern der Samples passt die Bildgröße nie automatisch an.",
|
||||
"render.cpu_progressive_reflection_adopt": "Setzen Sie die Reflexionsversuche ausgewählter Körper auf 1, bevor Sie die progressive Abtastung übernehmen.",
|
||||
"render.cpu_progressive_seed": "Startwert für das CPU-Sampling",
|
||||
"render.cpu_progressive_spp": "Samples pro Pixel",
|
||||
"render.cpu_quality_budget_help": "Ändert Größe, Obergrenze der Produktionssamples, Startwert und CPU-Ressourcenprofil. Kamera, Objektiv, Beleuchtung, GI-Tiefe und Effekte bleiben erhalten.",
|
||||
"render.cpu_quality_progressive_required": "Aktiviere unten progressive Samples, bevor du ein CPU-Budget auswählst.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · Sample-Grenze {samples} · Startwert {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "Raue Reflexion hinzufügen",
|
||||
"render.cpu_reflection_depth": "Reflexionstiefe (1–8)",
|
||||
"render.cpu_reflection_empty": "Keine Körper ausgewählt: Szenenreflexionen sind aus.",
|
||||
@@ -3156,21 +3088,7 @@
|
||||
"render.cpu_runtime_help": "Numerische Licht- und Umgebungseinstellungen werden mit dem Setup gespeichert. Körperreflexionen und HDR-Dateien bleiben vorübergehend. Vorhandene Werte für Flächenlicht und Spotkegel bleiben erhalten.",
|
||||
"render.cpu_sampling_mode": "CPU-Sampling-Modus",
|
||||
"render.cpu_shadows": "Schatten werfen",
|
||||
"render.cpu_sky_bounds": "Die Himmelsrotation muss endlich innerhalb ±1.000.000 Radiant liegen; die Intensität muss 0–100 sein.",
|
||||
"render.cpu_studio_enable": "Indirekte Beleuchtung für dieses Lichtset aktivieren",
|
||||
"render.cpu_studio_enable_apply": "Indirekte Beleuchtung aktivieren und anwenden",
|
||||
"render.cpu_studio_indirect_help": "Dieses Sonnen-Lichtset benötigt indirekte Beleuchtung mit Tiefe 8, verfolgte Schatten und eine Kamerastichprobe pro Durchlauf. Ist progressives Rendern ausgeschaltet, beginnt es mit 16 Stichproben pro Pixel und Startwert 0. Vorhandene progressive Einstellungen und das Ressourcenprofil bleiben erhalten. Unvereinbare Einstellungen verhindern die Anwendung, ohne sie zu ändern.",
|
||||
"render.cpu_studio_radiance_limit": "Dieses Lichtset hat eine Flächenstrahldichte von {radiance}, über der CPU-Grenze von 64. Die vorgegebene Beleuchtung bleibt erhalten; wählen Sie ein anderes Lichtset oder GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "Streuung im Glas auf der CPU benötigt ausgeschaltete Rauschreduzierung. Die Einstellungen bleiben erhalten.",
|
||||
"render.cpu_subsurface_help": "Streuung lenkt Licht im Inneren geschlossenen Glases um. Positive Raten benötigen indirekte Beleuchtung ohne Rauschreduzierung. Die Rate zählt Kollisionen pro mm; 0 fügt keine Streuung hinzu. Positive Richtungswerte bevorzugen vorwärts, negative rückwärts.",
|
||||
"render.cpu_summary": "CPU-Raytracing · Reflexionen · HDR · Richtungs- und Punktlichter",
|
||||
"render.cpu_transparent_help": "Transparentes PNG entfernt den Hintergrund und erhält die Kantenabdeckung. Körper- und Flächendeckkraft müssen 100 % bleiben.",
|
||||
"render.cpu_volume_enable_path": "Indirekte Beleuchtung einschalten",
|
||||
"render.cpu_volume_enable_path_plan": "CPU-Raytracing · Pfadtiefe {depth} · {spp} Samples/Pixel · Seed {seed} · Raytracing-Schatten. Atmosphäre und Bildbudget bleiben erhalten.",
|
||||
"render.cpu_volume_help": "CPU-Atmosphäre nutzt indirekte Beleuchtung. Bei Extinktion null bleibt die gespeicherte Box ohne Nebel erhalten.",
|
||||
"render.cpu_volume_method_adopt": "CPU-Raytracing mit indirekter Beleuchtung",
|
||||
"render.cpu_volume_method_incompatible": "Diese Licht-, Sampling- oder Atmosphäreinstellungen lassen sich nicht auf diese Methode übertragen. Die Einstellungen bleiben erhalten.",
|
||||
"render.cpu_volume_path_required": "Indirekte Beleuchtung einschalten, um CPU-Atmosphäre zu nutzen.",
|
||||
"render.cpu_warm_cool": "Warm und kühl",
|
||||
"render.diag_bodies": "Sichtbare Körper",
|
||||
"render.diag_job_details": "Erfasste Auftragsdetails",
|
||||
@@ -3180,7 +3098,6 @@
|
||||
"render.diag_preflight_details": "Details der Vorabprüfung",
|
||||
"render.diag_request": "Aktuelle Anfrage im Wortlaut",
|
||||
"render.diag_triangles": "Dreiecke",
|
||||
"render.directional_sun_limit": "Die GPU unterstützt insgesamt acht gerichtete Lichtquellen, darunter höchstens eine weiche Sonne. Dupliziere ein hartes gerichtetes Licht für eine weitere entfernte Quelle.",
|
||||
"render.discard_continue": "Entwurf verwerfen und fortfahren",
|
||||
"render.draft_confirm_help": "Der aktuelle Render-Entwurf enthält nicht angewendete Änderungen. Speichern Sie sie vor dem Wechsel, verwerfen Sie sie ausdrücklich oder bearbeiten Sie weiter.",
|
||||
"render.draft_confirm_title": "Bearbeitete Rendereinstellungen beibehalten",
|
||||
@@ -3330,7 +3247,7 @@
|
||||
"render.export_display": "Export: Anzeige-PNG",
|
||||
"render.export_image_label": "Bild exportieren",
|
||||
"render.export_linear": "Export: lineares Roh-EXR",
|
||||
"render.exr_pending": "CPU-Raytracing und GPU-Pathtracing können rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
|
||||
"render.exr_pending": "Die GPU kann rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
|
||||
"render.film_grain": "Fotografisches Korn",
|
||||
"render.fit": "Produkt einpassen",
|
||||
"render.fixed_camera": "Kamera",
|
||||
@@ -3368,20 +3285,11 @@
|
||||
"render.frame_tall": "9:16 · Vertikal",
|
||||
"render.frame_thirds": "Drittellinien",
|
||||
"render.frame_wide": "16:9 · Breitbild",
|
||||
"render.glass_abbe": "Abbe-Zahl",
|
||||
"render.glass_dispersion": "Chromatische Dispersion",
|
||||
"render.glass_film": "Dünnschichtinterferenz",
|
||||
"render.glass_film_ior": "Brechungsindex der Schicht",
|
||||
"render.glass_film_thickness": "Schichtdicke",
|
||||
"render.glass_model_enable": "Erweitertes Glasmodell",
|
||||
"render.glass_model_help": "Glasrauheit streut durchgelassene Strahlen. Dispersion und Schichtfarbe verwenden drei Wellenlängenbänder. Raue Scheiben benötigen ausgeschaltete gerade Schattenübertragung.",
|
||||
"render.glass_roughness": "Glasrauheit",
|
||||
"render.gpu_depth": "Maximale Pfadtiefe",
|
||||
"render.gpu_options_help": "Samples, Tiefe, Startwert und Belichtung werden mit diesem Setup gespeichert. Es wird nur die aktive Bildmethode gespeichert.",
|
||||
"render.gpu_path_crashed": "SolidCraft wurde beim letzten Einrichten auf diesem Grafikadapter beendet, wahrscheinlich im Grafiktreiber, und richtet ihn deshalb nicht von selbst erneut ein. Verwenden Sie stattdessen Raytracing auf der CPU oder versuchen Sie es erneut (nach einem Treiberupdate wird es ebenfalls erneut versucht).",
|
||||
"render.gpu_path_failed": "GPU-Pathtracer nicht verfügbar",
|
||||
"render.gpu_path_first_setup": "Das erste Setup auf dieser GPU kann mehrere Minuten dauern; SolidCraft bleibt währenddessen bedienbar. Spätere Starts verwenden die kompilierten Shader erneut, sofern der Treiber sie behält.",
|
||||
"render.gpu_path_not_started": "Die Einrichtung des GPU-Pfadtracers wurde noch nicht gestartet.",
|
||||
"render.gpu_path_preparing": "GPU-Pathtracer: Shader werden kompiliert…",
|
||||
"render.gpu_path_retry": "Erneut versuchen",
|
||||
"render.gpu_seed": "Zufallsstartwert",
|
||||
@@ -3577,8 +3485,6 @@
|
||||
"render.light_product_centre": "Produktmitte",
|
||||
"render.light_size": "Größe",
|
||||
"render.light_softness": "Weichheit",
|
||||
"render.light_space_world": "Lichter in Weltkoordinaten",
|
||||
"render.light_space_world_help": "Lichtpositionen und -richtungen verwenden Weltkoordinaten. Beim Umkreisen mit der Kamera bleiben die Lichter an ihrer Position.",
|
||||
"render.light_temperature": "Farbtemperatur",
|
||||
"render.light_temperature_help": "Angenäherte Schwarzkörperfarbe; als lineares RGB gespeichert.",
|
||||
"render.light_type": "Typ",
|
||||
@@ -3635,25 +3541,6 @@
|
||||
"render.linear_help": "Behält ein lineares 32-Bit-Gleitkomma-RGBA-Rohbild vor Entrauschen, Belichtung, Tone-Mapping, Filtern und Hintergrund. Lineares sRGB/D65; Abdeckung vormultipliziert. Zusätzlicher Speicher wird ausdrücklich zugelassen. Keine Komprimierung und keine zusätzlichen Durchgänge.",
|
||||
"render.linear_retain": "Lineares Roh-EXR behalten",
|
||||
"render.linear_unavailable": "Für das gewählte fertige Bild wurde kein EXR behalten. Aktivieren Sie das Behalten vor dem Rendern; ein älteres PNG kann die rohe Strahldichte nicht wiederherstellen.",
|
||||
"render.live.adaptive": "Live · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "Die Render-Anfrage hat sich beim Anhalten des Live-Renderings geändert. Starten Sie das finale Bild erneut.",
|
||||
"render.live.final_waiting": "Finales Bild vorbereiten · Live-Render-Ressourcen freigeben",
|
||||
"render.live.invalid_size": "Live-Rendering benötigt eine positive Ausgabebreite und -höhe.",
|
||||
"render.live.pause": "Live pausieren",
|
||||
"render.live.paused": "Live · Pausiert",
|
||||
"render.live.paused_outdated": "Live · Pausiert · Veraltet",
|
||||
"render.live.quality_help": "Interaktive Qualität: höchstens 512 Pixel an der längsten Kante. Verwendet den gewählten Renderer und seine unterstützten Effekte. Endauflösung, Einstellungen und Export bleiben getrennt.",
|
||||
"render.live.refused": "Live nicht verfügbar: {reason}",
|
||||
"render.live.resume": "Live fortsetzen",
|
||||
"render.live.samples": "Live · {width} × {height} · {current}/{target} Samples",
|
||||
"render.live.source_current": "{method} · Live",
|
||||
"render.live.source_outdated": "{method} · Vorheriges Bild",
|
||||
"render.live.source_outdated_help": "Zeigt das zuletzt aufgenommene Bild, während das Live-Rendering aktualisiert wird oder nicht verfügbar ist. Es kann von der aktuellen Szene oder den Bildeinstellungen abweichen.",
|
||||
"render.live.source_settings_help": "Ausgabe öffnen, um Renderer und Bildeinstellungen zu bearbeiten.",
|
||||
"render.live.stopped": "Live-Rendering angehalten",
|
||||
"render.live.updating": "Live · Interaktive Qualität (bis 512 px) · Aktualisierung",
|
||||
"render.live.waiting_canvas": "Warten auf die Render-Ansicht",
|
||||
"render.live.worker_stopped": "Der Aufnahmeprozess für das Live-Rendering wurde vor der Rückgabe eines Ergebnisses beendet",
|
||||
"render.look_add": "Farblook hinzufügen",
|
||||
"render.look_applied": "{name} angewendet",
|
||||
"render.look_bleach": "Von Bleach-Bypass inspiriert",
|
||||
@@ -3683,9 +3570,6 @@
|
||||
"render.material_coat_help": "Illustrative dielektrische Beschichtung über dem vorhandenen undurchsichtigen Erscheinungsbild. Gewicht null lässt das Grundmaterial unverändert.",
|
||||
"render.material_color_map": "Grundfarbentextur (sRGB)",
|
||||
"render.material_color_replace": "Basismap ersetzt Körperfarbe",
|
||||
"render.material_cpu_glass_only": "Temporäre Bilddatei-Karten benötigen die GPU. Die CPU kann gespeicherte integrierte Oberflächenkarten verwenden.",
|
||||
"render.material_cpu_optics_limits": "Indirekte Beleuchtung für unterstützte geschlossene Glasformen, geprüfte Verschachtelung und Streuung im Inneren einschalten. Dünne Scheiben können Licht für Schatten durchlassen. Sonst eine ebene Scheibe oder achsenparallele Box verwenden. Berührende Formen und durch geschlossenes Glas übertragene Schatten werden weiterhin nicht unterstützt. Die Werte bleiben erhalten.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Gespeicherte CPU-Optik kann nicht mit temporären optischen Bindungen kombiniert werden. Geprüfte Optik speichern oder die temporäre Bindung entfernen.",
|
||||
"render.material_custom": "Eigene illustrative Optik",
|
||||
"render.material_enable": "Diese GPU-Materialüberschreibungen verwenden",
|
||||
"render.material_filter": "Szenenkörper filtern…",
|
||||
@@ -3706,7 +3590,6 @@
|
||||
"render.material_normal_map": "Normalenbild (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "Normalenstärke (0–1)",
|
||||
"render.material_origin": "Texturursprung X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "Physikalisches Glas benötigt CPU-Raytracing oder GPU-Pathtracing.",
|
||||
"render.material_refresh": "Szenenkörper aktualisieren",
|
||||
"render.material_refresh_needed": "Die Szene hat sich geändert. Aktualisieren Sie die Körperliste, bevor Sie ein Ziel hinzufügen.",
|
||||
"render.material_remove": "Diese Zuweisung entfernen",
|
||||
@@ -3716,7 +3599,6 @@
|
||||
"render.material_revalidate_needed": "Diese Zuweisung gehört zu einer früheren Szenenrevision. Prüfen Sie die genaue Körperidentität erneut, bevor Sie rendern.",
|
||||
"render.material_roughness": "Rauheit des Klarlacks (0,045–1)",
|
||||
"render.material_roughness_map": "Rauheitsmap (linear)",
|
||||
"render.material_saved_edit_pending": "Diese Optikänderungen sind ungespeichert. Geprüfte Werte speichern, die gespeicherte Zuordnung entfernen oder Änderungen verwerfen. Temporäre Bindungen und nicht unterstützte Effekte können das gespeicherte Ziel nicht überschreiben.",
|
||||
"render.material_shadow": "Gerade Schattenstrahlen durchlassen",
|
||||
"render.material_target_missing": "Ordnungsnummer und Name des Körpers stimmen nicht mehr überein. Entfernen Sie diese Zuweisung und wählen Sie den gewünschten Körper ausdrücklich erneut.",
|
||||
"render.material_thickness": "Dicke der dünnen Schicht (mm)",
|
||||
@@ -3728,7 +3610,7 @@
|
||||
"render.method_cpu": "CPU-Raytracing",
|
||||
"render.method_gpu": "GPU-Pathtracing",
|
||||
"render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.",
|
||||
"render.method_raster": "CPU-Rasterung (schematisch)",
|
||||
"render.method_raster": "CPU-Rasterung",
|
||||
"render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
|
||||
"render.nested_enable": "Zertifizierte verschachtelte Dielektrika",
|
||||
"render.nested_help": "Bis zu 16 umschließende Schichten; jedes übergeordnete Element muss konvex und jedes untergeordnete strikt darin enthalten sein. Überschneidungen, sich berührende Flächen, dünne Schichten und die Transmission gerader Schattenstrahlen werden abgelehnt. Der Zustand „Kamera im Inneren“ folgt der zertifizierten Hierarchie. Erzeugt keine Flüssigkeitsoberfläche und verschmilzt keine sich berührenden CAD-Grenzflächen.",
|
||||
@@ -3756,19 +3638,16 @@
|
||||
"render.output_exr_unavailable": "EXR-Ausgabeeinstellungen sind hier nicht verfügbar. Linearer EXR-Export erfordert ein fertiges GPU-Bild mit gespeichertem linearem Original.",
|
||||
"render.output_fit": "Einpassen",
|
||||
"render.output_format": "Format",
|
||||
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales CPU/GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Anzeigefiltern. Der Export verwendet nie den Vorschaucache oder einen anderen Auftrag.",
|
||||
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Filtern. Export ersetzt nie durch Vorschaucache oder anderen Auftrag.",
|
||||
"render.output_grading": "Farbkorrektur",
|
||||
"render.output_image": "Bild",
|
||||
"render.output_largest": "{width} × {height} max.",
|
||||
"render.output_lock_ratio": "Seitenverhältnis sperren",
|
||||
"render.output_master_unavailable": "Zum EXR-Export ein fertiges Bild mit EXR-Rohdaten auswählen. EXR-Rohdaten vor dem Rendern neuer Bilder aktivieren.",
|
||||
"render.output_over_budget": "Das angeforderte Bild überschreitet den gewählten Ressourcenrahmen. Wählen Sie ausdrücklich eine passende Größe oder ein passendes Profil; es erfolgt keine automatische Herabstufung.",
|
||||
"render.output_presentation_limits": "CPU-Bildeffekte und EXR-Ausgabe unterstützen Bilder mit bis zu 8192 Pixeln Breite oder Höhe und insgesamt höchstens 16.777.216 Pixeln.",
|
||||
"render.output_presets_help": "Format, Qualität und Prüfliste befinden sich im Ausgabeeditor.",
|
||||
"render.output_quality": "Qualität",
|
||||
"render.output_quality_waiting": "Qualitätsdefinition steht noch aus",
|
||||
"render.output_ratio_refused": "Die verknüpfte Abmessung überschreitet das Größenlimit dieser Methode. Die vorherige Größe bleibt erhalten; entsperren Sie das Verhältnis oder wählen Sie einen kleineren Wert.",
|
||||
"render.output_raytrace_only": "Bildeffekte erfordern CPU-Raytracing oder GPU-Pathtracing.",
|
||||
"render.output_ready": "Bereit zum Rendern",
|
||||
"render.output_run_check": "Prüfung starten",
|
||||
"render.output_size_limit": "{method}: bis zu {dimension} px pro Seite und {pixels} Pixel. Größte passende Größe: {width} × {height}.",
|
||||
@@ -3823,12 +3702,6 @@
|
||||
"render.post_saturation": "Sättigung",
|
||||
"render.post_title": "Darstellungsfilter",
|
||||
"render.post_vignette": "Vignettierung",
|
||||
"render.preparation_cancelled": "Rendering-Vorbereitung abgebrochen",
|
||||
"render.preparation_checking": "Rendering wird geprüft",
|
||||
"render.preparation_retirement_failed": "Die Bereinigung der Rendering-Vorbereitung ist fehlgeschlagen. Dieses Fenster kann kein weiteres Bild vorbereiten.",
|
||||
"render.preparation_retiring": "Rendering-Vorbereitung wird abgeschlossen",
|
||||
"render.preparation_unavailable": "Rendering-Vorbereitung nicht verfügbar",
|
||||
"render.presentation_banner": "Bildeffekte funktionieren mit CPU- und GPU-Raytracing. Das Öffnen dieser Seite ändert die Methode nicht.",
|
||||
"render.preview": "Integrierte Vorschau",
|
||||
"render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
|
||||
"render.preview_image_settings_help": "Die Live-Vorschau behält eine feste Studiobeleuchtung. Belichtung und Bildbeleuchtung gelten für den Bildauftrag, wenn Sie „Prüfen“ oder „Start“ verwenden.",
|
||||
@@ -3847,15 +3720,11 @@
|
||||
"render.quality_ack": "Die Projektion mit festem Budget und ihre Auslassungen bestätigen",
|
||||
"render.quality_apply": "Dieses feste Sampling-Budget verwenden",
|
||||
"render.quality_balanced": "Ausgewogen",
|
||||
"render.quality_budget_help": "Legt Größe, Samples, Tiefe, Startwert und Rauschreduzierung fest. Andere Bildeinstellungen bleiben erhalten.",
|
||||
"render.quality_custom": "Benutzerdefiniert",
|
||||
"render.quality_denoise_off": "Rauschreduzierung: Aus",
|
||||
"render.quality_denoise_on": "Rauschreduzierung: Ein",
|
||||
"render.quality_draft": "Entwurf",
|
||||
"render.quality_high": "Hoch",
|
||||
"render.quality_projection_help": "Diese Prüfung ändert nur Auflösung, Abtastbudget, Pfadtiefe, Startwert und Rauschreduzierung. Andere Einstellungen bleiben erhalten; das Budget garantiert keine Konvergenz.",
|
||||
"render.quality_projection_help": "Q-Vorgaben enthalten künftige Anforderungen. Diese Aktion projiziert nur ein unterstütztes PNG-Budget mit festen Samples und lässt adaptives Stoppen, rohe lineare Ausgabe und unabhängige Transmissionslimits ausdrücklich aus.",
|
||||
"render.quality_review": "Sampling-Budget prüfen",
|
||||
"render.quality_summary": "{width} × {height} · {samples} Samples · Tiefe {depth} · Startwert {seed} · {denoise}",
|
||||
"render.queue_pending": "In dieser Oberfläche gibt es keine automatische Produktionswarteschlange und keinen Animations-Renderer. Fertige Einzelbilder bleiben separat exportierbar.",
|
||||
"render.radial_assign": "Erscheinungsbild zuweisen",
|
||||
"render.radial_compare": "Vergleichen",
|
||||
@@ -3885,7 +3754,7 @@
|
||||
"render.result_navigation": "Verschieben und Zoomen folgen den Navigationseinstellungen. Leertaste + Ziehen verschiebt.",
|
||||
"render.result_open": "Das fertige Bild in der Hauptansicht öffnen.",
|
||||
"render.result_preview": "Live-Vorschau",
|
||||
"render.result_provisional": "Vorläufiges Bild",
|
||||
"render.result_provisional": "Vorläufiges CPU-Bild",
|
||||
"render.result_provisional_help": "Dieser Zwischenstand ist nicht exportierbar. Der Export behält das letzte fertige Ergebnis; ein Abbruch kehrt zu diesem Ergebnis zurück.",
|
||||
"render.result_reduced_display": "Anzeigevorschau auf die Geräte-/Cachegrenze reduziert. PNG und Export behalten die volle Auflösung; native 100%-Ansicht ist hier nicht verfügbar.",
|
||||
"render.result_retained": "Das zuletzt fertiggestellte Bild wird angezeigt; ein neueres Rendering hat es noch nicht ersetzt.",
|
||||
@@ -3974,11 +3843,9 @@
|
||||
"render.shot_frame_subject": "Motiv einpassen",
|
||||
"render.shot_invalid_camera": "Kamerawerte müssen endlich und im gültigen Bereich sein.",
|
||||
"render.shot_lens": "Objektiv",
|
||||
"render.shot_lens_cpu_progressive_reason": "Vor der CPU-Schärfentiefe unter Sampling die progressive Abtastung übernehmen.",
|
||||
"render.shot_lens_enable_reason": "Schärfentiefe aktivieren, um Blende und Fokusabstand zu bearbeiten.",
|
||||
"render.shot_lens_gpu_reason": "Schärfentiefe benötigt GPU-Pfadverfolgung.",
|
||||
"render.shot_lens_perspective_reason": "Schärfentiefe benötigt perspektivische Projektion.",
|
||||
"render.shot_lens_raytrace_reason": "Schärfentiefe ist für CPU-Raytracing und GPU-Pathtracing verfügbar.",
|
||||
"render.shot_look_selection": "Auswahl anvisieren",
|
||||
"render.shot_manager": "Gespeicherte Aufnahmen",
|
||||
"render.shot_new_name": "Neuer Aufnahmename",
|
||||
@@ -4012,12 +3879,7 @@
|
||||
"render.sky_horizon": "Horizont · lineares RGB",
|
||||
"render.sky_zenith": "Zenit · lineares RGB",
|
||||
"render.soft_focus": "Weichzeichnung",
|
||||
"render.spot_axis": "Abstrahlrichtung",
|
||||
"render.spot_cone": "Spotkegel",
|
||||
"render.spot_gpu_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer. Sehr schmale Übergänge erfordern CPU-Rendering.",
|
||||
"render.spot_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer.",
|
||||
"render.spot_inner": "Innerer Halbwinkel",
|
||||
"render.spot_outer": "Äußerer Halbwinkel",
|
||||
"render.stage_frame": "Bildausschnitt",
|
||||
"render.stage_layout.appearance_note": "Zum Anwenden auf die Auswahl klicken.",
|
||||
"render.stage_layout.studio_note": "Vor dem Anwenden zur Prüfung klicken.",
|
||||
@@ -4057,7 +3919,7 @@
|
||||
"render.stage_panels.prepare_plan": "Erstellungsplan vorbereiten",
|
||||
"render.stage_panels.projects": "Projiziert",
|
||||
"render.stage_panels.quality_ack": "Ich bestätige das feste Budget und seine Auslassungen",
|
||||
"render.stage_panels.quality_omissions": "Dieses Budget legt keine adaptiven Fehlerziele, eigenen Transmissionsgrenzen, Russian-Roulette-Regeln oder Speicherung des linearen Masters fest und garantiert keine Konvergenz.",
|
||||
"render.stage_panels.quality_omissions": "Adaptives Stoppen und Fehlerziele, separate Transmissionstiefe, Russian-Roulette-Regeln, lineare Rohausgabe, EXR-Master und Konvergenznachweis.",
|
||||
"render.stage_panels.reference": "Referenz",
|
||||
"render.stage_panels.references": "Referenzen",
|
||||
"render.stage_panels.remove_optics": "Optik entfernen",
|
||||
@@ -4101,7 +3963,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Originale HDR-Umgebung · gesamte Szene",
|
||||
"render.stage_panels.subtitle_maps": "Erstellte räumliche Texturen · temporäre Vorschau",
|
||||
"render.stage_panels.subtitle_physical": "Schreibgeschützter Plan · kein Material erstellt oder zugewiesen",
|
||||
"render.stage_panels.subtitle_quality": "GPU-Abtastbudget · nur aufgeführte Einstellungen",
|
||||
"render.stage_panels.subtitle_quality": "Festes GPU-Budget · zukünftige Anforderungen ausgelassen",
|
||||
"render.stage_panels.subtitle_rig": "Erstellter Lichtaufbau · Lichtquellen und passive Flächen",
|
||||
"render.stage_panels.subtitle_saved_maps": "Erstellte Texturen · mit dieser Aufnahme gespeichert",
|
||||
"render.stage_panels.subtitle_stage": "Physischer Boden und Bühne · an das Produkt angepasst",
|
||||
@@ -4126,7 +3988,7 @@
|
||||
"render.studio_area_help": "Bis zu vier einseitige Rechtecke. Orthogonale Halbkanten U und V bestimmen die Größe; U × V zeigt zur beleuchteten Seite. Die Koordinaten sind Weltmillimeter.",
|
||||
"render.studio_center": "Mittelpunkt X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Rauschen im fertigen Bild reduzieren",
|
||||
"render.studio_denoise_help": "Kantenerhaltender Filter für progressive und fertige Bilder; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
|
||||
"render.studio_denoise_help": "Abschließender kantenerhaltender Filter; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
|
||||
"render.studio_direction": "Richtung zur Sonne (X, Y, Z)",
|
||||
"render.studio_environment": "Umgebung",
|
||||
"render.studio_environment_bounds": "Die Umgebungsdrehung muss endlich und innerhalb von ±1.000.000 Radiant liegen, die Intensität zwischen 0 und 100.",
|
||||
@@ -4219,15 +4081,11 @@
|
||||
"render.use_preview": "Integrierte Vorschau verwenden",
|
||||
"render.volume_albedo": "Streualbedo · lineares RGB",
|
||||
"render.volume_anisotropy": "Mittlerer Phasenkosinus",
|
||||
"render.volume_denoise_required": "Für Atmosphäre die Rauschreduzierung ausschalten.",
|
||||
"render.volume_enable": "Teilnehmendes Medium aktivieren",
|
||||
"render.volume_extinction": "Extinktionskoeffizient",
|
||||
"render.volume_glass_required": "Atmosphäre und Glas können in dieser Einstellung nicht kombiniert werden.",
|
||||
"render.volume_help": "GPU-Transport innerhalb einer endlichen Box, mit Beer-Lambert-Abschwächung und HG-Streuung auf Kamera- und indirekten Pfaden. Verwendet ausdrückliche Studio-, HDR- und Himmelsbeleuchtung einschließlich Schattenabschwächung. Ein positiver Phasenkosinus streut nach vorn. Glasgrenzflächen und Oberflächenentrauschen müssen ausdrücklich deaktiviert werden. Extinktion null lässt den Transport unverändert. Keine physikalische Mondatmosphäre, kein Dichtegitter, keine Volumenemission, kein spektraler Transport und keine zertifizierte Materialeigenschaft.",
|
||||
"render.volume_lighting_required": "Für Atmosphäre eine Umgebung oder ein eigenes Lichtrig wählen.",
|
||||
"render.volume_max": "Obere Grenzen · Weltkoordinaten (mm)",
|
||||
"render.volume_min": "Untere Grenzen · Weltkoordinaten (mm)",
|
||||
"render.volume_single_stratum_required": "Für Atmosphäre ein Transmissionssample verwenden.",
|
||||
"render.volume_title": "Begrenztes homogenes Volumen",
|
||||
"render.wait_add_light": "Noch nicht unterstützt: benannte Lichtquellen erstellen",
|
||||
"render.wait_batch": "Noch nicht unterstützt: Renderwarteschlange gespeicherter Aufnahmen",
|
||||
@@ -4344,7 +4202,6 @@
|
||||
"sketch.dimension_applied": "Maß angewendet",
|
||||
"sketch.dimension_applied_defined": "Maß angewendet; Parameter `{name}` definiert",
|
||||
"sketch.dimension_applied_renamed": "Maß angewendet; der Parameter heißt jetzt `{name}`",
|
||||
"sketch.divide_hint": "Punkte teilen jede Kurve in gleich lange Abschnitte und behalten sie bei, wenn sie sich ändert.",
|
||||
"sketch.dof_left": "{count} Freiheitsgrade übrig",
|
||||
"sketch.dof_left_one": "{count} Freiheitsgrad übrig",
|
||||
"sketch.draw_curves": "Als {linetype} zeichnen",
|
||||
@@ -5201,7 +5058,6 @@
|
||||
"ui.heading_custom_view_navigator": "EIGENER ANSICHTSNAVIGATOR",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_divide_curve": "KURVE TEILEN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
"ui.heading_edit": "BEARBEITEN",
|
||||
"ui.heading_equation_curve": "GLEICHUNGSKURVE",
|
||||
@@ -5282,11 +5138,6 @@
|
||||
"ui.history_play_button_show": "Wiedergabe-Schaltfläche einblenden",
|
||||
"ui.history_play_finish_action": "Die aktuelle Aktion abschließen, bevor der Verlauf abgespielt wird",
|
||||
"ui.history_play_roll_back": "Die Verlaufsmarkierung vor dem Abspielen zurücksetzen",
|
||||
"ui.history_scope_all": "Features des ganzen Designs zeigen",
|
||||
"ui.history_scope_all_shown": "Die Features des ganzen Designs",
|
||||
"ui.history_scope_pick": "Eine Komponente bearbeiten oder im Browser wählen, um nur ihre Features zu zeigen",
|
||||
"ui.history_scope_show": "Nur die Features von {name} und der Komponenten darin zeigen",
|
||||
"ui.history_scope_shown": "Die Features von {name} und der Komponenten darin",
|
||||
"ui.history_show_names": "Namen anzeigen",
|
||||
"ui.history_sketch_listed_under": "Aufgeführt unter {feature}",
|
||||
"ui.history_title": "Verlauf",
|
||||
@@ -5517,7 +5368,6 @@
|
||||
"ui.point_cloud_no_file_colors": "Die Datei hat keine Farben",
|
||||
"ui.point_cloud_pause": "Pause",
|
||||
"ui.point_cloud_play": "Abspielen",
|
||||
"ui.point_cloud_reload": "Aus Datei neu laden",
|
||||
"ui.point_cloud_single_color": "Einheitliche Farbe",
|
||||
"ui.point_cloud_size": "Punktgröße",
|
||||
"ui.point_cloud_units": "Einheit der Datei",
|
||||
@@ -5612,7 +5462,6 @@
|
||||
"ui.second_rail": "Zweite Leitkurve",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.section_size": "Querschnittsgröße",
|
||||
"ui.segments": "Segmente",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selectOther": "Andere auswählen…",
|
||||
"ui.selectTangentChain": "Tangentialkette auswählen",
|
||||
|
||||
@@ -134,7 +134,6 @@
|
||||
"render.aging_metallic": "Layer metallic fraction",
|
||||
"render.aging_origin": "Field origin · world mm",
|
||||
"render.aging_patina": "Copper-green patina",
|
||||
"render.aging_raytrace_help": "Ageing is applied by CPU ray tracing and GPU path tracing. Raster keeps schematic rendering.",
|
||||
"render.aging_refresh_targets": "Inspect current bodies",
|
||||
"render.aging_roughness": "Layer roughness",
|
||||
"render.aging_rust": "Oxidized iron / rust",
|
||||
@@ -160,7 +159,6 @@
|
||||
"render.background_workflow": "Choose the background you intend: a camera-only colour/gradient, a physical stage that receives light, or HDR illumination. They are independent. Live Preview is a modelling view; render a new image or regrade a retained master to see camera finishing.",
|
||||
"render.base_color": "Base colour · sRGB",
|
||||
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
|
||||
"render.check_media_remaining": "Density grids, displacement, full spectral integration and propagated polarisation remain unavailable.",
|
||||
"render.chromatic_offset": "Chromatic edge offset",
|
||||
"render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.",
|
||||
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.",
|
||||
@@ -180,7 +178,6 @@
|
||||
"render.flare_strength": "Highlight flare strength",
|
||||
"render.flare_threshold": "Scene-linear flare threshold",
|
||||
"render.frame_centre_mark": "Centre mark",
|
||||
"render.glass_model_help": "Glass roughness scatters transmitted rays. Dispersion and film colour use three wavelength bands. Rough sheets require straight transmitted shadows off.",
|
||||
"render.hdr_primaries_declared": "The source declares its colour primaries.",
|
||||
"render.hdr_primaries_srgb": "Colour primaries: sRGB/D65",
|
||||
"render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.",
|
||||
|
||||
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "Flat size X",
|
||||
"Flat size Y": "Flat size Y",
|
||||
"Flipped": "Flipped",
|
||||
"Fold Features": "Fold Features",
|
||||
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
|
||||
"Follow inherited appearance": "Follow inherited appearance",
|
||||
"Follow material": "Follow material",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "Glass - Smoked",
|
||||
"Grid": "Grid",
|
||||
"Groove": "Groove",
|
||||
"Group Features": "Group Features",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "HDR file changed since selection; inspect and select it again before saving",
|
||||
"HISTORY": "HISTORY",
|
||||
"Halo under sketch curves": "Halo under sketch curves",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "Section position",
|
||||
"Sections": "Sections",
|
||||
"Sections (one sketch each)": "Sections (one sketch each)",
|
||||
"Select Features": "Select Features",
|
||||
"Select Other": "Select Other",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).",
|
||||
"Select Same Look": "Select Same Look",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "Select edges",
|
||||
"Select points of the sketch being edited": "Select points of the sketch being edited",
|
||||
"Select the centre the pattern turns around first": "Select the center the pattern turns around first",
|
||||
"Select the curves to divide first": "Select the curves to divide first",
|
||||
"Select the curves to offset first": "Select the curves to offset first",
|
||||
"Select the mirror line": "Select the mirror line",
|
||||
"Select the sketch curves or points to mirror first": "Select the sketch curves or points to mirror first",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "Sheet Metal",
|
||||
"Sheet Rules": "Sheet Rules",
|
||||
"Show Agent Cursor": "Show Agent Cursor",
|
||||
"Show Each Feature": "Show Each Feature",
|
||||
"Show ghost": "Show ghost",
|
||||
"Show in folder": "Show in folder",
|
||||
"Show the pointer an AI agent moves (#35)": "Show the pointer an AI agent moves (#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "Edit Point Cloud",
|
||||
"cmd.pointcloud_insert": "Insert Point Cloud",
|
||||
"cmd.pointcloud_list": "Point Clouds",
|
||||
"cmd.pointcloud_reload": "Reload Point Cloud",
|
||||
"cmd.render.coat.bind": "Save Clear Coat",
|
||||
"cmd.render.coat.unbind": "Remove Clear Coat",
|
||||
"cmd.render.compare.divider": "Move Render Comparison Divider",
|
||||
"cmd.render.result.discard": "Discard Render Result",
|
||||
"cmd.render.result.select": "Select Render Result",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "Sketch Palette Options",
|
||||
"cmd.sketch_paste": "Paste Sketch Entities",
|
||||
"cmd.sketch_point3d": "3D Point",
|
||||
"cmd.sketch_point_divide": "Divide Curve",
|
||||
"cmd.sketch_polygon_circumscribed": "Circumscribed Polygon",
|
||||
"cmd.sketch_polygon_edge": "Edge Polygon",
|
||||
"cmd.sketch_polygon_inscribed": "Inscribed Polygon",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "Render Browser",
|
||||
"cmd.view.render_dock": "Move Render Dock",
|
||||
"cmd.view.render_focus": "Render Focus View",
|
||||
"cmd.view.render_live": "Pause or Resume Live Render",
|
||||
"cmd.view.status_details": "Viewport Status Details",
|
||||
"cmd.view.status_items": "Viewport Status Items",
|
||||
"cmd.view_ambient_occlusion": "Ambient Occlusion",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "Reading the file... {percent}%",
|
||||
"file_load.rebuilding": "Rebuilding feature {done} of {total}",
|
||||
"file_load.recovering": "Recovering design",
|
||||
"file_load.reloading_cloud": "Reloading point cloud",
|
||||
"file_load.step_build": "Building the model",
|
||||
"file_load.step_feature": "Feature {done} of {total}",
|
||||
"file_load.step_part": "Part {done} of {total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "Copies sketch geometry around a centre point, over a full circle or a set angle. Use it for bolt circles, spokes and teeth.",
|
||||
"help.sketch.pattern.rectangular": "Copies sketch geometry in rows and columns at set spacings. Use it for grids of holes, slots or vents.",
|
||||
"help.sketch.point": "Places a sketch point. Use it as a reference for dimensions, constraints, hole centres or later features.",
|
||||
"help.sketch.point.divide": "Places points that divide the picked curves into equal lengths. The points stay on each curve and keep their spacing when it changes.",
|
||||
"help.sketch.point3d": "Places a point in space, with 3D Sketch on. Use it as a reference for 3D curves and paths.",
|
||||
"help.sketch.polygon.circumscribed": "Draws a regular polygon around a circle, sized from its centre to the middle of a side. Use it when the width across flats is known, as for a hex nut.",
|
||||
"help.sketch.polygon.edge": "Draws a regular polygon from the two ends of one side. Use it when the length and position of a side are known.",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt+left drag orbits, Alt+middle pans, Alt+right zooms; middle drag also pans",
|
||||
"nav.nx.help": "Middle drag orbits, Shift+middle pans (NX's middle + right), Ctrl+middle zooms (NX's middle + left); right drag leaves the camera",
|
||||
"nav.openscad.help": "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
|
||||
"nav.orbit_cursor_wrap.help": "An orbit drag that reaches the edge of the view moves the cursor to the opposite edge and keeps turning. Off: the drag stops at the edge of the screen. Has no effect on Wayland or in the browser.",
|
||||
"nav.orbit_cursor_wrap.label": "Wrap the cursor while orbiting",
|
||||
"nav.perspective.sketch": "Sketch editing uses orthographic view. Finish the sketch to switch perspective.",
|
||||
"nav.right_drag_orbits.help": "Off: a right drag leaves the camera alone and the right button only opens the context menu. Picking the SolidWorks mouse mapping turns it off.",
|
||||
"nav.right_drag_orbits.label": "Right drag orbits",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Hide constraint glyphs when zoomed out",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Constraint glyphs leave the view when the sketch is zoomed out far enough that they would clutter it. Off: they stay at a fixed screen size at every zoom.",
|
||||
"prefs.history": "History",
|
||||
"prefs.history_bar_groups": "Fold the History bar into groups",
|
||||
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
|
||||
"prefs.history_group_joints": "Group joints in the History",
|
||||
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
|
||||
"prefs.history_origin_bodies": "Show origin and bodies at the top",
|
||||
"prefs.history_origin_bodies_note": "The History on the left or under the browser starts with the active component's Bodies folder and the Origin, with their eyes and menus as in the browser. The History at the bottom is unchanged.",
|
||||
"prefs.history_play_button": "Play History button in the History controls",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "The selection's size in the view's bottom right corner: an edge's length, or Ø and R for a round one, a face's area, a point's position, two items' distance and angle, several edges' or faces' total. A click copies it.",
|
||||
"prefs.remember_dialog_values": "Remember command dialog values",
|
||||
"prefs.remember_dialog_values.help": "Off by default. When on, after a command succeeds, its safe values and options are reused the next time you open the same command, until SolidCraft closes.",
|
||||
"prefs.render_legacy_image_defaults": "Use previous Render image defaults",
|
||||
"prefs.render_legacy_image_defaults_help": "Keep the previous manual workflow: new setups use CPU raster, CPU ray tracing keeps fixed sampling, and you open rendered results manually. Refit on Render entry, switch shots without restoring their cameras, and use the previous framing and navigation. Disable automatic live image feedback. Saved setups are unchanged.",
|
||||
"prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget",
|
||||
"prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.",
|
||||
"prefs.reports_saved": "{n} saved",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "Work",
|
||||
"prefs.supersampling_brief": "Draws the view larger and filters it down: very crisp, and heavier.",
|
||||
"prefs.tab_accent_note": "The accent follows the active tab",
|
||||
"prefs.tab_double_click_renames": "Double-click a design tab to rename it",
|
||||
"prefs.tab_double_click_renames_note": "Type the new name, then press Enter or click elsewhere to keep it, or Esc to cancel. Off: a double-click on a tab maximizes the window, as on the empty part of the bar.",
|
||||
"prefs.theme_file": "theme file",
|
||||
"prefs.theme_file_or_paste": "file or paste",
|
||||
"prefs.theme_follow": "Follow a theme file",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "Couldn't join to {body}.",
|
||||
"refused.repair": "Repair part",
|
||||
"refused.show_face": "Show face",
|
||||
"render.adaptive.pilot_progress": "Adaptive pilots · {current}/{target} passes",
|
||||
"render.adaptive.preview_help": "Pilot images guide allocation and are excluded from the final image. Production iterations use a separate budget for each pixel; the iteration count is not uniform samples per pixel.",
|
||||
"render.adaptive.production_progress": "Adaptive production · {current}/{target} iterations",
|
||||
"render.adaptive_absolute": "Absolute RGB error floor",
|
||||
"render.adaptive_coverage": "Coverage error target",
|
||||
"render.adaptive_enable": "Allocate samples with independent pilots",
|
||||
"render.adaptive_help": "Independent pilot samples are discarded, then choose 16 or more production samples per pixel up to Samples per pixel. CPU resource limits include both stages. RGB and coverage variance guide allocation; capped pixels can remain noisy. The final report counts actual samples.",
|
||||
"render.adaptive_help": "GPU only. Independent pilot samples are discarded, then allocate 16 or more production samples per pixel up to Samples per pixel. RGB and coverage variance guide allocation; rare paths may be missed and capped pixels may remain noisy. The final report counts actual work. No certified noise or time guarantee.",
|
||||
"render.adaptive_minimum": "Minimum production samples",
|
||||
"render.adaptive_pending": "Independent-pilot adaptive allocation is available for CPU and GPU. Conditional Fresnel strata require GPU. Statistical allocation does not certify convergence.",
|
||||
"render.adaptive_pending": "GPU independent-pilot adaptive allocation and conditional Fresnel strata are available. Certified convergence, rare-event guarantees and CPU adaptive transport remain unavailable.",
|
||||
"render.adaptive_pilots": "Pilot samples per pixel",
|
||||
"render.adaptive_relative": "Relative RGB error target",
|
||||
"render.add_ground": "Add fitted ground to setup",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "Layer metallic fraction",
|
||||
"render.aging_origin": "Field origin · world mm",
|
||||
"render.aging_patina": "Copper-green patina",
|
||||
"render.aging_raytrace_help": "Aging is applied by CPU ray tracing and GPU path tracing. Raster keeps schematic rendering.",
|
||||
"render.aging_refresh_targets": "Inspect current bodies",
|
||||
"render.aging_roughness": "Layer roughness",
|
||||
"render.aging_rust": "Oxidized iron / rust",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "Closed solid",
|
||||
"render.appearance_editor.coat": "Clear coat",
|
||||
"render.appearance_editor.cpu_reason": "Needs CPU ray tracing.",
|
||||
"render.appearance_editor.discard_saved_edit": "Discard unsaved optical edits",
|
||||
"render.appearance_editor.glass": "Glass and absorption",
|
||||
"render.appearance_editor.glass_enable": "Use physical glass on this body",
|
||||
"render.appearance_editor.glass_note": "Closed glass uses geometric distance. It cannot share a body with clear coat or maps.",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "Replace this body's saved optics",
|
||||
"render.appearance_editor.revalidate": "Revalidate this body",
|
||||
"render.appearance_editor.save": "Save these optics",
|
||||
"render.appearance_editor.save_reason": "To save Glass, enable it on a current CPU or GPU body. Use Save coat to shot for clear coat. Local image files remain temporary.",
|
||||
"render.appearance_editor.save_reason": "Saving these optics needs physical glass on a current GPU body. Coat and local image maps remain temporary.",
|
||||
"render.appearance_editor.saved": "Saved with this shot",
|
||||
"render.appearance_editor.saved_state": "These optics are saved with this shot.",
|
||||
"render.appearance_editor.scatter_reason": "Interior scattering needs a closed solid boundary.",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "Metallic fraction",
|
||||
"render.base_opaque_only": "This editor preserves opaque body semantics. Use the explicit glass controls for optical transmission.",
|
||||
"render.batch_all_shots": "All shots ({count})",
|
||||
"render.batch_current_settings_help": "Uses the current image settings with each saved shot camera, one shot at a time.",
|
||||
"render.batch_empty": "Save a shot before rendering all shots.",
|
||||
"render.batch_limit": "Render all shots is limited to 32 shots.",
|
||||
"render.batch_owner_limit": "At most 32 document shot queues can be retained. Finish or cancel an existing queue first.",
|
||||
"render.batch_preparing": "Preparing next shot",
|
||||
"render.batch_status": "{running} running · {waiting} waiting",
|
||||
"render.batch_waiting_worker": "Waiting for render preparation",
|
||||
"render.bloom_enable": "Display bloom",
|
||||
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalized box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
|
||||
"render.bloom_opaque": "Bloom requires opaque output. Transparent PNG is refused, not silently flattened.",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "Paint",
|
||||
"render.category_plastic": "Plastic",
|
||||
"render.category_wood": "Wood",
|
||||
"render.caustics_depth": "Caustic path depth",
|
||||
"render.caustics_enable": "Glass caustics from point and directional lights",
|
||||
"render.caustics_help": "Photon count controls noise and work. Gather radius trades sharp detail for a smoother estimate. CPU requires Indirect lighting.",
|
||||
"render.caustics_path_required": "CPU caustics require Indirect lighting.",
|
||||
"render.caustics_photons": "Photons",
|
||||
"render.caustics_radius": "Gather radius",
|
||||
"render.check_glass_cpu": "CPU supports certified planar Glass sheets and axis-aligned boxes. General closed meshes, nesting and interior scattering require Indirect lighting. Closed transmitted shadows remain unsupported.",
|
||||
"render.check_glass_gpu": "GPU supports certified disjoint Glass or explicit certified nesting. Nesting requires convex parents, strictly contained children and no touching boundaries.",
|
||||
"render.check_media_remaining": "Density grids, displacement, full spectral integration and propagated polarization remain unavailable.",
|
||||
"render.check_media_scope": "Active interior scattering needs a closed Glass medium, straight shadow transmission off and Denoise off. GPU also needs one Fresnel stratum. Active exterior Atmosphere cannot combine with Glass or surface Denoise.",
|
||||
"render.check_opaque": "Transparency: opaque surfaces only; background alpha is supported.",
|
||||
"render.check_sorted_alpha": "Transparency: sorted alpha approximation.",
|
||||
"render.check_transmission": "Transparency: physical transmission.",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "Copy request JSON",
|
||||
"render.children.coverage_error": "Coverage error",
|
||||
"render.children.cpu": "CPU ray tracing",
|
||||
"render.children.denoise": "Reduce image noise",
|
||||
"render.children.denoise": "Reduce noise in the finished image",
|
||||
"render.children.depth": "Path depth",
|
||||
"render.children.distortion": "Distortion",
|
||||
"render.children.edge_samples": "Edge samples",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "Choose a look in Appearances first",
|
||||
"render.chromatic_offset": "Chromatic edge offset",
|
||||
"render.clear_stage": "Remove saved stage",
|
||||
"render.coat_discard": "Discard coat edit",
|
||||
"render.coat_pending_edit": "This coat edit is unsaved. Save it with the shot or discard it.",
|
||||
"render.coat_remove": "Remove saved coat",
|
||||
"render.coat_remove_note": "Removes this target’s coat from the shot. Geometry, appearances and physical materials stay unchanged.",
|
||||
"render.coat_remove_title": "Remove saved clear coat?",
|
||||
"render.coat_revalidate_saved": "The saved coat changed. Discard this edit and reopen the target before editing it again.",
|
||||
"render.coat_save": "Save coat to shot",
|
||||
"render.coat_saved_help": "Clear coat adds a glossy layer to opaque surfaces. Save it with the shot. Glass cannot share this target. CPU custom reflection and anisotropy overrides cannot share it either.",
|
||||
"render.compare_divider": "Comparison divider",
|
||||
"render.compare_live": "Live",
|
||||
"render.compare_live_preview": "Live preview",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "Triangle ceiling",
|
||||
"render.cpu_budget_workstation": "Workstation (heavy scenes)",
|
||||
"render.cpu_captured_hdr": "HDR captured for this image",
|
||||
"render.cpu_celestial_adopt": "CPU ray tracing with indirect lighting · path depth {depth} · {spp} SPP · traced shadows · seed {seed} · 1 primary sample",
|
||||
"render.cpu_celestial_adopt_label": "CPU ray tracing with indirect lighting",
|
||||
"render.cpu_celestial_path_required": "Finite Sun and gradient Sky use CPU indirect lighting.",
|
||||
"render.cpu_celestial_switch": "Choose CPU ray tracing with indirect lighting to keep this Sun or Sky.",
|
||||
"render.cpu_celestial_unportable": "Keep GPU path tracing for the current transmission sample settings.",
|
||||
"render.cpu_check_capture_help": "Check and Start verify the same file digest or use the embedded HDR. Each image keeps its captured source.",
|
||||
"render.cpu_check_changed": "The controls or model changed after this check. Check again before rendering.",
|
||||
"render.cpu_constant_direct_help": "Without Indirect lighting, this constant source can appear in reflections and through Glass. It does not light opaque surfaces. Enable Indirect lighting to use it for scene lighting.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Path depth {depth}; progressive sampling at {spp} samples/pixel (seed {seed}); one primary ray per sample; traced shadows. Keeps your image size, CPU budget and other settings.",
|
||||
"render.cpu_constant_help": "Constant radiance is visible to reflected or transmitted rays. It does not provide direct diffuse lighting.",
|
||||
"render.cpu_custom_directions": "Replace preset with custom directional lights",
|
||||
"render.cpu_denoise_help": "Reduces noise in every image update. It may soften fine detail and depth-of-field edges. Raw EXR is unchanged. Adjusting a completed image uses its raw image and does not apply noise reduction again.",
|
||||
"render.cpu_direction": "Direction toward light (X, Y, Z)",
|
||||
"render.cpu_directional": "Directional light",
|
||||
"render.cpu_directional_add": "Add directional light",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "HDR lighting samples",
|
||||
"render.cpu_exposure_help": "CPU exposure must be between −16 and 16 EV. A GPU exposure outside that range is retained until you change it.",
|
||||
"render.cpu_fixed": "Single image",
|
||||
"render.cpu_fixed_retained": "Fixed sampling retained",
|
||||
"render.cpu_glass_enable_path": "Enable Indirect lighting",
|
||||
"render.cpu_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024 and 16 MiB. CPU requires explicit sRGB/D65 primaries in the file; unspecified primaries and EXR are refused.",
|
||||
"render.cpu_hdr_reflection_help": "HDR rough reflections require fully metallic body and face surfaces. Check reports unsupported combinations.",
|
||||
"render.cpu_indirect_method_required": "Choose CPU ray tracing to enable this Indirect lighting mode.",
|
||||
"render.cpu_key_rim": "Key and rim",
|
||||
"render.cpu_last_check": "Last CPU check",
|
||||
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colors are linear RGB and intensity is relative, not watts.",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "Lighting preset",
|
||||
"render.cpu_lights": "CPU lights",
|
||||
"render.cpu_linear_color": "Linear color (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "CPU renders can use saved opaque maps: choose a surface here, then Save maps to shot. With Indirect lighting, Glass can be on another body; a mapped body cannot use Glass. Selected reflections, anisotropy and procedural surface effects must be off. Temporary map file paths use GPU.",
|
||||
"render.cpu_material_certification": "CPU Check validates supported Glass geometry before rendering. Closed Glass uses geometric distance.",
|
||||
"render.cpu_neutral": "Neutral",
|
||||
"render.cpu_opaque_hint": "CPU ray tracing uses an opaque background.",
|
||||
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
|
||||
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
|
||||
"render.cpu_path_depth": "Path depth",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_lighting": "Indirect lighting",
|
||||
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_point": "Point light",
|
||||
"render.cpu_point_add": "Add point light",
|
||||
"render.cpu_point_help": "Up to eight point lights, added to the selected directional lighting. Distances are in mm; the minimum must not exceed the reference distance.",
|
||||
"render.cpu_primary_samples": "Edge samples per pixel",
|
||||
"render.cpu_progressive": "CPU progressive",
|
||||
"render.cpu_progressive_adopt": "Use progressive sampling",
|
||||
"render.cpu_progressive_adopt_help": "Adopting progressive uses one camera sample per pass. Image size, lights and reflection settings stay unchanged.",
|
||||
"render.cpu_progressive_always": "Progressive sampling is always on",
|
||||
"render.cpu_progressive_help": "Accumulate fresh CPU camera samples and show every complete pass. Selected reflections use one attempt per camera sample; certified glass and planar anisotropy keep their existing limits. Adaptive pilots and production share the CPU profile budget.",
|
||||
"render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; area lights, dielectrics and anisotropy require Single image.",
|
||||
"render.cpu_progressive_limits": "At most 1,048,576 pixels and 16,777,216 pixel × sample evaluations. Changing samples never resizes the image automatically.",
|
||||
"render.cpu_progressive_reflection_adopt": "Set selected reflection attempts to 1 before adopting progressive sampling.",
|
||||
"render.cpu_progressive_seed": "CPU sampling seed",
|
||||
"render.cpu_progressive_spp": "samples per pixel",
|
||||
"render.cpu_quality_budget_help": "Changes size, production sample cap, seed and CPU resource profile. Camera, lens, lighting, GI depth and effects are kept.",
|
||||
"render.cpu_quality_progressive_required": "Enable progressive sampling below before choosing a CPU budget.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · {samples} sample cap · seed {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "Add rough reflection",
|
||||
"render.cpu_reflection_depth": "Reflection depth (1–8)",
|
||||
"render.cpu_reflection_empty": "No bodies selected: scene reflections are off.",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "Numeric light and environment settings are saved with the setup. Body reflections and HDR files remain temporary. Existing area-light and spot-cone values are retained.",
|
||||
"render.cpu_sampling_mode": "CPU sampling mode",
|
||||
"render.cpu_shadows": "Cast shadows",
|
||||
"render.cpu_sky_bounds": "Sky rotation must be finite within ±1,000,000 radians; intensity must be 0–100.",
|
||||
"render.cpu_studio_enable": "Enable indirect lighting for this rig",
|
||||
"render.cpu_studio_enable_apply": "Enable indirect lighting and apply",
|
||||
"render.cpu_studio_indirect_help": "This Sun rig needs indirect lighting at depth 8, traced shadows and one camera sample per pass. If progressive rendering is off, it starts at 16 samples per pixel with seed 0. Existing progressive settings and the resource profile are kept. Incompatible settings stop the apply without changing them.",
|
||||
"render.cpu_studio_radiance_limit": "This rig has area radiance {radiance}, above the CPU limit of 64. Its authored lighting is kept; choose another rig or GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU subsurface scattering needs Denoise off. Your settings are kept.",
|
||||
"render.cpu_subsurface_help": "Scattering redirects light inside closed Glass. Positive rates need Indirect lighting with Denoise off. The rate is collisions per mm; 0 adds no scattering. Positive phase bias favors forward directions; negative favors backward.",
|
||||
"render.cpu_summary": "CPU ray tracing · reflections · HDR · directional and point lights",
|
||||
"render.cpu_transparent_help": "Transparent PNG removes the background and keeps edge coverage. Body and face opacity must stay at 100%.",
|
||||
"render.cpu_volume_enable_path": "Enable Indirect lighting",
|
||||
"render.cpu_volume_enable_path_plan": "CPU ray tracing · path depth {depth} · {spp} samples/pixel · seed {seed} · traced shadows. Keeps the authored atmosphere and image budget.",
|
||||
"render.cpu_volume_help": "CPU Atmosphere uses Indirect lighting. Zero extinction keeps the saved box without fog.",
|
||||
"render.cpu_volume_method_adopt": "CPU ray tracing with indirect lighting",
|
||||
"render.cpu_volume_method_incompatible": "These light, sampling or atmosphere settings cannot be transferred to this method. Your settings are kept.",
|
||||
"render.cpu_volume_path_required": "Enable Indirect lighting to use CPU Atmosphere.",
|
||||
"render.cpu_warm_cool": "Warm and cool",
|
||||
"render.diag_bodies": "Visible bodies",
|
||||
"render.diag_job_details": "Captured job details",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "Preflight details",
|
||||
"render.diag_request": "Exact current request",
|
||||
"render.diag_triangles": "Triangles",
|
||||
"render.directional_sun_limit": "GPU lighting supports eight directional sources in total, including at most one soft Sun. Duplicate a hard directional light to add another distant source.",
|
||||
"render.discard_continue": "Discard draft and continue",
|
||||
"render.draft_confirm_help": "The current render draft has unapplied edits. Save them before switching, discard them explicitly, or keep editing.",
|
||||
"render.draft_confirm_title": "Preserve edited render settings",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "Export: display PNG",
|
||||
"render.export_image_label": "Export Image",
|
||||
"render.export_linear": "Export: raw linear EXR",
|
||||
"render.exr_pending": "CPU ray tracing and GPU path tracing can retain raw linear EXR. Compression, multilayer images and auxiliary render passes remain unavailable.",
|
||||
"render.exr_pending": "GPU can retain raw linear EXR. Compression, multilayer images and auxiliary render passes remain unavailable.",
|
||||
"render.film_grain": "Photographic grain",
|
||||
"render.fit": "Fit Product",
|
||||
"render.fixed_camera": "Camera",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · Vertical",
|
||||
"render.frame_thirds": "Thirds",
|
||||
"render.frame_wide": "16:9 · Widescreen",
|
||||
"render.glass_abbe": "Abbe number",
|
||||
"render.glass_dispersion": "Chromatic dispersion",
|
||||
"render.glass_film": "Thin-film interference",
|
||||
"render.glass_film_ior": "Film IOR",
|
||||
"render.glass_film_thickness": "Film thickness",
|
||||
"render.glass_model_enable": "Advanced Glass model",
|
||||
"render.glass_model_help": "Glass roughness scatters transmitted rays. Dispersion and film color use three wavelength bands. Rough sheets require straight transmitted shadows off.",
|
||||
"render.glass_roughness": "Glass roughness",
|
||||
"render.gpu_depth": "Maximum path depth",
|
||||
"render.gpu_options_help": "Samples, depth, seed and exposure are saved with this setup. Only the active image method is saved.",
|
||||
"render.gpu_path_crashed": "SolidCraft closed while setting it up on this graphics adapter last time, most likely inside the graphics driver, so it is not set up again by itself. Choose CPU ray tracing instead, or try again (a driver update also tries again).",
|
||||
"render.gpu_path_failed": "GPU path tracer unavailable",
|
||||
"render.gpu_path_first_setup": "The first setup on this GPU can take several minutes; SolidCraft stays usable meanwhile. Later starts reuse the compiled shaders where the driver keeps them.",
|
||||
"render.gpu_path_not_started": "GPU path tracer setup has not started.",
|
||||
"render.gpu_path_preparing": "GPU path tracer: compiling shaders…",
|
||||
"render.gpu_path_retry": "Try again",
|
||||
"render.gpu_seed": "Random seed",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "Product center",
|
||||
"render.light_size": "Size",
|
||||
"render.light_softness": "Softness",
|
||||
"render.light_space_world": "World-space lights",
|
||||
"render.light_space_world_help": "Light positions and directions use world coordinates. Orbiting the camera does not move them.",
|
||||
"render.light_temperature": "Color temperature",
|
||||
"render.light_temperature_help": "Approximate blackbody tint; saved as linear RGB.",
|
||||
"render.light_type": "Type",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "Keeps a 32-bit float RGBA master before denoising, exposure, tone mapping, filters and backdrop. Linear sRGB/D65; coverage-premultiplied. Additional memory is admitted explicitly. No compression or extra passes.",
|
||||
"render.linear_retain": "Retain raw linear EXR",
|
||||
"render.linear_unavailable": "The selected completed frame has no retained EXR. Enable retention before rendering; an older PNG cannot recover raw radiance.",
|
||||
"render.live.adaptive": "Live · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "The render request changed while live rendering was stopping. Start the final image again.",
|
||||
"render.live.final_waiting": "Preparing final image · releasing live-render resources",
|
||||
"render.live.invalid_size": "Live rendering needs a positive output width and height.",
|
||||
"render.live.pause": "Pause live",
|
||||
"render.live.paused": "Live · Paused",
|
||||
"render.live.paused_outdated": "Live · Paused · Out of date",
|
||||
"render.live.quality_help": "Interactive quality: at most 512 pixels on the longest edge. Uses the selected renderer and its supported effects. Final resolution, settings and export remain separate.",
|
||||
"render.live.refused": "Live unavailable: {reason}",
|
||||
"render.live.resume": "Resume live",
|
||||
"render.live.samples": "Live · {width} × {height} · {current}/{target} samples",
|
||||
"render.live.source_current": "{method} · Live",
|
||||
"render.live.source_outdated": "{method} · Previous image",
|
||||
"render.live.source_outdated_help": "Shows the last captured image while live rendering updates or is unavailable. It may differ from the current scene or image settings.",
|
||||
"render.live.source_settings_help": "Open Output to edit the renderer and image settings.",
|
||||
"render.live.stopped": "Live rendering stopped",
|
||||
"render.live.updating": "Live · Interactive quality (up to 512 px) · Updating",
|
||||
"render.live.waiting_canvas": "Waiting for the Render canvas",
|
||||
"render.live.worker_stopped": "The live-render capture worker stopped before returning a result",
|
||||
"render.look_add": "Add color look",
|
||||
"render.look_applied": "Applied {name}",
|
||||
"render.look_bleach": "Bleach-bypass-inspired",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "Illustrative dielectric coating over the existing opaque appearance. Zero weight leaves the base unchanged.",
|
||||
"render.material_color_map": "Base color image (sRGB)",
|
||||
"render.material_color_replace": "Base map replaces body color",
|
||||
"render.material_cpu_glass_only": "Temporary image-file maps require GPU. CPU can use saved built-in surface maps.",
|
||||
"render.material_cpu_optics_limits": "Turn on Indirect lighting for supported closed Glass shapes, certified nesting and subsurface scattering. Thin sheets can pass shadows. Otherwise use a planar sheet or axis-aligned box. Touching shapes and transmitted shadows through closed Glass remain unsupported. Your values are kept.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Saved CPU optics cannot combine with legacy temporary optical bindings. Save the reviewed optics or remove the conflicting temporary binding.",
|
||||
"render.material_custom": "Custom illustrative optics",
|
||||
"render.material_enable": "Use these GPU material overrides",
|
||||
"render.material_filter": "Filter scene bodies…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "Normal image (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "Normal strength (0–1)",
|
||||
"render.material_origin": "Mapping origin X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "Physical Glass requires CPU ray tracing or GPU path tracing.",
|
||||
"render.material_refresh": "Refresh scene bodies",
|
||||
"render.material_refresh_needed": "Scene changed. Refresh the body list before adding a target.",
|
||||
"render.material_remove": "Remove this assignment",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "This assignment belongs to an earlier scene revision. Revalidate its exact body identity before rendering.",
|
||||
"render.material_roughness": "Coat roughness (0.045–1)",
|
||||
"render.material_roughness_map": "Roughness map (linear)",
|
||||
"render.material_saved_edit_pending": "These optical edits are unsaved. Save reviewed values, remove the saved assignment, or discard the edits. Legacy temporary bindings and unsupported effects cannot override the saved target.",
|
||||
"render.material_shadow": "Transmit straight shadow rays",
|
||||
"render.material_target_missing": "The body ordinal and name no longer match. Remove this assignment and explicitly choose the intended body again.",
|
||||
"render.material_thickness": "Thin-sheet thickness (mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "CPU ray tracing",
|
||||
"render.method_gpu": "GPU path tracing",
|
||||
"render.method_limit_raster": "Raster uses the fixed opaque studio preview. Select an explicit raytracing method for supported optical controls.",
|
||||
"render.method_raster": "CPU raster (schematic)",
|
||||
"render.method_raster": "CPU raster",
|
||||
"render.navigation_help": "The camera moves as in Design (Preferences › Navigation). Save Setup records the current camera.",
|
||||
"render.nested_enable": "Certified nested dielectrics",
|
||||
"render.nested_help": "Up to 16 containing layers; every parent must be convex and every child strictly inside it. Intersections, touching surfaces, thin sheets and straight-shadow transmission refuse. Camera-inside state follows the certified hierarchy. Does not create a liquid surface or merge touching CAD boundaries.",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "EXR output setup is unavailable here. Linear EXR export requires a completed GPU image with a retained linear master.",
|
||||
"render.output_fit": "Fit",
|
||||
"render.output_format": "Format",
|
||||
"render.output_format_status": "PNG is the exact completed display image. Optional CPU/GPU EXR retains raw scene-linear FLOAT32 radiance before display filters. Export never substitutes the preview cache or a different job.",
|
||||
"render.output_format_status": "PNG is the exact completed display image. Optional GPU EXR retains raw scene-linear FLOAT32 radiance before display filters. Export never substitutes the preview cache or a different job.",
|
||||
"render.output_grading": "Grading",
|
||||
"render.output_image": "Image",
|
||||
"render.output_largest": "{width} × {height} max",
|
||||
"render.output_lock_ratio": "Lock aspect ratio",
|
||||
"render.output_master_unavailable": "Select a completed image with raw EXR data to export EXR. Enable raw EXR before rendering new images.",
|
||||
"render.output_over_budget": "The requested image exceeds the selected resource envelope. Choose a compatible size or profile explicitly; no automatic downgrade occurs.",
|
||||
"render.output_presentation_limits": "CPU image effects and EXR output support images up to 8192 pixels wide or high, with at most 16,777,216 pixels in total.",
|
||||
"render.output_presets_help": "Format, quality and the Check list are in the Output editor.",
|
||||
"render.output_quality": "Quality",
|
||||
"render.output_quality_waiting": "waiting for the quality contract",
|
||||
"render.output_ratio_refused": "The linked dimension exceeds this method’s size limit. The previous size is retained; unlock the ratio or choose a smaller value.",
|
||||
"render.output_raytrace_only": "Image effects require CPU ray tracing or GPU path tracing.",
|
||||
"render.output_ready": "Ready to render",
|
||||
"render.output_run_check": "Run check",
|
||||
"render.output_size_limit": "{method}: up to {dimension} px per side and {pixels} pixels. Largest fitting size: {width} × {height}.",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "Saturation",
|
||||
"render.post_title": "Presentation filters",
|
||||
"render.post_vignette": "Vignette",
|
||||
"render.preparation_cancelled": "Render preparation cancelled",
|
||||
"render.preparation_checking": "Checking render",
|
||||
"render.preparation_retirement_failed": "Render preparation cleanup failed. This window cannot prepare another image.",
|
||||
"render.preparation_retiring": "Finishing render preparation",
|
||||
"render.preparation_unavailable": "Render preparation unavailable",
|
||||
"render.presentation_banner": "Presentation effects work with CPU and GPU ray tracing. Opening this page never changes the method.",
|
||||
"render.preview": "Integrated preview",
|
||||
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"render.preview_image_settings_help": "Live Preview keeps fixed studio lighting. Exposure and image lighting apply to the image job when you Check or Start.",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "Acknowledge the fixed-budget projection and omissions",
|
||||
"render.quality_apply": "Use this fixed sampling budget",
|
||||
"render.quality_balanced": "Balanced",
|
||||
"render.quality_budget_help": "Sets size, samples, depth, seed and noise reduction. Other image settings are kept.",
|
||||
"render.quality_custom": "Custom",
|
||||
"render.quality_denoise_off": "Noise reduction: Off",
|
||||
"render.quality_denoise_on": "Noise reduction: On",
|
||||
"render.quality_draft": "Draft",
|
||||
"render.quality_high": "High",
|
||||
"render.quality_projection_help": "This review changes only resolution, sample budget, path depth, seed and noise reduction. Other settings are kept; the budget does not certify convergence.",
|
||||
"render.quality_projection_help": "Q presets include future requirements. This action projects only a supported fixed-sample PNG budget and explicitly omits adaptive stopping, raw linear output and independent transmission limits.",
|
||||
"render.quality_review": "Review sampling budget",
|
||||
"render.quality_summary": "{width} × {height} · {samples} samples · depth {depth} · seed {seed} · {denoise}",
|
||||
"render.queue_pending": "There is no automatic production queue or animation renderer in this UI. Completed still images remain separately exportable.",
|
||||
"render.radial_assign": "Assign Appearance",
|
||||
"render.radial_compare": "Compare",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "Pan and zoom use Navigation preferences. Space + drag pans.",
|
||||
"render.result_open": "Open the completed image in the main canvas.",
|
||||
"render.result_preview": "Live Preview",
|
||||
"render.result_provisional": "Provisional image",
|
||||
"render.result_provisional": "Provisional CPU image",
|
||||
"render.result_provisional_help": "This checkpoint is not exportable. Export keeps the last completed result; cancellation returns to that result.",
|
||||
"render.result_reduced_display": "Display preview reduced to the viewport device/cache limit. The retained PNG and export keep full resolution; native 100% inspection is unavailable for this reduced view.",
|
||||
"render.result_retained": "Showing the last completed image; a newer render has not replaced it.",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "Frame subject",
|
||||
"render.shot_invalid_camera": "Camera values must be finite and within range.",
|
||||
"render.shot_lens": "Lens",
|
||||
"render.shot_lens_cpu_progressive_reason": "Adopt progressive sampling in Sampling before enabling CPU depth of field.",
|
||||
"render.shot_lens_enable_reason": "Enable depth of field to edit aperture and focus distance.",
|
||||
"render.shot_lens_gpu_reason": "Depth of field needs GPU path tracing.",
|
||||
"render.shot_lens_perspective_reason": "Depth of field needs perspective projection.",
|
||||
"render.shot_lens_raytrace_reason": "Depth of field is available for CPU ray tracing and GPU path tracing.",
|
||||
"render.shot_look_selection": "Look at selection",
|
||||
"render.shot_manager": "Saved shots",
|
||||
"render.shot_new_name": "New shot name",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "Horizon · linear RGB",
|
||||
"render.sky_zenith": "Zenith · linear RGB",
|
||||
"render.soft_focus": "Soft focus",
|
||||
"render.spot_axis": "Emission direction",
|
||||
"render.spot_cone": "Spot cone",
|
||||
"render.spot_gpu_help": "Angles are measured from the emission direction. Light fades between the inner and outer angles. Very narrow fade ranges require CPU rendering.",
|
||||
"render.spot_help": "Angles are measured from the emission direction. Light fades between the inner and outer angles.",
|
||||
"render.spot_inner": "Inner half-angle",
|
||||
"render.spot_outer": "Outer half-angle",
|
||||
"render.stage_frame": "Frame",
|
||||
"render.stage_layout.appearance_note": "Click to apply to the selection.",
|
||||
"render.stage_layout.studio_note": "Click to review before applying.",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "Prepare creation plan",
|
||||
"render.stage_panels.projects": "Projects",
|
||||
"render.stage_panels.quality_ack": "I acknowledge the fixed budget and its omissions",
|
||||
"render.stage_panels.quality_omissions": "This budget does not set adaptive error targets, independent transmission limits, Russian roulette rules or raw-master retention, and does not guarantee convergence.",
|
||||
"render.stage_panels.quality_omissions": "Adaptive stopping and error targets, separate transmission depth, Russian roulette rules, raw linear output, EXR master and convergence certification.",
|
||||
"render.stage_panels.reference": "Reference",
|
||||
"render.stage_panels.references": "References",
|
||||
"render.stage_panels.remove_optics": "Remove optics",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Original HDR environment · whole scene",
|
||||
"render.stage_panels.subtitle_maps": "Authored spatial maps · temporary preview",
|
||||
"render.stage_panels.subtitle_physical": "Read-only plan · no material created or assigned",
|
||||
"render.stage_panels.subtitle_quality": "GPU sampling budget · listed fields only",
|
||||
"render.stage_panels.subtitle_quality": "Fixed GPU budget · future requirements omitted",
|
||||
"render.stage_panels.subtitle_rig": "Authored light rig · emitters and passive cards",
|
||||
"render.stage_panels.subtitle_saved_maps": "Authored maps · saved with this shot",
|
||||
"render.stage_panels.subtitle_stage": "Physical ground and stage · fitted to the product",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimeters.",
|
||||
"render.studio_center": "Center X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Reduce noise in the finished image",
|
||||
"render.studio_denoise_help": "Progressive and final images use an edge-aware filter; fine detail may soften. Glass and sharp specular surfaces bypass it.",
|
||||
"render.studio_denoise_help": "Final edge-aware filter; fine detail can soften. Glass and sharp specular surfaces bypass the filter.",
|
||||
"render.studio_direction": "Direction toward sun (X, Y, Z)",
|
||||
"render.studio_environment": "Environment",
|
||||
"render.studio_environment_bounds": "Environment rotation must be finite within ±1,000,000 radians and intensity between 0 and 100.",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "Use Integrated Preview",
|
||||
"render.volume_albedo": "Scattering albedo · linear RGB",
|
||||
"render.volume_anisotropy": "Phase mean cosine",
|
||||
"render.volume_denoise_required": "Turn off noise reduction to use Atmosphere.",
|
||||
"render.volume_enable": "Enable participating medium",
|
||||
"render.volume_extinction": "Extinction coefficient",
|
||||
"render.volume_glass_required": "Atmosphere cannot be combined with Glass in this setup.",
|
||||
"render.volume_help": "GPU transport inside one finite box, with Beer attenuation and HG scattering on camera and indirect paths. Uses explicit studio/HDR/sky lighting, including shadow attenuation. Positive phase cosine scatters forward. Glass boundaries and surface denoising must be disabled explicitly. Zero extinction leaves transport unchanged. Not a physical lunar atmosphere, density grid, volume emission, spectral transport or certified material property.",
|
||||
"render.volume_lighting_required": "Choose an environment or an explicit light rig to render Atmosphere.",
|
||||
"render.volume_max": "Maximum bounds · world mm",
|
||||
"render.volume_min": "Minimum bounds · world mm",
|
||||
"render.volume_single_stratum_required": "Use one transmission sample to render Atmosphere.",
|
||||
"render.volume_title": "Bounded homogeneous volume",
|
||||
"render.wait_add_light": "not supported yet: named light creation",
|
||||
"render.wait_batch": "not supported yet: the saved-shot render queue",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "Dimension applied",
|
||||
"sketch.dimension_applied_defined": "Dimension applied; parameter `{name}` defined",
|
||||
"sketch.dimension_applied_renamed": "Dimension applied; parameter named `{name}`",
|
||||
"sketch.divide_hint": "Points divide each curve into equal lengths and keep them as it changes.",
|
||||
"sketch.dof_left": "{count} degrees of freedom left",
|
||||
"sketch.dof_left_one": "{count} degree of freedom left",
|
||||
"sketch.draw_curves": "Draw as {linetype}",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "Fine",
|
||||
"ui.finest": "Finest",
|
||||
"ui.finish_sketch": "Finish Sketch",
|
||||
"ui.first_dimension_scales": "The first dimension scales the drawing",
|
||||
"ui.first_dimension_scales_tip": "On: a sketch's first length dimension scales the drawing it measures, so its shape stays the same. Off: the dimension sizes only what it measures.",
|
||||
"ui.fit": "Fit",
|
||||
"ui.fix_unfix": "Fix/Unfix",
|
||||
"ui.flat": "Flat",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "CUSTOM VIEW NAVIGATOR",
|
||||
"ui.heading_cylinder": "CYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_divide_curve": "DIVIDE CURVE",
|
||||
"ui.heading_draft": "DRAFT",
|
||||
"ui.heading_edit": "EDIT",
|
||||
"ui.heading_equation_curve": "EQUATION CURVE",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "Show Play Button",
|
||||
"ui.history_play_finish_action": "Finish the current action before playing History",
|
||||
"ui.history_play_roll_back": "Move the History marker back before playing",
|
||||
"ui.history_scope_all": "Show the whole design's features",
|
||||
"ui.history_scope_all_shown": "The whole design's features",
|
||||
"ui.history_scope_pick": "Edit a component, or pick one in the Browser, to show only its features",
|
||||
"ui.history_scope_show": "Show only the features of {name} and the components inside it",
|
||||
"ui.history_scope_shown": "The features of {name} and the components inside it",
|
||||
"ui.history_show_hidden": "Show Hidden Components",
|
||||
"ui.history_show_names": "Show names",
|
||||
"ui.history_sketch_listed_under": "Listed under {feature}",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "The file has no colors",
|
||||
"ui.point_cloud_pause": "Pause",
|
||||
"ui.point_cloud_play": "Play",
|
||||
"ui.point_cloud_reload": "Reload from file",
|
||||
"ui.point_cloud_single_color": "Single Color",
|
||||
"ui.point_cloud_size": "Point Size",
|
||||
"ui.point_cloud_units": "File Units",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "Second Rail",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
"ui.section_size": "Section size",
|
||||
"ui.segments": "Segments",
|
||||
"ui.select": "Select",
|
||||
"ui.selectOther": "Select Other…",
|
||||
"ui.selectTangentChain": "Select Tangent Chain",
|
||||
|
||||
@@ -568,7 +568,6 @@
|
||||
"Select edges": "Selecciona aristas",
|
||||
"Select points of the sketch being edited": "Selecciona puntos del boceto que se está editando",
|
||||
"Select the centre the pattern turns around first": "Selecciona primero el centro alrededor del cual gira el patrón",
|
||||
"Select the curves to divide first": "Seleccione primero las curvas que desea dividir",
|
||||
"Select the curves to offset first": "Selecciona primero las curvas que se van a desfasar",
|
||||
"Select the mirror line": "Selecciona la línea de simetría",
|
||||
"Select the sketch curves or points to mirror first": "Selecciona primero las curvas o puntos del boceto que se van a reflejar",
|
||||
@@ -1222,7 +1221,6 @@
|
||||
"cmd.sketch_options": "Opciones de la paleta de bocetos",
|
||||
"cmd.sketch_paste": "Pegar entidades del boceto",
|
||||
"cmd.sketch_point3d": "Punto 3D",
|
||||
"cmd.sketch_point_divide": "Dividir curva",
|
||||
"cmd.sketch_polygon_circumscribed": "Polígono circunscrito",
|
||||
"cmd.sketch_polygon_edge": "Polígono por arista",
|
||||
"cmd.sketch_polygon_inscribed": "Polígono inscrito",
|
||||
@@ -1699,7 +1697,6 @@
|
||||
"help.sketch.pattern.circular": "Copies sketch geometry around a centre point, over a full circle or a set angle. Use it for bolt circles, spokes and teeth.",
|
||||
"help.sketch.pattern.rectangular": "Copies sketch geometry in rows and columns at set spacings. Use it for grids of holes, slots or vents.",
|
||||
"help.sketch.point": "Places a sketch point. Use it as a reference for dimensions, constraints, hole centres or later features.",
|
||||
"help.sketch.point.divide": "Coloca puntos que dividen las curvas elegidas en longitudes iguales. Los puntos permanecen en cada curva y mantienen su separación cuando esta cambia.",
|
||||
"help.sketch.point3d": "Places a point in space, with 3D Sketch on. Use it as a reference for 3D curves and paths.",
|
||||
"help.sketch.polygon.circumscribed": "Draws a regular polygon around a circle, sized from its centre to the middle of a side. Use it when the width across flats is known, as for a hex nut.",
|
||||
"help.sketch.polygon.edge": "Draws a regular polygon from the two ends of one side. Use it when the length and position of a side are known.",
|
||||
@@ -3052,8 +3049,6 @@
|
||||
"render.cpu_lighting": "Preajuste de iluminación",
|
||||
"render.cpu_lights": "Luces CPU",
|
||||
"render.cpu_linear_color": "Color lineal (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "Los renders CPU pueden usar mapas opacos guardados: elige una superficie y guarda sus mapas en la toma. Con Iluminación indirecta, otro cuerpo puede usar Vidrio; el cuerpo con mapas no puede usar Vidrio. Desactiva los reflejos seleccionados, la anisotropía y los efectos procedurales. Las rutas temporales de archivos de mapas usan GPU.",
|
||||
"render.cpu_material_certification": "La comprobación de CPU valida la geometría de vidrio admitida antes de renderizar. El vidrio cerrado usa la distancia geométrica.",
|
||||
"render.cpu_neutral": "Neutro",
|
||||
"render.cpu_opaque_required": "El trazado CPU requiere un fondo opaco. Desactiva Fondo transparente para continuar.",
|
||||
"render.cpu_options_pending": "Este panel CPU todavía no puede editar emisión, efectos de superficie, vidrio ni anisotropía. Borra esas opciones de la API antes de usarlo.",
|
||||
@@ -4177,7 +4172,6 @@
|
||||
"sketch.dimension_applied": "Cota aplicada",
|
||||
"sketch.dimension_applied_defined": "Cota aplicada; parámetro `{name}` definido",
|
||||
"sketch.dimension_applied_renamed": "Cota aplicada; parámetro nombrado `{name}`",
|
||||
"sketch.divide_hint": "Los puntos dividen cada curva en longitudes iguales y las mantienen cuando cambia.",
|
||||
"sketch.dof_left": "Quedan {count} grados de libertad",
|
||||
"sketch.dof_left_one": "Queda {count} grado de libertad",
|
||||
"sketch.draw_curves": "Dibujar como {linetype}",
|
||||
@@ -5059,7 +5053,6 @@
|
||||
"ui.heading_create_sketch": "CREAR BOCETO",
|
||||
"ui.heading_cylinder": "CILINDRO",
|
||||
"ui.heading_design": "DISEÑO",
|
||||
"ui.heading_divide_curve": "DIVIDIR CURVA",
|
||||
"ui.heading_draft": "ÁNGULO DE SALIDA",
|
||||
"ui.heading_edit": "EDITAR",
|
||||
"ui.heading_equation_curve": "CURVA ECUACIÓN",
|
||||
@@ -5400,7 +5393,6 @@
|
||||
"ui.second_rail": "Segunda línea guía",
|
||||
"ui.section_analysis": "Análisis de sección",
|
||||
"ui.section_size": "Tamaño de la sección",
|
||||
"ui.segments": "Segmentos",
|
||||
"ui.select": "Seleccionar",
|
||||
"ui.selectOther": "Seleccionar otro…",
|
||||
"ui.selectTangentChain": "Seleccionar cadena tangente",
|
||||
|
||||
@@ -566,7 +566,6 @@
|
||||
"Select edges": "Sélectionner des arêtes",
|
||||
"Select points of the sketch being edited": "Sélectionner des points de l'esquisse en cours de modification",
|
||||
"Select the centre the pattern turns around first": "Sélectionner d'abord le centre autour duquel la répétition pivote",
|
||||
"Select the curves to divide first": "Sélectionner d'abord les courbes à diviser",
|
||||
"Select the curves to offset first": "Sélectionner d'abord les courbes à décaler",
|
||||
"Select the mirror line": "Sélectionner la ligne de symétrie",
|
||||
"Select the sketch curves or points to mirror first": "Sélectionner d'abord les courbes ou points d'esquisse à symétriser",
|
||||
@@ -1049,7 +1048,6 @@
|
||||
"cmd.pointcloud_edit": "Modifier le nuage de points",
|
||||
"cmd.pointcloud_insert": "Insérer un nuage de points",
|
||||
"cmd.pointcloud_list": "Nuages de points",
|
||||
"cmd.pointcloud_reload": "Recharger le nuage de points",
|
||||
"cmd.render.compare.divider": "Déplacer le séparateur de comparaison des rendus",
|
||||
"cmd.render.result.discard": "Écarter le résultat de rendu",
|
||||
"cmd.render.result.select": "Sélectionner le résultat de rendu",
|
||||
@@ -1195,7 +1193,6 @@
|
||||
"cmd.sketch_options": "Options de la palette d'esquisse",
|
||||
"cmd.sketch_paste": "Coller les entités d'esquisse",
|
||||
"cmd.sketch_point3d": "Point 3D",
|
||||
"cmd.sketch_point_divide": "Diviser la courbe",
|
||||
"cmd.sketch_polygon_circumscribed": "Polygone circonscrit",
|
||||
"cmd.sketch_polygon_edge": "Polygone par côté",
|
||||
"cmd.sketch_polygon_inscribed": "Polygone inscrit",
|
||||
@@ -1428,7 +1425,6 @@
|
||||
"file_load.reading_percent": "Lecture du fichier... {percent} %",
|
||||
"file_load.rebuilding": "Reconstruction de l’entité {done} sur {total}",
|
||||
"file_load.recovering": "Récupération de la conception",
|
||||
"file_load.reloading_cloud": "Rechargement du nuage de points",
|
||||
"file_load.stopping": "Attente de l’arrêt de l’opération précédente...",
|
||||
"file_load.updating_links": "Mise à jour des liens",
|
||||
"file_load.waiting": "En attente d’une opération sur fichier",
|
||||
@@ -1640,7 +1636,6 @@
|
||||
"help.sketch.pattern.circular": "Copie la géométrie d'esquisse autour d'un centre, sur un cercle complet ou un angle donné. À utiliser pour des cercles de perçages, des rayons et des dents.",
|
||||
"help.sketch.pattern.rectangular": "Copie la géométrie d'esquisse en lignes et colonnes à des espacements donnés. À utiliser pour des grilles de trous, de rainures ou d'aérations.",
|
||||
"help.sketch.point": "Place un point d'esquisse. À utiliser comme référence pour des cotes, des contraintes, des centres de perçage ou des fonctions ultérieures.",
|
||||
"help.sketch.point.divide": "Place des points qui divisent les courbes choisies en longueurs égales. Les points restent sur chaque courbe et gardent leur espacement quand elle change.",
|
||||
"help.sketch.point3d": "Place un point dans l'espace, avec Esquisse 3D activée. À utiliser comme référence pour des courbes et trajectoires 3D.",
|
||||
"help.sketch.polygon.circumscribed": "Dessine un polygone régulier autour d'un cercle, dimensionné du centre au milieu d'un côté. À utiliser lorsque la largeur sur plats est connue, comme pour un écrou hexagonal.",
|
||||
"help.sketch.polygon.edge": "Dessine un polygone régulier à partir des deux extrémités d'un côté. À utiliser lorsque la longueur et la position d'un côté sont connues.",
|
||||
@@ -2787,8 +2782,6 @@
|
||||
"render.cpu_lighting": "Préréglage d'éclairage",
|
||||
"render.cpu_lights": "Lumières CPU",
|
||||
"render.cpu_linear_color": "Couleur linéaire (R, V, B)",
|
||||
"render.cpu_maps_saved_help": "Le rendu CPU accepte les cartes opaques enregistrées : choisissez une surface, puis enregistrez ses cartes dans la vue. Avec l’éclairage indirect, un autre corps peut utiliser Verre ; le corps avec les cartes ne le peut pas. Désactivez les réflexions sélectionnées, l’anisotropie et les effets procéduraux. Les fichiers temporaires de cartes utilisent le GPU.",
|
||||
"render.cpu_material_certification": "La vérification CPU valide la géométrie de verre prise en charge avant le rendu. Le verre fermé utilise la distance géométrique.",
|
||||
"render.cpu_neutral": "Neutre",
|
||||
"render.cpu_opaque_required": "Le lancer de rayons CPU nécessite un arrière-plan opaque. Décochez Arrière-plan transparent pour continuer.",
|
||||
"render.cpu_options_pending": "Ce panneau CPU ne peut pas encore modifier l'émission, les effets de surface, le verre ni l'anisotropie. Effacez ces options d'API avant de l'utiliser.",
|
||||
@@ -3914,7 +3907,6 @@
|
||||
"sketch.dimension_applied": "Cote appliquée",
|
||||
"sketch.dimension_applied_defined": "Cote appliquée ; paramètre `{name}` défini",
|
||||
"sketch.dimension_applied_renamed": "Cote appliquée ; paramètre nommé `{name}`",
|
||||
"sketch.divide_hint": "Les points divisent chaque courbe en longueurs égales et les gardent quand elle change.",
|
||||
"sketch.dof_left": "{count} degrés de liberté restants",
|
||||
"sketch.dof_left_one": "{count} degré de liberté restant",
|
||||
"sketch.draw_curves": "Dessiner en {linetype}",
|
||||
@@ -4704,7 +4696,6 @@
|
||||
"ui.heading_create_sketch": "CRÉER UNE ESQUISSE",
|
||||
"ui.heading_cylinder": "CYLINDRE",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_divide_curve": "DIVISER LA COURBE",
|
||||
"ui.heading_draft": "DÉPOUILLE",
|
||||
"ui.heading_edit": "ÉDITION",
|
||||
"ui.heading_equation_curve": "COURBE PAR ÉQUATION",
|
||||
@@ -4951,7 +4942,6 @@
|
||||
"ui.point_cloud_no_file_colors": "Le fichier n'a pas de couleurs",
|
||||
"ui.point_cloud_pause": "Pause",
|
||||
"ui.point_cloud_play": "Lire",
|
||||
"ui.point_cloud_reload": "Recharger le fichier",
|
||||
"ui.point_cloud_single_color": "Couleur unique",
|
||||
"ui.point_cloud_size": "Taille des points",
|
||||
"ui.point_cloud_units": "Unités du fichier",
|
||||
@@ -5044,7 +5034,6 @@
|
||||
"ui.second_rail": "Second rail",
|
||||
"ui.section_analysis": "Analyse de coupe",
|
||||
"ui.section_size": "Taille de la section",
|
||||
"ui.segments": "Segments",
|
||||
"ui.select": "Sélectionner",
|
||||
"ui.selectOther": "Sélectionner un autre…",
|
||||
"ui.selectTangentChain": "Sélectionner la chaîne tangente",
|
||||
|
||||
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "מידות הפריסה X",
|
||||
"Flat size Y": "מידות הפריסה Y",
|
||||
"Flipped": "הפוך",
|
||||
"Fold Features": "קיפול פיצ'רים",
|
||||
"Follow camera (Look At sketches)": "מעקב אחר המצלמה (הבטה אל סקיצות)",
|
||||
"Follow inherited appearance": "לפי מראה בירושה",
|
||||
"Follow material": "לפי החומר",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "זכוכית - מעושנת",
|
||||
"Grid": "רשת",
|
||||
"Groove": "חריץ",
|
||||
"Group Features": "קיבוץ פיצ'רים",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "קובץ ה-HDR השתנה מאז שנבחר; יש לבדוק ולבחור אותו מחדש לפני השמירה",
|
||||
"HISTORY": "ציר הזמן",
|
||||
"Halo under sketch curves": "הילה מתחת לעקומות סקיצה",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "מיקום החתך",
|
||||
"Sections": "חתכים",
|
||||
"Sections (one sketch each)": "חתכים (סקיצה אחת לכל אחד)",
|
||||
"Select Features": "בחירת פיצ'רים",
|
||||
"Select Other": "בחירת אחר",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "בחירת אחר: החזקת הכפתור השמאלי ללא תזוזה על המודל (או לחיצה ימנית › בחירת אחר…) מציגה את כל מה שמתחת לסמן; לחיצה נוספת באותה נקודה בוחרת את הפריט הבא שם. לחיצה כפולה על הכפתור האמצעי חוזרת לתצוגת הבית (העדפות › ניווט).",
|
||||
"Select Same Look": "בחר אותו מראה",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "בחירת מקצועות",
|
||||
"Select points of the sketch being edited": "יש לבחור נקודות של הסקיצה הנערכת",
|
||||
"Select the centre the pattern turns around first": "יש לבחור תחילה את המרכז שסביבו המערך מסתובב",
|
||||
"Select the curves to divide first": "יש לבחור תחילה את העקומות לחלוקה",
|
||||
"Select the curves to offset first": "יש לבחור תחילה את העקומות להיסט",
|
||||
"Select the mirror line": "יש לבחור את קו השיקוף",
|
||||
"Select the sketch curves or points to mirror first": "יש לבחור תחילה את עקומות הסקיצה או הנקודות לשיקוף",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "פח",
|
||||
"Sheet Rules": "כללי פח",
|
||||
"Show Agent Cursor": "הצגת סמן הסוכן",
|
||||
"Show Each Feature": "הצגת כל פיצ'ר",
|
||||
"Show ghost": "הצגת צללית",
|
||||
"Show in folder": "הצגה בתיקייה",
|
||||
"Show the pointer an AI agent moves (#35)": "הצגת הסמן שסוכן הבינה המלאכותית מזיז (#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "עריכת ענן נקודות",
|
||||
"cmd.pointcloud_insert": "הוספת ענן נקודות",
|
||||
"cmd.pointcloud_list": "ענני נקודות",
|
||||
"cmd.pointcloud_reload": "טעינה מחדש של ענן נקודות",
|
||||
"cmd.render.coat.bind": "שמירת ציפוי שקוף",
|
||||
"cmd.render.coat.unbind": "הסרת ציפוי שקוף",
|
||||
"cmd.render.compare.divider": "הזז את מחיצת ההשוואה של הרינדור",
|
||||
"cmd.render.result.discard": "השלך תוצאת רינדור",
|
||||
"cmd.render.result.select": "בחר תוצאת רינדור",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "אפשרויות חלונית הסקיצה",
|
||||
"cmd.sketch_paste": "הדבקת ישויות סקיצה",
|
||||
"cmd.sketch_point3d": "נקודה תלת־ממדית",
|
||||
"cmd.sketch_point_divide": "חלוקת עקומה",
|
||||
"cmd.sketch_polygon_circumscribed": "מצולע חוסם",
|
||||
"cmd.sketch_polygon_edge": "מצולע לפי צלע",
|
||||
"cmd.sketch_polygon_inscribed": "מצולע חסום",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "דפדפן רינדור",
|
||||
"cmd.view.render_dock": "הזז את עגינת הרינדור",
|
||||
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
|
||||
"cmd.view.render_live": "השהיה או חידוש של הדמיה חיה",
|
||||
"cmd.view.status_details": "פרטי מצב חלון התצוגה",
|
||||
"cmd.view.status_items": "פריטי מצב תצוגה",
|
||||
"cmd.view_ambient_occlusion": "הצללה סביבתית",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "קורא את הקובץ... {percent}%",
|
||||
"file_load.rebuilding": "בונה מחדש פיצ'ר {done} מתוך {total}",
|
||||
"file_load.recovering": "משחזר את התכן",
|
||||
"file_load.reloading_cloud": "טוען מחדש ענן נקודות",
|
||||
"file_load.step_build": "בונה את המודל",
|
||||
"file_load.step_feature": "תכונה {done} מתוך {total}",
|
||||
"file_load.step_part": "חלק {done} מתוך {total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "מעתיק גאומטריית סקיצה סביב נקודת מרכז, על פני מעגל שלם או זווית נתונה. מתאים למעגלי ברגים, לחישורים ולשיניים.",
|
||||
"help.sketch.pattern.rectangular": "מעתיק גאומטריית סקיצה בשורות ובעמודות במרווחים קבועים. מתאים לרשתות של חורים, חריצים או פתחי אוורור.",
|
||||
"help.sketch.point": "מציב נקודת סקיצה. משמש כייחוס למידות, לאילוצים, למרכזי חורים או למאפיינים מאוחרים יותר.",
|
||||
"help.sketch.point.divide": "מציב נקודות המחלקות את העקומות שנבחרו לאורכים שווים. הנקודות נשארות על כל עקומה ושומרות על המרווח כשהיא משתנה.",
|
||||
"help.sketch.point3d": "מציב נקודה במרחב, כשסקיצה תלת־ממדית פעילה. משמש כייחוס לעקומות ולמסלולים תלת־ממדיים.",
|
||||
"help.sketch.polygon.circumscribed": "משרטט מצולע משוכלל סביב מעגל, בגודל שנקבע ממרכזו ועד אמצע צלע. מתאים כשהרוחב בין הצלעות ידוע, כמו באום משושה.",
|
||||
"help.sketch.polygon.edge": "משרטט מצולע משוכלל משני קצותיה של צלע אחת. מתאים כשאורך הצלע ומיקומה ידועים.",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt וגרירה שמאלית מסובבים, Alt וגרירה אמצעית מזיזים, Alt וגרירה ימנית מבצעים זום; גם גרירה אמצעית מזיזה",
|
||||
"nav.nx.help": "גרירה בלחצן האמצעי מסובבת, Shift+האמצעי מזיז את התצוגה (ב-NX: אמצעי + ימני), Ctrl+האמצעי מקרב ומרחיק (ב-NX: אמצעי + שמאלי); גרירה בלחצן הימני לא מזיזה את המצלמה",
|
||||
"nav.openscad.help": "גרירה שמאלית על שטח ריק מסובבת, Shift וגרירה שמאלית בוחרים במלבן, גרירת גאומטריית סקיצה עורכת אותה; גרירה ימנית או אמצעית מזיזה, Shift וגרירה ימנית או אמצעית מבצעים זום",
|
||||
"nav.orbit_cursor_wrap.help": "גרירת סיבוב שמגיעה לקצה המבט מעבירה את הסמן לקצה הנגדי וממשיכה לסובב. כבוי: הגרירה נעצרת בקצה המסך. ללא השפעה ב־Wayland או בדפדפן.",
|
||||
"nav.orbit_cursor_wrap.label": "העברת הסמן לצד השני בזמן סיבוב",
|
||||
"nav.perspective.sketch": "עריכת סקיצה משתמשת בתצוגה אורתוגרפית. יש לסיים את הסקיצה כדי לעבור לפרספקטיבה.",
|
||||
"nav.right_drag_orbits.help": "כבוי: גרירה בכפתור הימני לא מזיזה את המצלמה, והכפתור הימני רק פותח את תפריט ההקשר. בחירת מיפוי העכבר SolidWorks מכבה זאת.",
|
||||
"nav.right_drag_orbits.label": "גרירה ימנית מסובבת",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "הסתרת סמלי אילוצים בהקטנה",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "סמלי האילוצים נעלמים כשהשרטוט מוקטן מספיק כדי שלא יעמיסו על התצוגה. כבוי: הם נשארים בגודל מסך קבוע בכל זום.",
|
||||
"prefs.history": "ציר הזמן",
|
||||
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
|
||||
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
|
||||
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
|
||||
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
|
||||
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
|
||||
"prefs.history_origin_bodies_note": "ההיסטוריה משמאל או מתחת לדפדפן מתחילה בתיקיית הגופים של הרכיב הפעיל ובראשית, עם סמלי העין והתפריטים כמו בדפדפן. ההיסטוריה למטה לא משתנה.",
|
||||
"prefs.history_play_button": "כפתור הפעלת ציר הזמן בפקדי ציר הזמן",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "מציג את מידות הבחירה בפינה הימנית התחתונה של התצוגה, מעל ההיסטוריה: אורך מקצוע (או Ø ו-R למקצוע עגול), שטח פאה, מיקום נקודה, המרחק והזווית בין שני פריטים, והסכום של כמה מקצועות או פאות. לחיצה מעתיקה אותו.",
|
||||
"prefs.remember_dialog_values": "זכור ערכים בתיבות דו-שיח של פקודות",
|
||||
"prefs.remember_dialog_values.help": "כבוי כברירת מחדל. כשהוא פועל, לאחר הצלחת פקודה, הערכים והאפשרויות הבטוחים שלה ישמשו שוב בפעם הבאה שתפתח את אותה פקודה, עד לסגירת SolidCraft.",
|
||||
"prefs.render_legacy_image_defaults": "שימוש בברירות המחדל הקודמות לתמונות הדמיה",
|
||||
"prefs.render_legacy_image_defaults_help": "שמירה על תהליך העבודה הידני הקודם: הגדרות חדשות ברסטר CPU, דגימה קבועה בעקיבת קרניים ב־CPU ופתיחת התוצאות ידנית. התאמת התצוגה מחדש בכניסה להדמיה, מעבר בין צילומים בלי לשחזר את המצלמות שלהם ושימוש במסגור ובניווט הקודמים. עדכון אוטומטי של התמונה החיה מושבת. הגדרות שמורות אינן משתנות.",
|
||||
"prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור",
|
||||
"prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.",
|
||||
"prefs.reports_saved": "{n} שמורים",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "עבודה",
|
||||
"prefs.supersampling_brief": "מצייר את המבט גדול יותר ומקטין אותו: חד מאוד, וכבד יותר.",
|
||||
"prefs.tab_accent_note": "צבע ההדגשה עוקב אחר הלשונית הפעילה",
|
||||
"prefs.tab_double_click_renames": "לחיצה כפולה על לשונית עיצוב משנה את שמו",
|
||||
"prefs.tab_double_click_renames_note": "הקלידו את השם החדש ואז הקישו Enter או לחצו במקום אחר כדי לשמור אותו, או Esc כדי לבטל. כבוי: לחיצה כפולה על לשונית מגדילה את החלון, כמו בחלק הריק של הסרגל.",
|
||||
"prefs.theme_file": "קובץ ערכת נושא",
|
||||
"prefs.theme_file_or_paste": "קובץ או הדבקה",
|
||||
"prefs.theme_follow": "מעקב אחר קובץ ערכת נושא",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "לא ניתן היה לחבר אל {body}.",
|
||||
"refused.repair": "תיקון חלק",
|
||||
"refused.show_face": "הצגת הפאה",
|
||||
"render.adaptive.pilot_progress": "מעברים מקדימים אדפטיביים · {current}/{target}",
|
||||
"render.adaptive.preview_help": "התמונות המקדימות מנחות את הקצאת הדגימות ואינן נכללות בתמונה הסופית. איטרציות ההדמיה משתמשות בתקציב נפרד לכל פיקסל; מספר האיטרציות אינו מספר דגימות אחיד לכל פיקסל.",
|
||||
"render.adaptive.production_progress": "הדמיה אדפטיבית · {current}/{target} איטרציות",
|
||||
"render.adaptive_absolute": "רצפת שגיאת RGB מוחלטת",
|
||||
"render.adaptive_coverage": "יעד שגיאת כיסוי",
|
||||
"render.adaptive_enable": "הקצאת דגימות באמצעות דגימות גישוש עצמאיות",
|
||||
"render.adaptive_help": "דגימות החלוץ העצמאיות נזרקות, ואז נקבעות לכל פיקסל לפחות 16 דגימות הפקה, עד לערך דגימות לפיקסל. מגבלות משאבי CPU כוללות את שני השלבים. השונות ב-RGB ובכיסוי מנחה את ההקצאה; פיקסלים שהגיעו לתקרה עשויים להישאר רועשים. הדוח הסופי מונה את הדגימות בפועל.",
|
||||
"render.adaptive_help": "GPU בלבד. דגימות הגישוש העצמאיות מושלכות, ואז מוקצות 16 דגימות הפקה או יותר לכל פיקסל עד לערך „דגימות לכל פיקסל”. השונות של RGB ושל הכיסוי מכוונת את ההקצאה; מסלולים נדירים עלולים להיות מוחמצים ופיקסלים שהגיעו לתקרה עשויים להישאר רועשים. הדוח הסופי סופר את העבודה בפועל. אין ערובה מאושרת לרמת הרעש או לזמן.",
|
||||
"render.adaptive_minimum": "מינימום דגימות הפקה",
|
||||
"render.adaptive_pending": "הקצאה מסתגלת עם דגימות חלוץ עצמאיות זמינה ב-CPU וב-GPU. שכבות פרנל מותנות דורשות GPU. ההקצאה הסטטיסטית אינה מבטיחה התכנסות.",
|
||||
"render.adaptive_pending": "הקצאה אדפטיבית ב־GPU עם דגימות גישוש עצמאיות ושכבות פרנל מותנות זמינות. התכנסות מאושרת, ערובה לאירועים נדירים והעברה אדפטיבית ב־CPU עדיין אינן זמינות.",
|
||||
"render.adaptive_pilots": "דגימות גישוש לכל פיקסל",
|
||||
"render.adaptive_relative": "יעד שגיאת RGB יחסית",
|
||||
"render.add_ground": "הוספת קרקע מותאמת להגדרה",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "שיעור המתכתיות של השכבה",
|
||||
"render.aging_origin": "ראשית השדה · mm בעולם",
|
||||
"render.aging_patina": "פטינה נחושתית ירוקה",
|
||||
"render.aging_raytrace_help": "התיישנות מוחלת במעקב קרניים במעבד ובמעקב מסלולים במעבד הגרפי. רינדור רסטר נשאר סכמטי.",
|
||||
"render.aging_refresh_targets": "בדיקת הגופים הנוכחיים",
|
||||
"render.aging_roughness": "חספוס השכבה",
|
||||
"render.aging_rust": "ברזל מחומצן / חלודה",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "גוף מלא סגור",
|
||||
"render.appearance_editor.coat": "ציפוי שקוף",
|
||||
"render.appearance_editor.cpu_reason": "דורש מעקב קרניים במעבד.",
|
||||
"render.appearance_editor.discard_saved_edit": "ביטול שינויים אופטיים שלא נשמרו",
|
||||
"render.appearance_editor.glass": "זכוכית וספיגה",
|
||||
"render.appearance_editor.glass_enable": "השתמש בזכוכית פיזיקלית בגוף זה",
|
||||
"render.appearance_editor.glass_note": "זכוכית סגורה משתמשת במרחק גאומטרי. אי אפשר לשלב אותה עם ציפוי שקוף או מפות באותו גוף.",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "החלף את האופטיקה השמורה של גוף זה",
|
||||
"render.appearance_editor.revalidate": "אמת מחדש גוף זה",
|
||||
"render.appearance_editor.save": "שמור אופטיקה זו",
|
||||
"render.appearance_editor.save_reason": "כדי לשמור זכוכית, יש להפעיל אותה בגוף CPU או GPU עדכני. לציפוי שקוף יש להשתמש בשמירת הציפוי בתצלום. קובצי תמונה מקומיים נשארים זמניים.",
|
||||
"render.appearance_editor.save_reason": "שמירת האופטיקה הזו דורשת זכוכית פיזיקלית בגוף GPU עדכני. הציפוי ומפות התמונה המקומיות נשארים זמניים.",
|
||||
"render.appearance_editor.saved": "נשמר עם הצילום הזה",
|
||||
"render.appearance_editor.saved_state": "האופטיקה הזו נשמרת עם הצילום הזה.",
|
||||
"render.appearance_editor.scatter_reason": "פיזור פנימי דורש גבול של גוף מלא סגור.",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "שיעור מתכתיות",
|
||||
"render.base_opaque_only": "עורך זה שומר על משמעות של גוף אטום. להעברה אופטית יש להשתמש בפקדי הזכוכית המפורשים.",
|
||||
"render.batch_all_shots": "כל הצילומים ({count})",
|
||||
"render.batch_current_settings_help": "משתמש בהגדרות התמונה הנוכחיות עם המצלמה של כל שוט שמור, שוט אחד בכל פעם.",
|
||||
"render.batch_empty": "שמור צילום לפני רינדור כל הצילומים.",
|
||||
"render.batch_limit": "רינדור כל הצילומים מוגבל ל-32 צילומים.",
|
||||
"render.batch_owner_limit": "ניתן לשמור תורי צילומים של 32 מסמכים לכל היותר. יש להשלים או לבטל תור קיים תחילה.",
|
||||
"render.batch_preparing": "מכין את הצילום הבא",
|
||||
"render.batch_status": "{running} רצים · {waiting} ממתינים",
|
||||
"render.batch_waiting_worker": "ממתין להכנת הרינדור",
|
||||
"render.bloom_enable": "זוהר בתצוגה",
|
||||
"render.bloom_help": "זוהר יצירתי לפלט בלבד מהבהרות ליניאריות חיוביות, המתפשט בשלושה מעברי תיבה מנורמלים. הרדיוס בפיקסלים של הפלט. נוסף באור תצוגה ליניארי לפני sRGB; אינו עדשה, אינו אפקט נפחי ואינו שיפור התכנסות. ה־EXR הגולמי אינו משתנה.",
|
||||
"render.bloom_opaque": "זוהר דורש פלט אטום. PNG שקוף נדחה, ואינו משוטח בשקט.",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "צבע",
|
||||
"render.category_plastic": "פלסטיק",
|
||||
"render.category_wood": "עץ",
|
||||
"render.caustics_depth": "עומק מסלול ריכוז האור",
|
||||
"render.caustics_enable": "ריכוז אור דרך זכוכית ממקורות נקודתיים וכיווניים",
|
||||
"render.caustics_help": "מספר הפוטונים שולט ברעש ובעומס החישוב. רדיוס האיסוף מאזן בין פרטים חדים לאומדן חלק יותר. CPU דורש תאורה עקיפה.",
|
||||
"render.caustics_path_required": "ריכוז אור ב-CPU דורש תאורה עקיפה.",
|
||||
"render.caustics_photons": "מספר פוטונים",
|
||||
"render.caustics_radius": "רדיוס איסוף",
|
||||
"render.check_glass_cpu": "ה־CPU תומך בלוחות זכוכית מישוריים ובתיבות מקבילות לצירים שעברו אימות. רשתות סגורות כלליות, קינון ופיזור פנימי דורשים תאורה עקיפה. העברת צל דרך זכוכית סגורה עדיין אינה נתמכת.",
|
||||
"render.check_glass_gpu": "ה־GPU תומך בנפחי זכוכית נפרדים שעברו אימות או בקינון מאומת שמופעל במפורש. קינון דורש גופים חיצוניים קמורים, גופים פנימיים המוכלים בתוכם ממש וגבולות שאינם נוגעים זה בזה.",
|
||||
"render.check_media_remaining": "רשתות צפיפות, הסטת משטח, אינטגרציה ספקטרלית מלאה והעברת מצב קיטוב עדיין אינן זמינות.",
|
||||
"render.check_media_scope": "פיזור פנימי פעיל דורש נפח זכוכית סגור, העברת קרני צל ישרות כבויה והסרת רעש כבויה. ה־GPU דורש גם שכבת פרנל אחת. אי אפשר לשלב אטמוספירה חיצונית פעילה עם זכוכית או עם הסרת רעש משטחית.",
|
||||
"render.check_opaque": "שקיפות: משטחים אטומים בלבד; אלפא של הרקע נתמכת.",
|
||||
"render.check_sorted_alpha": "שקיפות: קירוב אלפא ממוין.",
|
||||
"render.check_transmission": "שקיפות: מעבר אור פיזיקלי.",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "העתק JSON של הבקשה",
|
||||
"render.children.coverage_error": "שגיאת כיסוי",
|
||||
"render.children.cpu": "מעקב קרניים במעבד",
|
||||
"render.children.denoise": "הפחתת רעש בתמונה",
|
||||
"render.children.denoise": "הפחת רעש בתמונה המוגמרת",
|
||||
"render.children.depth": "עומק נתיב",
|
||||
"render.children.distortion": "עיוות",
|
||||
"render.children.edge_samples": "דגימות קצה",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "בחר קודם מראה במראות",
|
||||
"render.chromatic_offset": "היסט כרומטי בקצוות",
|
||||
"render.clear_stage": "הסרת הבמה השמורה",
|
||||
"render.coat_discard": "ביטול עריכת הציפוי",
|
||||
"render.coat_pending_edit": "שינוי הציפוי הזה לא נשמר. יש לשמור אותו עם התצלום או לבטל את השינוי.",
|
||||
"render.coat_remove": "הסרת ציפוי שמור",
|
||||
"render.coat_remove_note": "מסיר את הציפוי של היעד הזה מהתצלום. הגאומטריה, המראה והחומרים הפיזיקליים אינם משתנים.",
|
||||
"render.coat_remove_title": "להסיר את הציפוי השקוף השמור?",
|
||||
"render.coat_revalidate_saved": "הציפוי השמור השתנה. יש לבטל את העריכה הזו ולפתוח מחדש את היעד לפני עריכה נוספת.",
|
||||
"render.coat_save": "שמירת הציפוי בתצלום",
|
||||
"render.coat_saved_help": "ציפוי שקוף מוסיף שכבה מבריקה למשטחים אטומים. יש לשמור אותו עם התצלום. זכוכית אינה יכולה להשתמש באותו יעד, וגם לא דריסות של השתקפות מותאמת אישית ואנאיזוטרופיה ב־CPU.",
|
||||
"render.compare_divider": "מחיצת השוואה",
|
||||
"render.compare_live": "חי",
|
||||
"render.compare_live_preview": "תצוגה חיה",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "תקרת משולשים",
|
||||
"render.cpu_budget_workstation": "תחנת עבודה (סצנות כבדות)",
|
||||
"render.cpu_captured_hdr": "HDR שנלכד לתמונה זו",
|
||||
"render.cpu_celestial_adopt": "מעקב קרניים במעבד עם תאורה עקיפה · עומק מסלול {depth} · {spp} דגימות לפיקסל · צללים במעקב · זרע {seed} · דגימה ראשית אחת",
|
||||
"render.cpu_celestial_adopt_label": "מעקב קרניים במעבד עם תאורה עקיפה",
|
||||
"render.cpu_celestial_path_required": "שמש בעלת גודל סופי ושמיים מדורגים משתמשים בתאורה עקיפה במעבד.",
|
||||
"render.cpu_celestial_switch": "כדי לשמור על השמש או השמיים האלה, יש לבחור מעקב קרניים במעבד עם תאורה עקיפה.",
|
||||
"render.cpu_celestial_unportable": "יש לשמור על מעקב מסלולים במעבד הגרפי עבור הגדרות דגימות ההעברה הנוכחיות.",
|
||||
"render.cpu_check_capture_help": "בדיקה והתחלה מאמתות את אותו תקציר קובץ או משתמשות ב־HDR המוטמע. כל תמונה שומרת את המקור שנלכד עבורה.",
|
||||
"render.cpu_check_changed": "הפקדים או המודל השתנו אחרי הבדיקה הזאת. יש לבדוק שוב לפני ההדמיה.",
|
||||
"render.cpu_constant_direct_help": "ללא תאורה עקיפה, מקור קבוע זה עשוי להופיע בהשתקפויות ודרך זכוכית. הוא אינו מאיר משטחים אטומים. יש להפעיל תאורה עקיפה כדי להשתמש בו לתאורת הסצנה.",
|
||||
"render.cpu_constant_enable_indirect_plan": "עומק מסלול {depth}; דגימה הדרגתית עם {spp} דגימות לפיקסל (זרע {seed}); קרן ראשית אחת לכל דגימה; צללים במעקב קרניים. גודל התמונה, תקציב ה־CPU וההגדרות האחרות נשמרים.",
|
||||
"render.cpu_constant_help": "קרינה קבועה נראית לקרניים מוחזרות או מועברות. היא אינה מספקת תאורה מפוזרת ישירה.",
|
||||
"render.cpu_custom_directions": "החלפת הפרופיל המוכן באורות כיווניים מותאמים",
|
||||
"render.cpu_denoise_help": "מפחיתה רעש בכל עדכון תמונה. היא עלולה לרכך פרטים עדינים וקצוות של עומק שדה. קובץ EXR הגולמי אינו משתנה. התאמות של תמונה שהושלמה משתמשות בתמונה הגולמית שלה ואינן מחילות שוב הפחתת רעש.",
|
||||
"render.cpu_direction": "כיוון אל האור (X, Y, Z)",
|
||||
"render.cpu_directional": "אור כיווני",
|
||||
"render.cpu_directional_add": "הוספת אור כיווני",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "דגימות תאורת HDR",
|
||||
"render.cpu_exposure_help": "חשיפת CPU חייבת להיות בין −16 ל־16 EV. חשיפת GPU מחוץ לטווח הזה נשמרת עד שמשנים אותה.",
|
||||
"render.cpu_fixed": "תמונה בודדת",
|
||||
"render.cpu_fixed_retained": "הדגימה הקבועה נשמרה",
|
||||
"render.cpu_glass_enable_path": "הפעלת תאורה עקיפה",
|
||||
"render.cpu_hdr_help": "קובץ Radiance .hdr, פנורמה 2:1, עד 2048 × 1024 ו־16 MiB. CPU דורש בקובץ צבעי יסוד sRGB/D65 מפורשים; צבעי יסוד לא מוגדרים וקובצי EXR נדחים.",
|
||||
"render.cpu_hdr_reflection_help": "החזרות HDR מחוספסות דורשות משטחי גופים ופאות מתכתיים לחלוטין. הבדיקה מדווחת על צירופים שאינם נתמכים.",
|
||||
"render.cpu_indirect_method_required": "יש לבחור מעקב קרניים ב־CPU כדי להפעיל מצב זה של תאורה עקיפה.",
|
||||
"render.cpu_key_rim": "ראשי ושוליים",
|
||||
"render.cpu_last_check": "בדיקת CPU אחרונה",
|
||||
"render.cpu_light_help": "עד שמונה אורות כיווניים. רשימה מותאמת ריקה מכבה אותם; בחירת פרופיל מוכן משחזרת את האורות שלו. הצבעים הם RGB ליניארי והעוצמה יחסית, לא בוואטים.",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "פרופיל תאורה מוכן",
|
||||
"render.cpu_lights": "אורות CPU",
|
||||
"render.cpu_linear_color": "צבע ליניארי (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "רינדור CPU יכול להשתמש במפות אטומות שמורות: בחרו כאן משטח ושמרו את המפות בתצלום. עם תאורה עקיפה, גוף אחר יכול להשתמש בזכוכית; הגוף בעל המפות אינו יכול להשתמש בזכוכית. יש לכבות החזרות שנבחרו, אנאיזוטרופיה ואפקטים פרוצדורליים. נתיבים זמניים לקובצי מפות משתמשים ב־GPU.",
|
||||
"render.cpu_material_certification": "בדיקת CPU מאמתת את גאומטריית הזכוכית הנתמכת לפני הרינדור. זכוכית סגורה משתמשת במרחק גאומטרי.",
|
||||
"render.cpu_neutral": "ניטרלי",
|
||||
"render.cpu_opaque_hint": "עקיבת קרניים ב־CPU משתמשת ברקע אטום.",
|
||||
"render.cpu_opaque_required": "מעקב קרניים ב־CPU דורש רקע אטום. יש לבטל את „רקע שקוף” כדי להמשיך.",
|
||||
"render.cpu_options_pending": "לוח CPU זה עדיין אינו יכול לערוך פליטה, אפקטי משטח, זכוכית או אנאיזוטרופיה. יש לנקות את אפשרויות ה־API האלה לפני השימוש בו.",
|
||||
"render.cpu_path_depth": "עומק מסלול",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_lighting": "תאורה עקיפה",
|
||||
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_point": "אור נקודתי",
|
||||
"render.cpu_point_add": "הוספת אור נקודתי",
|
||||
"render.cpu_point_help": "עד שמונה אורות נקודתיים, המתווספים לתאורה הכיוונית שנבחרה. המרחקים במילימטרים; המינימום אינו יכול לעלות על מרחק הייחוס.",
|
||||
"render.cpu_primary_samples": "דגימות קצה לכל פיקסל",
|
||||
"render.cpu_progressive": "CPU הדרגתי",
|
||||
"render.cpu_progressive_adopt": "שימוש בדגימה הדרגתית",
|
||||
"render.cpu_progressive_adopt_help": "מעבר לדגימה הדרגתית משתמש בדגימת מצלמה אחת לכל מעבר. גודל התמונה, האורות והגדרות ההחזר נשארים ללא שינוי.",
|
||||
"render.cpu_progressive_always": "דגימה הדרגתית פעילה תמיד",
|
||||
"render.cpu_progressive_help": "נאספות דגימות מצלמת CPU חדשות ומוצג כל מעבר שהושלם. השתקפויות נבחרות משתמשות בניסיון אחד לכל דגימת מצלמה; זכוכית מאושרת ואנאיזוטרופיה מישורית שומרות על מגבלותיהן הקיימות. דגימות החלוץ וההפקה המסתגלות חולקות את תקציב פרופיל CPU.",
|
||||
"render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; אורות שטח, דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
|
||||
"render.cpu_progressive_limits": "לכל היותר 1,048,576 פיקסלים ו־16,777,216 הערכות פיקסל × דגימה. שינוי הדגימות לעולם אינו משנה את גודל התמונה אוטומטית.",
|
||||
"render.cpu_progressive_reflection_adopt": "יש לקבוע את מספר ניסיונות ההחזר שנבחרו ל־1 לפני המעבר לדגימה הדרגתית.",
|
||||
"render.cpu_progressive_seed": "זרע דגימה ב־CPU",
|
||||
"render.cpu_progressive_spp": "דגימות לכל פיקסל",
|
||||
"render.cpu_quality_budget_help": "משנה גודל, תקרת דגימות הפקה, זרע ופרופיל משאבי CPU. המצלמה, העדשה, התאורה, עומק GI והאפקטים נשמרים.",
|
||||
"render.cpu_quality_progressive_required": "יש להפעיל דגימה מתקדמת למטה לפני בחירת תקציב CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · תקרת דגימות {samples} · זרע {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "הוספת החזרה מחוספסת",
|
||||
"render.cpu_reflection_depth": "עומק החזרה (1–8)",
|
||||
"render.cpu_reflection_empty": "לא נבחרו גופים: החזרות הסצנה כבויות.",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "הגדרות האור והסביבה המספריות נשמרות עם ההגדרה. החזרות גופים וקובצי HDR נשארים זמניים. ערכי אורות שטח וקונוסי זרקורים קיימים נשמרים.",
|
||||
"render.cpu_sampling_mode": "מצב דגימה ב־CPU",
|
||||
"render.cpu_shadows": "הטלת צללים",
|
||||
"render.cpu_sky_bounds": "סיבוב השמיים חייב להיות סופי בטווח ±1,000,000 רדיאנים, והעוצמה חייבת להיות בטווח 0–100.",
|
||||
"render.cpu_studio_enable": "הפעלת תאורה עקיפה עבור מערך זה",
|
||||
"render.cpu_studio_enable_apply": "הפעלת תאורה עקיפה והחלה",
|
||||
"render.cpu_studio_indirect_help": "מערך השמש הזה דורש תאורה עקיפה בעומק 8, צללים מחושבים ודגימת מצלמה אחת בכל מעבר. אם רינדור הדרגתי כבוי, הוא מתחיל ב־16 דגימות לפיקסל עם זרע 0. הגדרות הדרגתיות קיימות ופרופיל המשאבים נשמרים. הגדרות שאינן תואמות עוצרות את ההחלה בלי לשנותן.",
|
||||
"render.cpu_studio_radiance_limit": "עוצמת מקור השטח במערך זה היא {radiance}, מעל מגבלת CPU של 64. התאורה המקורית נשמרת; בחרו מערך אחר או GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "פיזור בתוך זכוכית ב־CPU דורש הסרת רעש כבויה. ההגדרות נשמרות.",
|
||||
"render.cpu_subsurface_help": "פיזור משנה את כיוון האור בתוך זכוכית סגורה. קצב חיובי דורש תאורה עקיפה והסרת רעש כבויה. הקצב הוא מספר התנגשויות למ״מ; 0 אינו מוסיף פיזור. הטיית כיוון חיובית מעדיפה קדימה, ושלילית אחורה.",
|
||||
"render.cpu_summary": "מעקב קרניים ב־CPU · החזרות · HDR · אורות כיווניים ונקודתיים",
|
||||
"render.cpu_transparent_help": "PNG שקוף מסיר את הרקע ושומר על כיסוי הקצוות. אטימות הגופים והפאות חייבת להישאר 100%.",
|
||||
"render.cpu_volume_enable_path": "הפעלת תאורה עקיפה",
|
||||
"render.cpu_volume_enable_path_plan": "מעקב קרניים ב־CPU · עומק מסלול {depth} · {spp} דגימות לפיקסל · זרע {seed} · צללים במעקב קרניים. האטמוספרה ותקציב התמונה שנקבעו נשמרים.",
|
||||
"render.cpu_volume_help": "אטמוספרה ב־CPU משתמשת בתאורה עקיפה. הכחדה אפסית שומרת את התיבה ללא ערפל.",
|
||||
"render.cpu_volume_method_adopt": "מעקב קרניים ב־CPU עם תאורה עקיפה",
|
||||
"render.cpu_volume_method_incompatible": "לא ניתן להעביר הגדרות תאורה, דגימה או אטמוספרה אלה לשיטה זו. ההגדרות שלך נשמרות.",
|
||||
"render.cpu_volume_path_required": "יש להפעיל תאורה עקיפה כדי להשתמש באטמוספרה ב־CPU.",
|
||||
"render.cpu_warm_cool": "חם וקר",
|
||||
"render.diag_bodies": "גופים גלויים",
|
||||
"render.diag_job_details": "פרטי המשימה שנלכדו",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "פרטי הבדיקה המקדימה",
|
||||
"render.diag_request": "הבקשה הנוכחית המדויקת",
|
||||
"render.diag_triangles": "משולשים",
|
||||
"render.directional_sun_limit": "תאורת GPU תומכת בשמונה מקורות אור כיווניים בסך הכול, ובהם לכל היותר שמש אחת בעלת צללים רכים. שכפלו אור כיווני בעל צללים חדים כדי להוסיף מקור מרוחק נוסף.",
|
||||
"render.discard_continue": "ביטול הטיוטה והמשך",
|
||||
"render.draft_confirm_help": "בטיוטת ההדמיה הנוכחית יש עריכות שלא הוחלו. יש לשמור אותן לפני המעבר, לבטל אותן במפורש או להמשיך לערוך.",
|
||||
"render.draft_confirm_title": "שמירת הגדרות ההדמיה שנערכו",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "ייצוא: PNG לתצוגה",
|
||||
"render.export_image_label": "ייצוא תמונה",
|
||||
"render.export_linear": "ייצוא: EXR ליניארי גולמי",
|
||||
"render.exr_pending": "עקיבת קרניים ב־CPU ועקיבת נתיבים ב־GPU יכולות לשמור EXR ליניארי גולמי. דחיסה, תמונות רב־שכבתיות ומעברי הדמיה נלווים עדיין אינם זמינים.",
|
||||
"render.exr_pending": "GPU יכול לשמור EXR ליניארי גולמי. דחיסה, תמונות רב־שכבתיות ומעברי הדמיה נלווים עדיין אינם זמינים.",
|
||||
"render.film_grain": "גרעיניות צילומית",
|
||||
"render.fit": "התאמת המוצר לתצוגה",
|
||||
"render.fixed_camera": "מצלמה",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · אנכי",
|
||||
"render.frame_thirds": "שלישים",
|
||||
"render.frame_wide": "16:9 · רחב",
|
||||
"render.glass_abbe": "מספר אבה",
|
||||
"render.glass_dispersion": "נפיצה כרומטית",
|
||||
"render.glass_film": "התאבכות בשכבה דקה",
|
||||
"render.glass_film_ior": "מקדם שבירה של השכבה",
|
||||
"render.glass_film_thickness": "עובי השכבה",
|
||||
"render.glass_model_enable": "מודל זכוכית מתקדם",
|
||||
"render.glass_model_help": "חספוס הזכוכית מפזר קרניים מועברות. נפיצה וצבע השכבה משתמשים בשלושה תחומי אורך גל. לוחות מחוספסים דורשים כיבוי צללים מועברים בקו ישר.",
|
||||
"render.glass_roughness": "חספוס זכוכית",
|
||||
"render.gpu_depth": "עומק מסלול מרבי",
|
||||
"render.gpu_options_help": "הדגימות, העומק, הזרע והחשיפה נשמרים עם ההגדרה הזאת. נשמרת רק שיטת התמונה הפעילה.",
|
||||
"render.gpu_path_crashed": "SolidCraft נסגר בזמן ההכנה על מתאם גרפי זה בפעם הקודמת, ככל הנראה בתוך מנהל ההתקן הגרפי, ולכן ההכנה לא תתבצע שוב מעצמה. יש להשתמש במעקב קרניים על המעבד במקום זאת, או לנסות שוב (עדכון מנהל ההתקן גם מנסה שוב).",
|
||||
"render.gpu_path_failed": "מעקב המסלולים ב־GPU אינו זמין",
|
||||
"render.gpu_path_first_setup": "ההגדרה הראשונה ב־GPU הזה עשויה להימשך כמה דקות; SolidCraft נשאר זמין לשימוש בינתיים. הפעלות מאוחרות יותר משתמשות שוב בשיידרים שהודרו, כשמנהל ההתקן שומר אותן.",
|
||||
"render.gpu_path_not_started": "הגדרת מעקב המסלולים ב-GPU טרם התחילה.",
|
||||
"render.gpu_path_preparing": "מעקב מסלולים ב־GPU: הידור שיידרים…",
|
||||
"render.gpu_path_retry": "לנסות שוב",
|
||||
"render.gpu_seed": "זרע אקראי",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "מרכז המוצר",
|
||||
"render.light_size": "גודל",
|
||||
"render.light_softness": "רכות",
|
||||
"render.light_space_world": "אורות בקואורדינטות העולם",
|
||||
"render.light_space_world_help": "מיקומי האורות וכיווניהם משתמשים בקואורדינטות העולם. סיבוב המצלמה סביב המודל אינו מזיז את האורות.",
|
||||
"render.light_temperature": "טמפרטורת צבע",
|
||||
"render.light_temperature_help": "גוון גוף שחור משוער; נשמר כ-RGB לינארי.",
|
||||
"render.light_type": "סוג",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "שומר מאסטר RGBA בנקודה צפה של 32 סיביות לפני הסרת רעש, חשיפה, מיפוי גוונים, מסננים ורקע. sRGB/D65 ליניארי; מוכפל מראש בכיסוי. זיכרון נוסף מאושר במפורש. ללא דחיסה או מעברים נוספים.",
|
||||
"render.linear_retain": "שמירת EXR ליניארי גולמי",
|
||||
"render.linear_unavailable": "לפריים הגמור שנבחר אין EXR שמור. יש להפעיל שמירה לפני ההדמיה; PNG ישן אינו יכול לשחזר קרינה גולמית.",
|
||||
"render.live.adaptive": "חי · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "בקשת ההדמיה השתנתה בזמן עצירת ההדמיה החיה. יש להתחיל את התמונה הסופית מחדש.",
|
||||
"render.live.final_waiting": "מכין תמונה סופית · משחרר משאבי הדמיה חיה",
|
||||
"render.live.invalid_size": "הדמיה חיה דורשת רוחב וגובה פלט גדולים מאפס.",
|
||||
"render.live.pause": "השהיית הדמיה חיה",
|
||||
"render.live.paused": "חי · מושהה",
|
||||
"render.live.paused_outdated": "חי · מושהה · לא מעודכן",
|
||||
"render.live.quality_help": "איכות אינטראקטיבית: לכל היותר 512 פיקסלים בצד הארוך. משתמשת במנוע ההדמיה שנבחר ובאפקטים הנתמכים בו. רזולוציית התמונה הסופית, ההגדרות והייצוא נשארים נפרדים.",
|
||||
"render.live.refused": "הדמיה חיה אינה זמינה: {reason}",
|
||||
"render.live.resume": "חידוש הדמיה חיה",
|
||||
"render.live.samples": "חי · {width} × {height} · {current}/{target} דגימות",
|
||||
"render.live.source_current": "{method} · חי",
|
||||
"render.live.source_outdated": "{method} · תמונה קודמת",
|
||||
"render.live.source_outdated_help": "מציג את התמונה האחרונה שנלכדה בזמן שההדמיה החיה מתעדכנת או אינה זמינה. היא עשויה להיות שונה מהסצנה או מהגדרות התמונה הנוכחיות.",
|
||||
"render.live.source_settings_help": "יש לפתוח את חלונית הפלט כדי לערוך את מנוע ההדמיה ואת הגדרות התמונה.",
|
||||
"render.live.stopped": "ההדמיה החיה נעצרה",
|
||||
"render.live.updating": "חי · איכות אינטראקטיבית (עד 512 פיקסלים) · מתעדכן",
|
||||
"render.live.waiting_canvas": "ממתין למשטח ההדמיה",
|
||||
"render.live.worker_stopped": "תהליך הלכידה של ההדמיה החיה נעצר לפני שהחזיר תוצאה",
|
||||
"render.look_add": "הוספת מראה צבע",
|
||||
"render.look_applied": "{name} הוחל",
|
||||
"render.look_bleach": "בהשראת עקיפת הלבנה",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "ציפוי דיאלקטרי להמחשה מעל המראה האטום הקיים. משקל אפס משאיר את הבסיס ללא שינוי.",
|
||||
"render.material_color_map": "תמונת צבע בסיס (sRGB)",
|
||||
"render.material_color_replace": "מפת הבסיס מחליפה את צבע הגוף",
|
||||
"render.material_cpu_glass_only": "מפות זמניות מקובצי תמונה דורשות GPU. ב־CPU אפשר להשתמש במפות משטח מובנות שנשמרו.",
|
||||
"render.material_cpu_optics_limits": "יש להפעיל תאורה עקיפה לצורות זכוכית סגורות נתמכות, לקינון שנבדק ולפיזור בתוך הזכוכית. יריעות דקות יכולות להעביר אור לצללים. אחרת יש להשתמש ביריעה מישורית או בתיבה מקבילה לצירים. צורות נוגעות וצללים העוברים בזכוכית סגורה עדיין אינם נתמכים. הערכים נשמרים.",
|
||||
"render.material_cpu_saved_runtime_conflict": "אי אפשר לשלב הגדרות אופטיות שמורות ב־CPU עם הקישורים האופטיים הזמניים הישנים. יש לשמור את ההגדרות שנבדקו או להסיר את הקישור הזמני המתנגש.",
|
||||
"render.material_custom": "אופטיקה מותאמת להמחשה",
|
||||
"render.material_enable": "שימוש בדריסות חומר GPU אלה",
|
||||
"render.material_filter": "סינון גופי הסצנה…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "תמונת נורמל (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "עוצמת הנורמל (0–1)",
|
||||
"render.material_origin": "ראשית המיפוי X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "זכוכית פיזיקלית דורשת עקיבת קרניים ב־CPU או עקיבת נתיבים ב־GPU.",
|
||||
"render.material_refresh": "רענון גופי הסצנה",
|
||||
"render.material_refresh_needed": "הסצנה השתנתה. יש לרענן את רשימת הגופים לפני הוספת יעד.",
|
||||
"render.material_remove": "הסרת ההקצאה הזאת",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "הקצאה זו שייכת לגרסה קודמת של הסצנה. יש לאמת מחדש את זהות הגוף המדויקת שלה לפני ההדמיה.",
|
||||
"render.material_roughness": "חספוס הציפוי (0.045–1)",
|
||||
"render.material_roughness_map": "מפת חספוס (ליניארית)",
|
||||
"render.material_saved_edit_pending": "השינויים האופטיים האלה לא נשמרו. יש לשמור את הערכים שנבדקו, להסיר את ההקצאה השמורה או לבטל את השינויים. קישורים זמניים ישנים ואפקטים שאינם נתמכים אינם יכולים לעקוף את היעד השמור.",
|
||||
"render.material_shadow": "העברת קרני צל ישרות",
|
||||
"render.material_target_missing": "המספר הסידורי והשם של הגוף כבר אינם תואמים. יש להסיר את ההקצאה הזאת ולבחור שוב במפורש את הגוף הרצוי.",
|
||||
"render.material_thickness": "עובי יריעה דקה (mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "מעקב קרניים ב־CPU",
|
||||
"render.method_gpu": "מעקב מסלולים ב־GPU",
|
||||
"render.method_limit_raster": "רסטר משתמש בתצוגה המקדימה הקבועה של הסטודיו האטום. לפקדים האופטיים הנתמכים יש לבחור שיטת מעקב קרניים מפורשת.",
|
||||
"render.method_raster": "רסטר ב־CPU (סכמטי)",
|
||||
"render.method_raster": "רסטר ב־CPU",
|
||||
"render.navigation_help": "המצלמה נעה כמו בתכן (העדפות › ניווט). שמירת ההגדרה מתעדת את המצלמה הנוכחית.",
|
||||
"render.nested_enable": "דיאלקטריים מקוננים מאושרים",
|
||||
"render.nested_help": "עד 16 שכבות מכילות; כל הורה חייב להיות קמור וכל צאצא חייב להיות בתוכו לגמרי. חיתוכים, משטחים נוגעים, יריעות דקות והעברת צל ישרה נדחים. מצב מצלמה בפנים עוקב אחר ההיררכיה המאושרת. אינו יוצר משטח נוזל ואינו ממזג גבולות CAD נוגעים.",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "הגדרת פלט EXR אינה זמינה כאן. ייצוא EXR לינארי דורש תמונת GPU שהושלמה עם מאסטר לינארי שנשמר.",
|
||||
"render.output_fit": "התאם",
|
||||
"render.output_format": "פורמט",
|
||||
"render.output_format_status": "PNG הוא תמונת התצוגה הגמורה המדויקת. EXR אופציונלי ב־CPU או ב־GPU שומר קרינה גולמית ליניארית של הסצנה ב־FLOAT32 לפני מסנני התצוגה. הייצוא לעולם אינו מחליף אותה במטמון התצוגה המקדימה או במשימה אחרת.",
|
||||
"render.output_format_status": "PNG הוא תמונת התצוגה הגמורה המדויקת. EXR אופציונלי ב־GPU שומר קרינה גולמית ליניארית של הסצנה ב־FLOAT32 לפני מסנני התצוגה. הייצוא לעולם אינו מחליף אותה במטמון התצוגה המקדימה או במשימה אחרת.",
|
||||
"render.output_grading": "גריידינג",
|
||||
"render.output_image": "תמונה",
|
||||
"render.output_largest": "מקס׳ {width} × {height}",
|
||||
"render.output_lock_ratio": "נעילת יחס הממדים",
|
||||
"render.output_master_unavailable": "כדי לייצא EXR, יש לבחור תמונה שהושלמה ומכילה נתוני EXR גולמיים. יש להפעיל EXR גולמי לפני רינדור תמונות חדשות.",
|
||||
"render.output_over_budget": "התמונה המבוקשת חורגת ממעטפת המשאבים שנבחרה. יש לבחור במפורש גודל או פרופיל מתאימים; אין הורדה אוטומטית.",
|
||||
"render.output_presentation_limits": "אפקטים של תמונה ב־CPU ופלט EXR תומכים בתמונות ברוחב ובגובה של עד 8192 פיקסלים, ועד 16,777,216 פיקסלים בסך הכול.",
|
||||
"render.output_presets_help": "פורמט, איכות ורשימת הבדיקה נמצאים בעורך הפלט.",
|
||||
"render.output_quality": "איכות",
|
||||
"render.output_quality_waiting": "ממתין לחוזה האיכות",
|
||||
"render.output_ratio_refused": "הממד המקושר חורג ממגבלת הגודל של השיטה הזאת. הגודל הקודם נשמר; יש לבטל את נעילת היחס או לבחור ערך קטן יותר.",
|
||||
"render.output_raytrace_only": "אפקטים של תמונה דורשים עקיבת קרניים ב־CPU או עקיבת נתיבים ב־GPU.",
|
||||
"render.output_ready": "מוכן לרינדור",
|
||||
"render.output_run_check": "הפעל בדיקה",
|
||||
"render.output_size_limit": "{method}: עד {dimension} פיקסלים לצלע ו-{pixels} פיקסלים. הגודל הגדול ביותר שמתאים: {width} × {height}.",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "רוויה",
|
||||
"render.post_title": "מסנני תצוגה",
|
||||
"render.post_vignette": "הצללת שוליים",
|
||||
"render.preparation_cancelled": "הכנת הרינדור בוטלה",
|
||||
"render.preparation_checking": "בודק רינדור",
|
||||
"render.preparation_retirement_failed": "ניקוי משאבי הכנת הרינדור נכשל. לא ניתן להכין תמונה נוספת בחלון זה.",
|
||||
"render.preparation_retiring": "מסיים את הכנת הרינדור",
|
||||
"render.preparation_unavailable": "הכנת הרינדור אינה זמינה",
|
||||
"render.presentation_banner": "אפקטים של תצוגת התמונה פועלים בעקיבת קרניים ב־CPU וב־GPU. פתיחת דף זה לעולם אינה משנה את השיטה.",
|
||||
"render.preview": "תצוגה מקדימה מובנית",
|
||||
"render.preview_help": "הצללת רסטר ושקיפות ממוזגת. שבירת אור, HDRI והדמיה סופית עדיין אינם זמינים.",
|
||||
"render.preview_image_settings_help": "התצוגה המקדימה החיה שומרת על תאורת סטודיו קבועה. החשיפה ותאורת התמונה חלות על משימת התמונה בעת בדיקה או התחלה.",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "אישור ההשלכה בתקציב קבוע וההשמטות",
|
||||
"render.quality_apply": "שימוש בתקציב הדגימה הקבוע הזה",
|
||||
"render.quality_balanced": "מאוזן",
|
||||
"render.quality_budget_help": "קובע גודל, דגימות, עומק, זרע והפחתת רעש. הגדרות התמונה האחרות נשמרות.",
|
||||
"render.quality_custom": "מותאם",
|
||||
"render.quality_denoise_off": "הפחתת רעש: כבויה",
|
||||
"render.quality_denoise_on": "הפחתת רעש: פעילה",
|
||||
"render.quality_draft": "טיוטה",
|
||||
"render.quality_high": "גבוהה",
|
||||
"render.quality_projection_help": "בדיקה זו משנה רק רזולוציה, תקציב דגימות, עומק נתיב, זרע והפחתת רעש. ההגדרות האחרות נשמרות; התקציב אינו מעיד על התכנסות התמונה.",
|
||||
"render.quality_projection_help": "פרופילי Q מוכנים כוללים דרישות עתידיות. פעולה זו משליכה רק תקציב PNG נתמך בדגימות קבועות, ומשמיטה במפורש עצירה אדפטיבית, פלט ליניארי גולמי ומגבלות העברה עצמאיות.",
|
||||
"render.quality_review": "סקירת תקציב הדגימה",
|
||||
"render.quality_summary": "{width} × {height} · {samples} דגימות · עומק {depth} · זרע {seed} · {denoise}",
|
||||
"render.queue_pending": "בממשק זה אין תור הפקה אוטומטי ואין מנוע הדמיה לאנימציה. תמונות סטילס גמורות נשארות ניתנות לייצוא כל אחת בנפרד.",
|
||||
"render.radial_assign": "הקצה מראה",
|
||||
"render.radial_compare": "השוואה",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "הזזה וזום משתמשים בהעדפות הניווט. רווח + גרירה מזיזים.",
|
||||
"render.result_open": "פתיחת התמונה הגמורה בקנבס הראשי.",
|
||||
"render.result_preview": "תצוגה מקדימה חיה",
|
||||
"render.result_provisional": "תמונה זמנית",
|
||||
"render.result_provisional": "תמונת CPU זמנית",
|
||||
"render.result_provisional_help": "נקודת ביקורת זו אינה ניתנת לייצוא. הייצוא שומר את התוצאה הגמורה האחרונה; ביטול חוזר לתוצאה הזאת.",
|
||||
"render.result_reduced_display": "התצוגה המקדימה הוקטנה למגבלת המכשיר או המטמון של אזור התצוגה. ה־PNG השמור והייצוא שומרים על רזולוציה מלאה; בדיקה ברזולוציה המקורית (100%) אינה זמינה בתצוגה המוקטנת הזאת.",
|
||||
"render.result_retained": "מוצגת התמונה הגמורה האחרונה; הדמיה חדשה יותר לא החליפה אותה.",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "מסגר נושא",
|
||||
"render.shot_invalid_camera": "ערכי המצלמה חייבים להיות סופיים ובטווח.",
|
||||
"render.shot_lens": "עדשה",
|
||||
"render.shot_lens_cpu_progressive_reason": "יש להפעיל דגימה הדרגתית בחלונית הדגימה לפני הפעלת עומק שדה ב־CPU.",
|
||||
"render.shot_lens_enable_reason": "הפעל עומק שדה כדי לערוך צמצם ומרחק מיקוד.",
|
||||
"render.shot_lens_gpu_reason": "עומק שדה דורש מעקב נתיב ב-GPU.",
|
||||
"render.shot_lens_perspective_reason": "עומק שדה דורש היטל פרספקטיבי.",
|
||||
"render.shot_lens_raytrace_reason": "עומק שדה זמין בעקיבת קרניים ב־CPU ובעקיבת נתיבים ב־GPU.",
|
||||
"render.shot_look_selection": "הבט אל הבחירה",
|
||||
"render.shot_manager": "צילומים שמורים",
|
||||
"render.shot_new_name": "שם הצילום החדש",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "אופק · RGB ליניארי",
|
||||
"render.sky_zenith": "זנית · RGB ליניארי",
|
||||
"render.soft_focus": "מיקוד רך",
|
||||
"render.spot_axis": "כיוון פליטת האור",
|
||||
"render.spot_cone": "חרוט זרקור",
|
||||
"render.spot_gpu_help": "הזוויות נמדדות מכיוון פליטת האור. האור דועך בין הזווית הפנימית לחיצונית. טווחי דעיכה צרים מאוד דורשים רינדור באמצעות CPU.",
|
||||
"render.spot_help": "הזוויות נמדדות מכיוון פליטת האור. האור דועך בין הזווית הפנימית לחיצונית.",
|
||||
"render.spot_inner": "חצי זווית פנימית",
|
||||
"render.spot_outer": "חצי זווית חיצונית",
|
||||
"render.stage_frame": "מסגרת",
|
||||
"render.stage_layout.appearance_note": "לחץ להחלה על הבחירה.",
|
||||
"render.stage_layout.studio_note": "לחץ לסקירה לפני ההחלה.",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "הכנת תוכנית יצירה",
|
||||
"render.stage_panels.projects": "מקרין",
|
||||
"render.stage_panels.quality_ack": "ידוע לי על התקציב הקבוע והפרטים המושמטים בו",
|
||||
"render.stage_panels.quality_omissions": "תקציב זה אינו קובע יעדי שגיאה אדפטיביים, מגבלות מעבר אור נפרדות, כללי רולטה רוסית או שמירת מקור גולמי, ואינו מבטיח התכנסות של התמונה.",
|
||||
"render.stage_panels.quality_omissions": "עצירה מסתגלת ויעדי שגיאה, עומק העברה נפרד, כללי רולטה רוסית, פלט ליניארי גולמי, קובץ מקור EXR ואישור התכנסות.",
|
||||
"render.stage_panels.reference": "ייחוס",
|
||||
"render.stage_panels.references": "ייחוסים",
|
||||
"render.stage_panels.remove_optics": "הסרת האופטיקה",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "סביבת HDR מקורית · הסצנה כולה",
|
||||
"render.stage_panels.subtitle_maps": "מפות מרחביות מעוצבות · תצוגה מקדימה זמנית",
|
||||
"render.stage_panels.subtitle_physical": "תוכנית לקריאה בלבד · לא נוצר או משויך חומר",
|
||||
"render.stage_panels.subtitle_quality": "תקציב דגימה ב־GPU · השדות המפורטים בלבד",
|
||||
"render.stage_panels.subtitle_quality": "תקציב GPU קבוע · דרישות עתידיות מושמטות",
|
||||
"render.stage_panels.subtitle_rig": "מערך תאורה מעוצב · פולטי אור וכרטיסים סבילים",
|
||||
"render.stage_panels.subtitle_saved_maps": "מפות מעוצבות · נשמרות עם צילום זה",
|
||||
"render.stage_panels.subtitle_stage": "קרקע ובמה פיזיות · מותאמות למוצר",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "עד ארבעה מלבנים חד־צדדיים. חצאי הצלעות הניצבים U ו־V קובעים את הגודל; U × V פונה אל הצד המואר. הקואורדינטות במילימטרים של מרחב העולם.",
|
||||
"render.studio_center": "מרכז X, Y, Z (mm)",
|
||||
"render.studio_denoise": "הפחתת רעש בתמונה הסופית",
|
||||
"render.studio_denoise_help": "תמונות הדרגתיות וסופיות משתמשות במסנן שומר קצוות; פרטים עדינים עלולים להתרכך. זכוכית ומשטחים בעלי החזר חד אינם עוברים סינון.",
|
||||
"render.studio_denoise_help": "מסנן סופי שמתחשב בקצוות; פרטים עדינים עלולים להתרכך. זכוכית ומשטחים ספקולריים חדים עוקפים את המסנן.",
|
||||
"render.studio_direction": "כיוון אל השמש (X, Y, Z)",
|
||||
"render.studio_environment": "סביבה",
|
||||
"render.studio_environment_bounds": "סיבוב הסביבה חייב להיות סופי בטווח ±1,000,000 רדיאנים והעוצמה בין 0 ל־100.",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "שימוש בתצוגה המקדימה המובנית",
|
||||
"render.volume_albedo": "אלבדו פיזור · RGB ליניארי",
|
||||
"render.volume_anisotropy": "קוסינוס ממוצע של פונקציית הפאזה",
|
||||
"render.volume_denoise_required": "יש לכבות הפחתת רעש כדי להשתמש באטמוספרה.",
|
||||
"render.volume_enable": "הפעלת תווך משתתף",
|
||||
"render.volume_extinction": "מקדם הכחדה",
|
||||
"render.volume_glass_required": "לא ניתן לשלב אטמוספרה וזכוכית בהגדרה זו.",
|
||||
"render.volume_help": "העברה ב־GPU בתוך תיבה סופית אחת, עם הנחתת בר ופיזור HG במסלולי המצלמה ובמסלולים עקיפים. משתמש בתאורת סטודיו, HDR או שמיים מפורשת, כולל הנחתת צל. קוסינוס פאזה חיובי מפזר קדימה. יש לבטל במפורש גבולות זכוכית והסרת רעש משטחית. הכחדה אפס משאירה את ההעברה ללא שינוי. אינו אטמוספרה ירחית פיזיקלית, אינו רשת צפיפות, אינו פליטה נפחית, אינו העברה ספקטרלית ואינו תכונת חומר מאושרת.",
|
||||
"render.volume_lighting_required": "יש לבחור סביבה או מערך תאורה מפורש כדי לרנדר אטמוספרה.",
|
||||
"render.volume_max": "גבולות מרביים · mm בעולם",
|
||||
"render.volume_min": "גבולות מזעריים · mm בעולם",
|
||||
"render.volume_single_stratum_required": "יש להשתמש בדגימת מעבר אחת כדי לרנדר אטמוספרה.",
|
||||
"render.volume_title": "נפח הומוגני תחום",
|
||||
"render.wait_add_light": "לא נתמך עדיין: יצירת אורות בעלי שם",
|
||||
"render.wait_batch": "לא נתמך עדיין: תור רינדור של צילומים שמורים",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "המידה הוחלה",
|
||||
"sketch.dimension_applied_defined": "המידה הוחלה; הפרמטר `{name}` הוגדר",
|
||||
"sketch.dimension_applied_renamed": "המידה הוחלה; שם הפרמטר הוא כעת `{name}`",
|
||||
"sketch.divide_hint": "הנקודות מחלקות כל עקומה לאורכים שווים ושומרות עליהם כשהיא משתנה.",
|
||||
"sketch.dof_left": "נותרו {count} דרגות חופש",
|
||||
"sketch.dof_left_one": "נותרה {count} דרגת חופש",
|
||||
"sketch.draw_curves": "שרטוט כ־{linetype}",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "עדינה",
|
||||
"ui.finest": "העדינה ביותר",
|
||||
"ui.finish_sketch": "סיום סקיצה",
|
||||
"ui.first_dimension_scales": "המידה הראשונה משנה את קנה המידה של הציור",
|
||||
"ui.first_dimension_scales_tip": "פועל: מידת האורך הראשונה בסקיצה משנה את קנה המידה של הציור שהיא מודדת, כך שצורתו נשמרת. כבוי: המידה משנה רק את גודל מה שהיא מודדת.",
|
||||
"ui.fit": "התאמה לחלון",
|
||||
"ui.fix_unfix": "קיבוע/שחרור",
|
||||
"ui.flat": "שטוח",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "ניווט תצוגה מותאם אישית",
|
||||
"ui.heading_cylinder": "גליל",
|
||||
"ui.heading_design": "תכן",
|
||||
"ui.heading_divide_curve": "חלוקת עקומה",
|
||||
"ui.heading_draft": "שיפוע יציקה",
|
||||
"ui.heading_edit": "עריכה",
|
||||
"ui.heading_equation_curve": "עקומת משוואה",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "הצגת כפתור ההפעלה",
|
||||
"ui.history_play_finish_action": "יש לסיים את הפעולה הנוכחית לפני הפעלת ציר הזמן",
|
||||
"ui.history_play_roll_back": "יש להזיז את סמן ציר הזמן לאחור לפני ההפעלה",
|
||||
"ui.history_scope_all": "הצגת הפיצ'רים של כל התכן",
|
||||
"ui.history_scope_all_shown": "הפיצ'רים של כל התכן",
|
||||
"ui.history_scope_pick": "יש לערוך רכיב, או לבחור אחד בעץ התכן, כדי להציג רק את הפיצ'רים שלו",
|
||||
"ui.history_scope_show": "הצגת הפיצ'רים של {name} ושל הרכיבים שבתוכו בלבד",
|
||||
"ui.history_scope_shown": "הפיצ'רים של {name} ושל הרכיבים שבתוכו",
|
||||
"ui.history_show_hidden": "הצג רכיבים מוסתרים",
|
||||
"ui.history_show_names": "הצגת שמות",
|
||||
"ui.history_sketch_listed_under": "מופיעה תחת {feature}",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "אין בקובץ צבעים",
|
||||
"ui.point_cloud_pause": "השהיה",
|
||||
"ui.point_cloud_play": "הפעלה",
|
||||
"ui.point_cloud_reload": "טעינה מחדש מהקובץ",
|
||||
"ui.point_cloud_single_color": "צבע אחיד",
|
||||
"ui.point_cloud_size": "גודל נקודה",
|
||||
"ui.point_cloud_units": "יחידות הקובץ",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "מסילה שנייה",
|
||||
"ui.section_analysis": "ניתוח חתך",
|
||||
"ui.section_size": "גודל החתך",
|
||||
"ui.segments": "מקטעים",
|
||||
"ui.select": "בחירה",
|
||||
"ui.selectOther": "בחירת אחר…",
|
||||
"ui.selectTangentChain": "בחירת שרשרת משיקה",
|
||||
|
||||
@@ -567,7 +567,6 @@
|
||||
"Select edges": "Seleziona spigoli",
|
||||
"Select points of the sketch being edited": "Seleziona punti dello schizzo in modifica",
|
||||
"Select the centre the pattern turns around first": "Seleziona prima il centro attorno a cui ruota la serie",
|
||||
"Select the curves to divide first": "Selezionare prima le curve da dividere",
|
||||
"Select the curves to offset first": "Seleziona prima le curve per l'offset",
|
||||
"Select the mirror line": "Seleziona la linea di simmetria",
|
||||
"Select the sketch curves or points to mirror first": "Seleziona prima le curve o i punti dello schizzo da specchiare",
|
||||
@@ -1195,7 +1194,6 @@
|
||||
"cmd.sketch_options": "Opzioni della tavolozza schizzo",
|
||||
"cmd.sketch_paste": "Incolla entità dello schizzo",
|
||||
"cmd.sketch_point3d": "Punto 3D",
|
||||
"cmd.sketch_point_divide": "Dividi curva",
|
||||
"cmd.sketch_polygon_circumscribed": "Poligono circoscritto",
|
||||
"cmd.sketch_polygon_edge": "Poligono per lato",
|
||||
"cmd.sketch_polygon_inscribed": "Poligono inscritto",
|
||||
@@ -1639,7 +1637,6 @@
|
||||
"help.sketch.pattern.circular": "Copia la geometria dello schizzo attorno a un punto centrale, su un cerchio completo o su un angolo stabilito. Usalo per cerchi di bulloni, raggi e denti.",
|
||||
"help.sketch.pattern.rectangular": "Copia la geometria dello schizzo in righe e colonne con spaziature stabilite. Usalo per griglie di fori, asole o prese d'aria.",
|
||||
"help.sketch.point": "Colloca un punto dello schizzo. Usalo come riferimento per quote, vincoli, centri di fori o lavorazioni successive.",
|
||||
"help.sketch.point.divide": "Posiziona punti che dividono le curve scelte in lunghezze uguali. I punti restano su ogni curva e ne mantengono la spaziatura quando cambia.",
|
||||
"help.sketch.point3d": "Colloca un punto nello spazio, con Schizzo 3D attivo. Usalo come riferimento per curve e percorsi 3D.",
|
||||
"help.sketch.polygon.circumscribed": "Disegna un poligono regolare attorno a un cerchio, dimensionato dal centro al punto medio di un lato. Usalo quando è nota l'apertura chiave, come per un dado esagonale.",
|
||||
"help.sketch.polygon.edge": "Disegna un poligono regolare a partire dai due estremi di un lato. Usalo quando sono note la lunghezza e la posizione di un lato.",
|
||||
@@ -3905,7 +3902,6 @@
|
||||
"sketch.dimension_applied": "Quota applicata",
|
||||
"sketch.dimension_applied_defined": "Quota applicata; parametro `{name}` definito",
|
||||
"sketch.dimension_applied_renamed": "Quota applicata; parametro denominato `{name}`",
|
||||
"sketch.divide_hint": "I punti dividono ogni curva in lunghezze uguali e le mantengono quando cambia.",
|
||||
"sketch.dof_left": "{count} gradi di libertà rimanenti",
|
||||
"sketch.dof_left_one": "{count} grado di libertà rimanente",
|
||||
"sketch.draw_curves": "Disegna come {linetype}",
|
||||
@@ -4695,7 +4691,6 @@
|
||||
"ui.heading_create_sketch": "CREA SCHIZZO",
|
||||
"ui.heading_cylinder": "CILINDRO",
|
||||
"ui.heading_design": "PROGETTAZIONE",
|
||||
"ui.heading_divide_curve": "DIVIDI CURVA",
|
||||
"ui.heading_draft": "SFORMO",
|
||||
"ui.heading_edit": "MODIFICA",
|
||||
"ui.heading_equation_curve": "CURVA DA EQUAZIONE",
|
||||
@@ -5034,7 +5029,6 @@
|
||||
"ui.second_rail": "Seconda guida",
|
||||
"ui.section_analysis": "Analisi in sezione",
|
||||
"ui.section_size": "Dimensione della sezione",
|
||||
"ui.segments": "Segmenti",
|
||||
"ui.select": "Seleziona",
|
||||
"ui.selectOther": "Seleziona altro…",
|
||||
"ui.selectTangentChain": "Seleziona catena tangente",
|
||||
|
||||
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "展開サイズ X",
|
||||
"Flat size Y": "展開サイズ Y",
|
||||
"Flipped": "反転",
|
||||
"Fold Features": "フィーチャーを折りたたむ",
|
||||
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Follow inherited appearance": "継承された外観に従う",
|
||||
"Follow material": "材料に従う",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "ガラス - スモーク",
|
||||
"Grid": "グリッド",
|
||||
"Groove": "溝",
|
||||
"Group Features": "フィーチャーをグループ化",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "選択後に HDR ファイルが変更されました。保存する前に確認して再選択してください",
|
||||
"HISTORY": "履歴",
|
||||
"Halo under sketch curves": "スケッチ曲線の下に縁取りを表示",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "断面の位置",
|
||||
"Sections": "セクション",
|
||||
"Sections (one sketch each)": "セクション (各1スケッチ)",
|
||||
"Select Features": "フィーチャーを選択",
|
||||
"Select Other": "その他を選択",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。中ボタンをダブルクリックするとホームビューに戻ります(環境設定 › ナビゲーション)。",
|
||||
"Select Same Look": "同じ外観を選択",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "エッジを選択",
|
||||
"Select points of the sketch being edited": "編集中のスケッチの点を選択してください",
|
||||
"Select the centre the pattern turns around first": "パターンの回転中心を先に選択してください",
|
||||
"Select the curves to divide first": "先に分割する曲線を選択してください",
|
||||
"Select the curves to offset first": "オフセットする曲線を先に選択してください",
|
||||
"Select the mirror line": "ミラー線を選択してください",
|
||||
"Select the sketch curves or points to mirror first": "ミラーするスケッチの曲線または点を先に選択してください",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "板金",
|
||||
"Sheet Rules": "板金ルール",
|
||||
"Show Agent Cursor": "エージェントカーソルを表示",
|
||||
"Show Each Feature": "各フィーチャーを表示",
|
||||
"Show ghost": "ゴーストを表示",
|
||||
"Show in folder": "フォルダに表示",
|
||||
"Show the pointer an AI agent moves (#35)": "エージェントが操作するポインターを表示 (#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "点群を編集",
|
||||
"cmd.pointcloud_insert": "点群を挿入",
|
||||
"cmd.pointcloud_list": "点群",
|
||||
"cmd.pointcloud_reload": "点群を再読み込み",
|
||||
"cmd.render.coat.bind": "クリアコートを保存",
|
||||
"cmd.render.coat.unbind": "クリアコートを削除",
|
||||
"cmd.render.compare.divider": "レンダー比較の境界を移動",
|
||||
"cmd.render.result.discard": "レンダリング結果を破棄",
|
||||
"cmd.render.result.select": "レンダリング結果を選択",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "スケッチ パレット オプション",
|
||||
"cmd.sketch_paste": "スケッチ要素を貼り付け",
|
||||
"cmd.sketch_point3d": "3D 点",
|
||||
"cmd.sketch_point_divide": "曲線を分割",
|
||||
"cmd.sketch_polygon_circumscribed": "外接ポリゴン",
|
||||
"cmd.sketch_polygon_edge": "エッジ ポリゴン",
|
||||
"cmd.sketch_polygon_inscribed": "内接ポリゴン",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "Renderブラウザー",
|
||||
"cmd.view.render_dock": "レンダードックを移動",
|
||||
"cmd.view.render_focus": "Renderフォーカス表示",
|
||||
"cmd.view.render_live": "ライブレンダーを一時停止・再開",
|
||||
"cmd.view.status_details": "ビューポートの状態の詳細",
|
||||
"cmd.view.status_items": "ビューポートの状態項目",
|
||||
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "ファイルを読み込み中... {percent}%",
|
||||
"file_load.rebuilding": "フィーチャーを再構築中: {done}/{total}",
|
||||
"file_load.recovering": "デザインを復元中",
|
||||
"file_load.reloading_cloud": "点群を再読み込み中",
|
||||
"file_load.step_build": "モデルを構築中",
|
||||
"file_load.step_feature": "フィーチャー {done}/{total}",
|
||||
"file_load.step_part": "パーツ {done}/{total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "スケッチ ジオメトリを中心点まわりに、全周または指定角度でコピーします。ボルト円、スポーク、歯に使います。",
|
||||
"help.sketch.pattern.rectangular": "スケッチ ジオメトリを、指定した間隔で行と列にコピーします。穴、スロット、通気口の格子に使います。",
|
||||
"help.sketch.point": "スケッチ点を配置します。寸法、拘束、穴の中心、後のフィーチャの参照として使います。",
|
||||
"help.sketch.point.divide": "選択した曲線を等しい長さに分割する点を配置します。点は各曲線上にとどまり、曲線が変わっても間隔を保ちます。",
|
||||
"help.sketch.point3d": "3D スケッチをオンにして、空間内に点を配置します。3D 曲線やパスの参照として使います。",
|
||||
"help.sketch.polygon.circumscribed": "円の外側に接する正多角形を、中心から辺の中央までのサイズで描きます。六角ナットのように、二面幅が決まっているときに使います。",
|
||||
"help.sketch.polygon.edge": "1辺の両端から正多角形を描きます。辺の長さと位置が決まっているときに使います。",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt+左ドラッグで回転、Alt+中ドラッグでパン、Alt+右ドラッグでズーム。中ドラッグでもパン。",
|
||||
"nav.nx.help": "中ボタンのドラッグで回転、Shift+中ボタンで平行移動(NXの中ボタン+右ボタン)、Ctrl+中ボタンでズーム(NXの中ボタン+左ボタン)。右ボタンのドラッグではカメラは動きません",
|
||||
"nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。",
|
||||
"nav.orbit_cursor_wrap.help": "回転のドラッグがビューの端に達すると、カーソルが反対側の端へ移り、そのまま回転を続けます。オフ:ドラッグは画面の端で止まります。Wayland とブラウザーでは効果がありません。",
|
||||
"nav.orbit_cursor_wrap.label": "回転中にカーソルを反対側へ移す",
|
||||
"nav.perspective.sketch": "スケッチ編集中は平行投影で表示されます。透視投影に切り替えるにはスケッチを終了してください。",
|
||||
"nav.right_drag_orbits.help": "オフ:右ドラッグではカメラは動かず、右ボタンはコンテキストメニューを開くだけです。マウス割り当てで SolidWorks を選ぶとオフになります。",
|
||||
"nav.right_drag_orbits.label": "右ドラッグで回転",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "ズームアウト時に拘束グリフを隠す",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "スケッチを十分にズームアウトすると、拘束グリフは表示されず画面を散らかしません。オフ: ズームに関係なく固定の画面サイズのままです。",
|
||||
"prefs.history": "履歴",
|
||||
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
|
||||
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
|
||||
"prefs.history_group_joints": "履歴でジョイントをまとめる",
|
||||
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
|
||||
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
|
||||
"prefs.history_origin_bodies_note": "左側またはブラウザー下の履歴の先頭に、アクティブなコンポーネントのボディフォルダーと原点を、ブラウザーと同じ表示切り替えとメニュー付きで表示します。下部の履歴は変わりません。",
|
||||
"prefs.history_play_button": "履歴コントロールに「履歴を再生」ボタンを表示",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "選択したものの寸法をビューの右下、履歴の上に表示します: エッジの長さ(円形のものは Ø と R)、面の面積、点の位置、2 つの項目の距離と角度、複数のエッジや面の合計。クリックでコピーします。",
|
||||
"prefs.remember_dialog_values": "コマンドダイアログの値を記憶",
|
||||
"prefs.remember_dialog_values.help": "既定ではオフです。オンにすると、コマンドが成功した後、SolidCraft を閉じるまで、同じコマンドを次に開いたときに安全な値とオプションを再利用します。",
|
||||
"prefs.render_legacy_image_defaults": "以前のレンダー画像の既定値を使用",
|
||||
"prefs.render_legacy_image_defaults_help": "以前の手動の画像ワークフローを使用します。新しいセットアップはCPUラスタ、CPUレイトレーシングは固定サンプリングとし、結果は手動で開きます。レンダーに入るたびにビューをフィットし、ショットを切り替えても保存カメラを復元せず、以前のフレーミングとナビゲーションを使用します。自動のライブ画像更新は無効にします。保存済みのセットアップは変更しません。",
|
||||
"prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用",
|
||||
"prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。",
|
||||
"prefs.reports_saved": "{n} 件保存済み",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "作業",
|
||||
"prefs.supersampling_brief": "大きく描画して縮小するため、非常に鮮明になりますが負荷も増えます。",
|
||||
"prefs.tab_accent_note": "アクセント色はアクティブなタブに従います",
|
||||
"prefs.tab_double_click_renames": "デザインのタブをダブルクリックして名前を変更",
|
||||
"prefs.tab_double_click_renames_note": "新しい名前を入力し、Enter キーを押すか別の場所をクリックすると確定し、Esc でキャンセルします。オフ: タブをダブルクリックすると、バーの空いている部分と同じようにウィンドウを最大化します。",
|
||||
"prefs.theme_file": "テーマファイル",
|
||||
"prefs.theme_file_or_paste": "ファイルまたは貼り付け",
|
||||
"prefs.theme_follow": "テーマファイルに従う",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "{body} に結合できませんでした。",
|
||||
"refused.repair": "パーツを修復",
|
||||
"refused.show_face": "面を表示",
|
||||
"render.adaptive.pilot_progress": "適応パイロット · {current}/{target} パス",
|
||||
"render.adaptive.preview_help": "パイロット画像は配分の決定に使われ、最終画像には含まれません。本処理はピクセルごとに別の予算を使うため、反復回数は全ピクセル共通のサンプル数ではありません。",
|
||||
"render.adaptive.production_progress": "適応本処理 · {current}/{target} 回",
|
||||
"render.adaptive_absolute": "RGB 絶対誤差の下限",
|
||||
"render.adaptive_coverage": "被覆率の誤差目標",
|
||||
"render.adaptive_enable": "独立した予備サンプルで配分",
|
||||
"render.adaptive_help": "独立したパイロットサンプルは破棄され、ピクセルごとに16以上、「ピクセルあたりのサンプル数」以下の本番サンプル数を決めます。CPUのリソース上限には両段階が含まれます。RGBとカバレッジの分散から配分し、上限に達したピクセルにはノイズが残ることがあります。最終レポートには実際のサンプル数を記録します。",
|
||||
"render.adaptive_help": "GPU 専用。独立した予備サンプルは破棄され、各ピクセルに最小16から設定上限までの本サンプルを配分します。RGB と被覆率の分散を目安にしますが、まれな経路を見逃したり上限でノイズが残ったりします。最終レポートは実際の処理量を示します。ノイズや時間を保証しません。",
|
||||
"render.adaptive_minimum": "最小本サンプル数",
|
||||
"render.adaptive_pending": "独立したパイロットによる適応配分はCPUとGPUで利用できます。条件付きフレネル層にはGPUが必要です。統計的な配分は収束を保証しません。",
|
||||
"render.adaptive_pending": "GPU の独立予備サンプルによる適応配分と条件付きフレネル層化が利用できます。収束の認証、まれな事象の保証、CPU 適応輸送は未対応です。",
|
||||
"render.adaptive_pilots": "ピクセルごとの予備サンプル",
|
||||
"render.adaptive_relative": "RGB 相対誤差の目標",
|
||||
"render.add_ground": "フィットした床をセットアップに追加",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "層の金属比率",
|
||||
"render.aging_origin": "フィールド原点 · ワールド mm",
|
||||
"render.aging_patina": "緑青",
|
||||
"render.aging_raytrace_help": "エイジングはCPUレイトレーシングとGPUパストレーシングで適用されます。ラスタは模式的な描画を維持します。",
|
||||
"render.aging_refresh_targets": "現在のボディを確認",
|
||||
"render.aging_roughness": "層の粗さ",
|
||||
"render.aging_rust": "鉄さび",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "閉じたソリッド",
|
||||
"render.appearance_editor.coat": "クリアコート",
|
||||
"render.appearance_editor.cpu_reason": "CPUレイトレーシングが必要です。",
|
||||
"render.appearance_editor.discard_saved_edit": "未保存の光学設定の変更を破棄",
|
||||
"render.appearance_editor.glass": "ガラスと吸収",
|
||||
"render.appearance_editor.glass_enable": "このボディに物理ガラスを使用",
|
||||
"render.appearance_editor.glass_note": "閉じたガラスは形状から距離を求めます。同じボディにクリアコートやマップを併用できません。",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "このボディの保存済み光学設定を置換",
|
||||
"render.appearance_editor.revalidate": "このボディを再検証",
|
||||
"render.appearance_editor.save": "この光学設定を保存",
|
||||
"render.appearance_editor.save_reason": "ガラスを保存するには、現在のCPUまたはGPUボディで有効にしてください。クリアコートは「コートをショットに保存」を使用します。ローカル画像ファイルは一時設定のままです。",
|
||||
"render.appearance_editor.save_reason": "保存には現在のGPUボディの物理ガラスが必要です。コートとローカル画像マップは一時設定のままです。",
|
||||
"render.appearance_editor.saved": "このショットに保存",
|
||||
"render.appearance_editor.saved_state": "この光学設定はこのショットに保存されています。",
|
||||
"render.appearance_editor.scatter_reason": "内部散乱には閉じたソリッド境界が必要です。",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "金属成分の割合",
|
||||
"render.base_opaque_only": "この編集では不透明ボディの意味を保持します。透過には明示的なガラス設定を使用してください。",
|
||||
"render.batch_all_shots": "すべてのショット ({count})",
|
||||
"render.batch_current_settings_help": "現在の画像設定と各保存済みショットのカメラで、1ショットずつレンダリングします。",
|
||||
"render.batch_empty": "すべてのショットをレンダリングする前にショットを保存してください。",
|
||||
"render.batch_limit": "一度にレンダリングできるショットは32個までです。",
|
||||
"render.batch_owner_limit": "保持できるドキュメントのショットキューは最大32個です。既存のキューを完了するかキャンセルしてください。",
|
||||
"render.batch_preparing": "次のショットを準備中",
|
||||
"render.batch_status": "{running} 実行中 · {waiting} 待機中",
|
||||
"render.batch_waiting_worker": "レンダリングの準備待ち",
|
||||
"render.bloom_enable": "表示ブルーム",
|
||||
"render.bloom_help": "正の線形ハイライトを3回の正規化フィルターで広げる表示専用の効果です。半径は出力ピクセル単位。sRGB変換前に加算され、レンズ・体積・収束の改善ではありません。生EXRは変わりません。",
|
||||
"render.bloom_opaque": "ブルームには不透明な出力が必要です。透明PNGを暗黙に統合せず拒否します。",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "塗装",
|
||||
"render.category_plastic": "プラスチック",
|
||||
"render.category_wood": "木材",
|
||||
"render.caustics_depth": "集光経路の深さ",
|
||||
"render.caustics_enable": "点光源・平行光源によるガラスの集光",
|
||||
"render.caustics_help": "光子数はノイズと計算量を調整します。収集半径を広げると細部より滑らかさを優先します。CPUでは間接照明が必要です。",
|
||||
"render.caustics_path_required": "CPUの集光には間接照明が必要です。",
|
||||
"render.caustics_photons": "光子数",
|
||||
"render.caustics_radius": "収集半径",
|
||||
"render.check_glass_cpu": "CPUは検証済みの平面ガラス薄板と軸に平行な箱に対応します。一般の閉じたメッシュ、入れ子、内部散乱には間接照明が必要です。閉じたガラスを通る透過影には未対応です。",
|
||||
"render.check_glass_gpu": "GPUは検証済みの分離したガラス、または明示的に有効にした検証済みの入れ子に対応します。入れ子には凸形状の親、厳密に内包された子、接触しない境界が必要です。",
|
||||
"render.check_media_remaining": "密度グリッド、変位、完全なスペクトル積分、偏光状態の伝播は未対応です。",
|
||||
"render.check_media_scope": "有効な内部散乱には閉じたガラス媒質を使い、直線の影透過とノイズ除去をオフにします。GPUではフレネル層を1つにします。有効な外部の大気はガラスや表面ノイズ除去と併用できません。",
|
||||
"render.check_opaque": "透明度:不透明サーフェスのみ。背景アルファに対応。",
|
||||
"render.check_sorted_alpha": "透明度:ソート済みアルファによる近似。",
|
||||
"render.check_transmission": "透明度:物理透過。",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "リクエストJSONをコピー",
|
||||
"render.children.coverage_error": "カバレッジ誤差",
|
||||
"render.children.cpu": "CPUレイトレーシング",
|
||||
"render.children.denoise": "画像のノイズを低減",
|
||||
"render.children.denoise": "完成画像のノイズを低減",
|
||||
"render.children.depth": "パス深度",
|
||||
"render.children.distortion": "歪み",
|
||||
"render.children.edge_samples": "エッジサンプル",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "まず外観ライブラリで外観を選択",
|
||||
"render.chromatic_offset": "色収差のずれ",
|
||||
"render.clear_stage": "保存済みステージを削除",
|
||||
"render.coat_discard": "コートの編集を破棄",
|
||||
"render.coat_pending_edit": "クリアコートの変更は未保存です。ショットに保存するか破棄してください。",
|
||||
"render.coat_remove": "保存済みコートを削除",
|
||||
"render.coat_remove_note": "この対象のコートをショットから削除します。形状、外観、物理材料は変更されません。",
|
||||
"render.coat_remove_title": "保存済みクリアコートを削除しますか?",
|
||||
"render.coat_revalidate_saved": "保存済みコートが変更されています。この編集を破棄し、対象を開き直してから編集してください。",
|
||||
"render.coat_save": "コートをショットに保存",
|
||||
"render.coat_saved_help": "クリアコートは不透明な表面に光沢のある層を追加します。ショットに保存してください。同じ対象にガラスは使用できません。CPUのカスタム反射や異方性の上書きも、この対象と併用できません。",
|
||||
"render.compare_divider": "比較の境界",
|
||||
"render.compare_live": "ライブ",
|
||||
"render.compare_live_preview": "ライブプレビュー",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "三角形数の上限",
|
||||
"render.cpu_budget_workstation": "ワークステーション(大規模シーン)",
|
||||
"render.cpu_captured_hdr": "この画像で使用したHDR",
|
||||
"render.cpu_celestial_adopt": "間接照明付き CPU レイトレーシング · パス深度 {depth} · {spp} SPP · 追跡シャドウ · シード {seed} · プライマリサンプル1個",
|
||||
"render.cpu_celestial_adopt_label": "間接照明付き CPU レイトレーシング",
|
||||
"render.cpu_celestial_path_required": "有限の太陽とグラデーション空は CPU の間接照明を使用します。",
|
||||
"render.cpu_celestial_switch": "この太陽や空を維持するには、間接照明を使用する CPU レイトレーシングを選択してください。",
|
||||
"render.cpu_celestial_unportable": "現在の透過サンプル設定には GPU パストレーシングを使用してください。",
|
||||
"render.cpu_check_capture_help": "確認と開始は同じファイルのハッシュを検証するか、埋め込みHDRを使用します。各画像は取得済みの入力を保持します。",
|
||||
"render.cpu_check_changed": "確認後に設定またはモデルが変更されました。レンダリング前に再確認してください。",
|
||||
"render.cpu_constant_direct_help": "間接照明を使わない場合、この一定の光源は反射やガラス越しに見えることがありますが、不透明な面は照らしません。シーンの照明に使うには、間接照明を有効にしてください。",
|
||||
"render.cpu_constant_enable_indirect_plan": "パスの深さ {depth}、ピクセルあたり {spp} サンプルのプログレッシブサンプリング(シード {seed})、各サンプルに一次レイ1本、レイトレーシングの影を使用します。画像サイズ、CPU予算、その他の設定は保持します。",
|
||||
"render.cpu_constant_help": "一定の放射輝度を反射・透過レイに与えます。直接拡散照明には使われません。",
|
||||
"render.cpu_custom_directions": "プリセットをカスタム平行光に置換",
|
||||
"render.cpu_denoise_help": "画像が更新されるたびにノイズを減らします。細部や被写界深度によるぼけの境界が柔らかくなる場合があります。RAW EXRは変更されません。完成画像の調整にはそのRAW画像を使用し、ノイズ低減は再適用しません。",
|
||||
"render.cpu_direction": "光源へ向かう方向(X、Y、Z)",
|
||||
"render.cpu_directional": "平行光",
|
||||
"render.cpu_directional_add": "平行光を追加",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "HDR照明サンプル数",
|
||||
"render.cpu_exposure_help": "CPU露出は−16~16 EVです。範囲外のGPU露出は、変更するまで保持されます。",
|
||||
"render.cpu_fixed": "固定画像",
|
||||
"render.cpu_fixed_retained": "固定サンプリングを維持",
|
||||
"render.cpu_glass_enable_path": "間接照明を有効にする",
|
||||
"render.cpu_hdr_help": "Radiance .hdr、2:1パノラマ、最大2048 × 1024・16 MiB。CPUではファイル内にsRGB/D65原色の明示が必要です。原色未指定やEXRは使用できません。",
|
||||
"render.cpu_hdr_reflection_help": "HDRの粗い反射には、ボディと面の完全な金属表面が必要です。未対応の組み合わせは確認時に表示されます。",
|
||||
"render.cpu_indirect_method_required": "この間接照明モードを有効にするには、CPUレイトレーシングを選択してください。",
|
||||
"render.cpu_key_rim": "キーライトとリムライト",
|
||||
"render.cpu_last_check": "前回のCPU確認",
|
||||
"render.cpu_light_help": "平行光は最大8個です。カスタムリストを空にすると消灯し、プリセットを選ぶとその光源に戻ります。色はリニアRGB、強度は相対値でワットではありません。",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "照明プリセット",
|
||||
"render.cpu_lights": "CPU光源",
|
||||
"render.cpu_linear_color": "リニア色(R、G、B)",
|
||||
"render.cpu_maps_saved_help": "CPUレンダリングは保存された不透明サーフェスマップを使えます。サーフェスを選び、ショットにマップを保存してください。間接光を使う場合、別のボディにはガラスを使えますが、マップを持つボディには使えません。選択した反射、異方性、プロシージャル効果はオフにします。一時的なマップファイルにはGPUを使います。",
|
||||
"render.cpu_material_certification": "CPUの確認では、レンダリング前に対応するガラス形状を検証します。閉じたガラスには形状から得られる距離を使います。",
|
||||
"render.cpu_neutral": "ニュートラル",
|
||||
"render.cpu_opaque_hint": "CPUレイトレーシングでは不透明な背景を使用します。",
|
||||
"render.cpu_opaque_required": "CPU レイトレーシングには不透明な背景が必要です。透明な背景を解除してください。",
|
||||
"render.cpu_options_pending": "このCPUパネルでは、発光、表面効果、ガラス、異方性はまだ編集できません。使用前にそれらのAPIオプションを解除してください。",
|
||||
"render.cpu_path_depth": "パス深度",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_lighting": "間接照明",
|
||||
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_point": "点光源",
|
||||
"render.cpu_point_add": "点光源を追加",
|
||||
"render.cpu_point_help": "選択した平行光に最大8個の点光源を追加できます。距離はmmで、最小距離は基準距離以下にしてください。",
|
||||
"render.cpu_primary_samples": "輪郭サンプル数/ピクセル",
|
||||
"render.cpu_progressive": "CPUプログレッシブ",
|
||||
"render.cpu_progressive_adopt": "プログレッシブサンプリングを使用",
|
||||
"render.cpu_progressive_adopt_help": "切り替えると各パスで1つのカメラサンプルを使用します。画像サイズ、照明、反射設定は変わりません。",
|
||||
"render.cpu_progressive_always": "プログレッシブサンプリングは常に有効です",
|
||||
"render.cpu_progressive_help": "新しいCPUカメラサンプルを蓄積し、完了したすべてのパスを表示します。選択した反射はカメラサンプルごとに1回評価します。認証済みガラスと平面異方性には従来の制限が適用されます。適応サンプリングのパイロットと本番は同じCPUプロファイル予算を使います。",
|
||||
"render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。面光源、誘電体、異方性には固定画像を使用してください。",
|
||||
"render.cpu_progressive_limits": "最大1,048,576ピクセル、ピクセル×サンプルの合計は16,777,216以下です。サンプル数を変えても画像サイズは自動変更されません。",
|
||||
"render.cpu_progressive_reflection_adopt": "切り替える前に、選択した反射の試行回数を1に設定してください。",
|
||||
"render.cpu_progressive_seed": "CPUサンプリングシード",
|
||||
"render.cpu_progressive_spp": "サンプル/ピクセル",
|
||||
"render.cpu_quality_budget_help": "サイズ、本番サンプル数の上限、シード、CPUリソースプロファイルを変更します。カメラ、レンズ、照明、GI深度、エフェクトは維持されます。",
|
||||
"render.cpu_quality_progressive_required": "CPU予算を選ぶ前に、下でプログレッシブサンプリングを有効にしてください。",
|
||||
"render.cpu_quality_summary": "{width} × {height} · サンプル上限 {samples} · シード {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "粗い反射を追加",
|
||||
"render.cpu_reflection_depth": "反射の深さ(1~8)",
|
||||
"render.cpu_reflection_empty": "対象ボディがないため、シーン反射は無効です。",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "照明と環境の数値設定は保存されます。ボディごとの反射とHDRファイルは一時設定です。既存の面光源とスポット円錐の値は保持されます。",
|
||||
"render.cpu_sampling_mode": "CPUサンプリング方式",
|
||||
"render.cpu_shadows": "影を落とす",
|
||||
"render.cpu_sky_bounds": "空の回転は ±1,000,000 ラジアン以内の有限値、強度は 0~100 にしてください。",
|
||||
"render.cpu_studio_enable": "このリグで間接照明を有効にする",
|
||||
"render.cpu_studio_enable_apply": "間接照明を有効にして適用",
|
||||
"render.cpu_studio_indirect_help": "この太陽リグには深度8の間接照明、追跡した影、パスごとに1つのカメラサンプルが必要です。プログレッシブ描画がオフなら、ピクセルごと16サンプル、シード0で開始します。既存のプログレッシブ設定とリソースプロファイルは維持します。非対応の設定がある場合は変更せず適用を中止します。",
|
||||
"render.cpu_studio_radiance_limit": "このリグの面光源の放射輝度は{radiance}で、CPU上限64を超えます。作成された照明は維持します。別のリグかGPUを選んでください。",
|
||||
"render.cpu_subsurface_denoise_reason": "CPUの内部散乱ではノイズ除去をオフにしてください。設定値は保持されます。",
|
||||
"render.cpu_subsurface_help": "散乱は閉じたガラスの内部で光の方向を変えます。正の値には間接照明を使い、ノイズ除去をオフにします。率は1mmあたりの衝突回数です。0では散乱を加えません。方向の偏りが正なら前方、負なら後方を優先します。",
|
||||
"render.cpu_summary": "CPUレイトレーシング・反射・HDR・平行光と点光源",
|
||||
"render.cpu_transparent_help": "透明PNGは背景を除き、輪郭の被覆率を保持します。ボディと面の不透明度は100%にしてください。",
|
||||
"render.cpu_volume_enable_path": "間接照明を有効にする",
|
||||
"render.cpu_volume_enable_path_plan": "CPUレイトレーシング · パス深度{depth} · {spp}サンプル/ピクセル · シード{seed} · レイトレースシャドウ。大気と画像予算の設定を保持します。",
|
||||
"render.cpu_volume_help": "CPUの大気は間接照明を使用します。消散係数がゼロでも、霧のないボックスとして設定は保存されます。",
|
||||
"render.cpu_volume_method_adopt": "間接照明を使うCPUレイトレーシング",
|
||||
"render.cpu_volume_method_incompatible": "この照明、サンプリング、大気の設定はこの方式に引き継げません。設定は保持されます。",
|
||||
"render.cpu_volume_path_required": "CPUの大気を使うには間接照明を有効にしてください。",
|
||||
"render.cpu_warm_cool": "暖色と寒色",
|
||||
"render.diag_bodies": "表示ボディ数",
|
||||
"render.diag_job_details": "記録されたジョブの詳細",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "事前確認の詳細",
|
||||
"render.diag_request": "現在の正確な要求",
|
||||
"render.diag_triangles": "三角形数",
|
||||
"render.directional_sun_limit": "GPUでは指向性光源を合計8個まで使用でき、柔らかい影の太陽は1個までです。遠方の光源を追加するには、硬い影の指向性ライトを複製してください。",
|
||||
"render.discard_continue": "下書きを破棄して続行",
|
||||
"render.draft_confirm_help": "現在のレンダー下書きには未適用の編集があります。切り替える前に保存するか、明示的に破棄するか、編集を続けてください。",
|
||||
"render.draft_confirm_title": "編集したレンダー設定を保持",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "書き出し:表示PNG",
|
||||
"render.export_image_label": "画像を書き出す",
|
||||
"render.export_linear": "書き出し:生の線形EXR",
|
||||
"render.exr_pending": "CPUレイトレーシングとGPUパストレーシングでは生の線形EXRを保持できます。圧縮、複数レイヤー、補助パスは未実装です。",
|
||||
"render.exr_pending": "GPUでは線形EXRを保持できます。圧縮、複数レイヤー、補助パスは未実装です。",
|
||||
"render.film_grain": "写真粒子",
|
||||
"render.fit": "製品を画面に合わせる",
|
||||
"render.fixed_camera": "カメラ",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · 縦長",
|
||||
"render.frame_thirds": "三分割線",
|
||||
"render.frame_wide": "16:9 · ワイドスクリーン",
|
||||
"render.glass_abbe": "アッベ数",
|
||||
"render.glass_dispersion": "色分散",
|
||||
"render.glass_film": "薄膜干渉",
|
||||
"render.glass_film_ior": "薄膜の屈折率",
|
||||
"render.glass_film_thickness": "薄膜の厚さ",
|
||||
"render.glass_model_enable": "高度なガラスモデル",
|
||||
"render.glass_model_help": "ガラスの粗さは透過光を散乱させます。分散と薄膜の色は3つの波長帯を使用します。粗い薄板では直進透過影をオフにしてください。",
|
||||
"render.glass_roughness": "ガラスの粗さ",
|
||||
"render.gpu_depth": "最大パス深度",
|
||||
"render.gpu_options_help": "サンプル数、深度、シード、露出をこの設定に保存します。保存するのは選択中の画像方式だけです。",
|
||||
"render.gpu_path_crashed": "前回このグラフィックアダプターでの準備中に SolidCraft が終了しました。グラフィックドライバー内で終了した可能性が高いため、自動では再度準備しません。代わりに CPU レイトレーシングを使うか、もう一度試してください(ドライバーを更新した場合も再試行されます)。",
|
||||
"render.gpu_path_failed": "GPUパストレーサーを利用できません",
|
||||
"render.gpu_path_first_setup": "このGPUでの初回セットアップには数分かかることがあります。その間もSolidCraftは使用できます。以降の起動では、ドライバーが保持していればコンパイル済みのシェーダーを再利用します。",
|
||||
"render.gpu_path_not_started": "GPUパストレーサーのセットアップはまだ開始されていません。",
|
||||
"render.gpu_path_preparing": "GPUパストレーサー:シェーダーをコンパイル中…",
|
||||
"render.gpu_path_retry": "もう一度試す",
|
||||
"render.gpu_seed": "乱数シード",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "製品の中心",
|
||||
"render.light_size": "サイズ",
|
||||
"render.light_softness": "柔らかさ",
|
||||
"render.light_space_world": "ワールド座標のライト",
|
||||
"render.light_space_world_help": "ライトの位置と方向はワールド座標を使用します。カメラを周回させてもライトは移動しません。",
|
||||
"render.light_temperature": "色温度",
|
||||
"render.light_temperature_help": "黒体色の近似。リニアRGBで保存されます。",
|
||||
"render.light_type": "タイプ",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "ノイズ除去、露出、トーンマッピング、フィルター、背景処理より前の32ビット浮動小数点RGBAを保持します。線形sRGB/D65で、被覆率を乗算した値です。追加メモリを明示的に見積もります。圧縮や追加パスはありません。",
|
||||
"render.linear_retain": "生の線形EXRを保持",
|
||||
"render.linear_unavailable": "選択した完了画像にEXRは保持されていません。レンダー前に保持を有効にしてください。古いPNGから生の放射輝度は復元できません。",
|
||||
"render.live.adaptive": "ライブ · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "ライブレンダリングの停止中にリクエストが変更されました。最終画像をもう一度開始してください。",
|
||||
"render.live.final_waiting": "最終画像を準備中 · ライブレンダーのリソースを解放中",
|
||||
"render.live.invalid_size": "ライブレンダリングには正の出力幅と高さが必要です。",
|
||||
"render.live.pause": "ライブを一時停止",
|
||||
"render.live.paused": "ライブ · 一時停止中",
|
||||
"render.live.paused_outdated": "ライブ · 一時停止中 · 更新が必要",
|
||||
"render.live.quality_help": "インタラクティブ品質: 長辺は最大512ピクセルです。選択したレンダラーと対応する効果を使用します。最終解像度、設定、書き出しは別に保持します。",
|
||||
"render.live.refused": "ライブを利用できません: {reason}",
|
||||
"render.live.resume": "ライブを再開",
|
||||
"render.live.samples": "ライブ · {width} × {height} · {current}/{target} サンプル",
|
||||
"render.live.source_current": "{method} · ライブ",
|
||||
"render.live.source_outdated": "{method} · 前の画像",
|
||||
"render.live.source_outdated_help": "ライブレンダリングの更新中や利用できない間は、最後に取得した画像を表示します。現在のシーンや画像設定とは異なる場合があります。",
|
||||
"render.live.source_settings_help": "出力を開いてレンダラーと画像設定を編集します。",
|
||||
"render.live.stopped": "ライブレンダリングを停止しました",
|
||||
"render.live.updating": "ライブ · インタラクティブ品質(最大512 px)· 更新中",
|
||||
"render.live.waiting_canvas": "レンダーキャンバスを待機中",
|
||||
"render.live.worker_stopped": "ライブレンダリングのキャプチャーワーカーが結果を返す前に停止しました",
|
||||
"render.look_add": "カラールックを追加",
|
||||
"render.look_applied": "{name} を適用",
|
||||
"render.look_bleach": "銀残し風",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "既存の不透明外観に参考用の誘電体コートを重ねます。重み0ではベースを変えません。",
|
||||
"render.material_color_map": "ベースカラー画像(sRGB)",
|
||||
"render.material_color_replace": "ベースマップでボディ色を置換",
|
||||
"render.material_cpu_glass_only": "一時画像ファイルのマップにはGPUが必要です。CPUでは保存済みの組み込み表面マップを使用できます。",
|
||||
"render.material_cpu_optics_limits": "対応する閉じたガラス形状、検証済みの入れ子形状、内部散乱には間接照明をオンにしてください。薄いシートは影に光を透過できます。それ以外は平面シートか軸平行の箱を使います。接触する形状と閉じたガラスを通る透過影には未対応です。値は保持されます。",
|
||||
"render.material_cpu_saved_runtime_conflict": "保存済みCPU光学設定と一時的な光学割り当ては併用できません。確認した設定を保存するか、競合する一時割り当てを削除してください。",
|
||||
"render.material_custom": "カスタム参考光学設定",
|
||||
"render.material_enable": "GPUマテリアル設定を使用",
|
||||
"render.material_filter": "シーンのボディを絞り込み…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "法線画像(OpenGL、+Y)",
|
||||
"render.material_normal_strength": "法線の強さ(0~1)",
|
||||
"render.material_origin": "マップ原点 X・Y・Z(mm)",
|
||||
"render.material_raytrace_required": "物理ガラスにはCPUレイトレーシングまたはGPUパストレーシングが必要です。",
|
||||
"render.material_refresh": "シーンのボディを更新",
|
||||
"render.material_refresh_needed": "シーンが変更されました。対象追加の前に一覧を更新してください。",
|
||||
"render.material_remove": "この割り当てを削除",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "以前のシーン版への割り当てです。レンダー前にボディの識別情報を再確認してください。",
|
||||
"render.material_roughness": "コート粗さ(0.045~1)",
|
||||
"render.material_roughness_map": "粗さマップ(リニア)",
|
||||
"render.material_saved_edit_pending": "光学設定の変更は未保存です。確認した値を保存するか、保存済み割り当てを削除するか、変更を破棄してください。一時割り当てや未対応の効果で保存済み対象を上書きできません。",
|
||||
"render.material_shadow": "直進するシャドウレイを透過",
|
||||
"render.material_target_missing": "ボディの順序と名前が一致しません。この割り当てを削除して対象を選び直してください。",
|
||||
"render.material_thickness": "薄板の厚さ(mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "CPU レイトレーシング",
|
||||
"render.method_gpu": "GPU パストレーシング",
|
||||
"render.method_limit_raster": "ラスターは固定の不透明スタジオプレビューを使用します。対応する光学設定には明示的なレイトレーシング方式を選択してください。",
|
||||
"render.method_raster": "CPU ラスター(模式図)",
|
||||
"render.method_raster": "CPU ラスター",
|
||||
"render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。",
|
||||
"render.nested_enable": "検証済み入れ子誘電体",
|
||||
"render.nested_help": "最大16層。親は凸形状、子は厳密に内部である必要があります。交差・接触・薄膜・直線透過影は拒否します。内部カメラは検証した階層を使います。液面の生成や接触するCAD境界の結合は行いません。",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "ここではEXR出力設定を利用できません。リニアEXR書き出しには、リニアマスターを保持した完成GPU画像が必要です。",
|
||||
"render.output_fit": "全体表示",
|
||||
"render.output_format": "形式",
|
||||
"render.output_format_status": "PNGは完了した表示画像そのものです。CPU/GPUの任意EXRは表示フィルター前の生の線形FLOAT32放射輝度を保持します。プレビューキャッシュや別ジョブへの置換はありません。",
|
||||
"render.output_format_status": "PNGは完了した表示画像そのものです。GPUの任意EXRは表示フィルター前の生の線形FLOAT32放射輝度を保持します。プレビューキャッシュや別ジョブへの置換はありません。",
|
||||
"render.output_grading": "カラーグレーディング",
|
||||
"render.output_image": "画像",
|
||||
"render.output_largest": "{width} × {height} 最大",
|
||||
"render.output_lock_ratio": "縦横比を固定",
|
||||
"render.output_master_unavailable": "EXRを書き出すには、RAW EXRデータを持つ完成画像を選択してください。新しい画像は、レンダリング前にRAW EXRを有効にしてください。",
|
||||
"render.output_over_budget": "要求画像が選択したリソース枠を超えています。対応するサイズかプロファイルを明示的に選択してください。自動的な品質低下は行いません。",
|
||||
"render.output_presentation_limits": "CPUの画像効果とEXR出力は、幅・高さとも8192ピクセル以下、合計16,777,216ピクセル以下の画像に対応します。",
|
||||
"render.output_presets_help": "形式、品質、チェックリストは出力エディターにあります。",
|
||||
"render.output_quality": "品質",
|
||||
"render.output_quality_waiting": "品質仕様の確定待ち",
|
||||
"render.output_ratio_refused": "連動する寸法が方式の上限を超えます。以前のサイズを維持します。縦横比固定を解除するか、小さい値を選択してください。",
|
||||
"render.output_raytrace_only": "画像効果にはCPUレイトレーシングまたはGPUパストレーシングが必要です。",
|
||||
"render.output_ready": "レンダーの準備完了",
|
||||
"render.output_run_check": "チェックを実行",
|
||||
"render.output_size_limit": "{method}:各辺 {dimension} px、合計 {pixels} ピクセルまで。対応する最大サイズ:{width} × {height}。",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "彩度",
|
||||
"render.post_title": "表示フィルター",
|
||||
"render.post_vignette": "ビネット",
|
||||
"render.preparation_cancelled": "レンダリングの準備をキャンセルしました",
|
||||
"render.preparation_checking": "レンダリングを確認中",
|
||||
"render.preparation_retirement_failed": "レンダリング準備の後処理に失敗しました。このウィンドウでは別の画像を準備できません。",
|
||||
"render.preparation_retiring": "レンダリングの準備を終了中",
|
||||
"render.preparation_unavailable": "レンダリングの準備を利用できません",
|
||||
"render.presentation_banner": "画像効果はCPUとGPUのレイトレーシングで使用できます。このページを開いても方式は変わりません。",
|
||||
"render.preview": "統合プレビュー",
|
||||
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
|
||||
"render.preview_image_settings_help": "ライブプレビューは固定のスタジオ照明を使用します。画像の露出と照明は、確認または開始時に画像ジョブへ適用されます。",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "固定予算への部分適用と省略項目を確認",
|
||||
"render.quality_apply": "この固定サンプリング予算を使用",
|
||||
"render.quality_balanced": "バランス",
|
||||
"render.quality_budget_help": "サイズ、サンプル数、深度、シード、ノイズ低減を設定します。他の画像設定は維持されます。",
|
||||
"render.quality_custom": "カスタム",
|
||||
"render.quality_denoise_off": "ノイズ低減:オフ",
|
||||
"render.quality_denoise_on": "ノイズ低減:オン",
|
||||
"render.quality_draft": "ドラフト",
|
||||
"render.quality_high": "高品質",
|
||||
"render.quality_projection_help": "この確認で変更するのは、解像度、サンプル予算、パス深度、シード、ノイズ低減だけです。他の設定は維持され、収束は保証されません。",
|
||||
"render.quality_projection_help": "Qプリセットには将来の要件も含まれます。この操作は対応する固定サンプルPNG予算のみを適用し、適応的停止、生の線形出力、独立した透過制限は含みません。",
|
||||
"render.quality_review": "サンプリング予算を確認",
|
||||
"render.quality_summary": "{width} × {height} · {samples} サンプル · 深度 {depth} · シード {seed} · {denoise}",
|
||||
"render.queue_pending": "このUIには自動の本番キューやアニメーションレンダラーはありません。完了した静止画像は個別に書き出せます。",
|
||||
"render.radial_assign": "外観を割り当て",
|
||||
"render.radial_compare": "比較",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "移動とズームはナビゲーション設定に従います。スペースキーを押しながらドラッグすると移動します。",
|
||||
"render.result_open": "完成した画像をメインキャンバスに表示します。",
|
||||
"render.result_preview": "ライブプレビュー",
|
||||
"render.result_provisional": "仮画像",
|
||||
"render.result_provisional": "CPU仮画像",
|
||||
"render.result_provisional_help": "この中間画像はエクスポートできません。最後に完成した結果をエクスポートします。キャンセルするとその結果に戻ります。",
|
||||
"render.result_reduced_display": "表示プレビューをビューポート機器またはキャッシュの上限に縮小しています。保持したPNGと書き出しは元の解像度です。この縮小表示ではネイティブ100%表示はできません。",
|
||||
"render.result_retained": "最後に完成した画像を表示しています。新しいレンダリングにはまだ置き換わっていません。",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "被写体を収める",
|
||||
"render.shot_invalid_camera": "カメラの値は有限で範囲内にしてください。",
|
||||
"render.shot_lens": "レンズ",
|
||||
"render.shot_lens_cpu_progressive_reason": "CPUの被写界深度を有効にするには、サンプリングでプログレッシブ方式を適用してください。",
|
||||
"render.shot_lens_enable_reason": "絞りと焦点距離を編集するには被写界深度を有効にしてください。",
|
||||
"render.shot_lens_gpu_reason": "被写界深度には GPU パストレーシングが必要です。",
|
||||
"render.shot_lens_perspective_reason": "被写界深度には透視投影が必要です。",
|
||||
"render.shot_lens_raytrace_reason": "被写界深度はCPUレイトレーシングとGPUパストレーシングで利用できます。",
|
||||
"render.shot_look_selection": "選択に注目",
|
||||
"render.shot_manager": "保存済みショット",
|
||||
"render.shot_new_name": "新しいショット名",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "地平線・線形RGB",
|
||||
"render.sky_zenith": "天頂・線形RGB",
|
||||
"render.soft_focus": "ソフトフォーカス",
|
||||
"render.spot_axis": "光の照射方向",
|
||||
"render.spot_cone": "スポット円錐",
|
||||
"render.spot_gpu_help": "角度は光の照射方向から測ります。内側の角度から外側の角度にかけて光が弱まります。減衰範囲が非常に狭い場合はCPUレンダリングが必要です。",
|
||||
"render.spot_help": "角度は光の照射方向から測ります。内側の角度から外側の角度にかけて光が弱まります。",
|
||||
"render.spot_inner": "内側の半角",
|
||||
"render.spot_outer": "外側の半角",
|
||||
"render.stage_frame": "構図",
|
||||
"render.stage_layout.appearance_note": "クリックして選択に適用します。",
|
||||
"render.stage_layout.studio_note": "クリックして適用前に確認します。",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "作成計画を準備",
|
||||
"render.stage_panels.projects": "投影",
|
||||
"render.stage_panels.quality_ack": "固定予算と省略事項を確認しました",
|
||||
"render.stage_panels.quality_omissions": "この予算は、適応サンプリングの誤差目標、独立した透過制限、ロシアンルーレット規則、リニアマスターの保持を設定しません。収束も保証しません。",
|
||||
"render.stage_panels.quality_omissions": "適応停止と誤差目標、個別の透過深度、ロシアンルーレット規則、線形RAW出力、EXRマスター、収束認証。",
|
||||
"render.stage_panels.reference": "参照",
|
||||
"render.stage_panels.references": "参照",
|
||||
"render.stage_panels.remove_optics": "光学設定を削除",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "オリジナルHDR環境・シーン全体",
|
||||
"render.stage_panels.subtitle_maps": "作成済み空間マップ・一時プレビュー",
|
||||
"render.stage_panels.subtitle_physical": "読み取り専用計画・マテリアルの作成や割り当てなし",
|
||||
"render.stage_panels.subtitle_quality": "GPUサンプリング予算 · 記載された設定のみ",
|
||||
"render.stage_panels.subtitle_quality": "固定GPU予算・将来の要件は省略",
|
||||
"render.stage_panels.subtitle_rig": "作成済みライトリグ・光源と受動カード",
|
||||
"render.stage_panels.subtitle_saved_maps": "作成済みマップ・このショットに保存",
|
||||
"render.stage_panels.subtitle_stage": "物理的な地面とステージ・製品にフィット",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "最大 4 個の片面矩形です。直交する半辺 U と V でサイズを定め、U × V が発光方向になります。座標はワールド空間の mm です。",
|
||||
"render.studio_center": "中心 X, Y, Z (mm)",
|
||||
"render.studio_denoise": "完成画像のノイズを低減",
|
||||
"render.studio_denoise_help": "プログレッシブ画像と完成画像に輪郭を保持するフィルターを適用します。微細な描写が柔らかくなることがあります。ガラスと鋭い鏡面反射は対象外です。",
|
||||
"render.studio_denoise_help": "輪郭を保持する最終フィルターです。微細な描写が柔らかくなることがあります。ガラスと鋭い鏡面反射は対象外です。",
|
||||
"render.studio_direction": "太陽に向かう方向 (X, Y, Z)",
|
||||
"render.studio_environment": "環境",
|
||||
"render.studio_environment_bounds": "環境回転は±1,000,000ラジアン以内の有限値、強度は0~100でなければなりません。",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "統合プレビューを使用",
|
||||
"render.volume_albedo": "散乱アルベド · リニア RGB",
|
||||
"render.volume_anisotropy": "位相関数の平均余弦",
|
||||
"render.volume_denoise_required": "大気を使うにはノイズ低減をオフにしてください。",
|
||||
"render.volume_enable": "散乱媒質を有効にする",
|
||||
"render.volume_extinction": "消散係数",
|
||||
"render.volume_glass_required": "この設定では大気とガラスを併用できません。",
|
||||
"render.volume_help": "有限の箱内で GPU による Beer 減衰と HG 散乱をカメラ・間接経路に適用します。明示したスタジオ照明・HDR・空を使い、影も減衰します。平均余弦が正なら前方散乱です。ガラス境界と表面ノイズ除去は明示的に無効化してください。消散ゼロは従来の輸送を保持します。月の大気、密度グリッド、体積発光、分光輸送、認証済み物性ではありません。",
|
||||
"render.volume_lighting_required": "大気を描画するには環境または明示的なライト構成を選んでください。",
|
||||
"render.volume_max": "最大境界 · ワールド mm",
|
||||
"render.volume_min": "最小境界 · ワールド mm",
|
||||
"render.volume_single_stratum_required": "大気を描画するには透過サンプル数を1にしてください。",
|
||||
"render.volume_title": "有限の均一ボリューム",
|
||||
"render.wait_add_light": "未対応:名前付きライトの作成",
|
||||
"render.wait_batch": "未対応: 保存ショットのレンダーキュー",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "寸法を適用しました",
|
||||
"sketch.dimension_applied_defined": "寸法を適用しました。パラメータ `{name}` を定義しました",
|
||||
"sketch.dimension_applied_renamed": "寸法を適用しました。パラメータ名を `{name}` にしました",
|
||||
"sketch.divide_hint": "点は各曲線を等しい長さに分割し、曲線が変わってもその分割を保ちます。",
|
||||
"sketch.dof_left": "残り自由度 {count}",
|
||||
"sketch.dof_left_one": "残り自由度 {count}",
|
||||
"sketch.draw_curves": "{linetype}として描く",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "細かい",
|
||||
"ui.finest": "最も細かい",
|
||||
"ui.finish_sketch": "スケッチを終了",
|
||||
"ui.first_dimension_scales": "最初の寸法で図形全体をスケールする",
|
||||
"ui.first_dimension_scales_tip": "オン: スケッチの最初の長さ寸法で、測定する図形全体をスケールし、形を保ちます。オフ: 寸法は測定する対象のサイズだけを変えます。",
|
||||
"ui.fit": "フィット",
|
||||
"ui.fix_unfix": "固定/固定解除",
|
||||
"ui.flat": "展開",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "カスタム ビューナビゲーター",
|
||||
"ui.heading_cylinder": "円柱",
|
||||
"ui.heading_design": "デザイン",
|
||||
"ui.heading_divide_curve": "曲線を分割",
|
||||
"ui.heading_draft": "勾配",
|
||||
"ui.heading_edit": "編集",
|
||||
"ui.heading_equation_curve": "方程式曲線",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "再生ボタンを表示",
|
||||
"ui.history_play_finish_action": "現在の操作を完了してから履歴を再生してください",
|
||||
"ui.history_play_roll_back": "再生する前に履歴マーカーを戻してください",
|
||||
"ui.history_scope_all": "設計全体のフィーチャーを表示",
|
||||
"ui.history_scope_all_shown": "設計全体のフィーチャー",
|
||||
"ui.history_scope_pick": "コンポーネントを編集するかブラウザで選ぶと、そのフィーチャーだけを表示します",
|
||||
"ui.history_scope_show": "{name} とその中のコンポーネントのフィーチャーだけを表示",
|
||||
"ui.history_scope_shown": "{name} とその中のコンポーネントのフィーチャー",
|
||||
"ui.history_show_hidden": "非表示のコンポーネントを表示",
|
||||
"ui.history_show_names": "名前を表示",
|
||||
"ui.history_sketch_listed_under": "{feature} の下に表示",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "ファイルに色情報がありません",
|
||||
"ui.point_cloud_pause": "一時停止",
|
||||
"ui.point_cloud_play": "再生",
|
||||
"ui.point_cloud_reload": "ファイルから再読み込み",
|
||||
"ui.point_cloud_single_color": "単色",
|
||||
"ui.point_cloud_size": "点のサイズ",
|
||||
"ui.point_cloud_units": "ファイルの単位",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "第2レール",
|
||||
"ui.section_analysis": "断面解析",
|
||||
"ui.section_size": "断面サイズ",
|
||||
"ui.segments": "分割数",
|
||||
"ui.select": "選択",
|
||||
"ui.selectOther": "その他を選択…",
|
||||
"ui.selectTangentChain": "接線チェーンを選択",
|
||||
|
||||
+10
-156
@@ -547,7 +547,6 @@
|
||||
"Select edges": "모서리 선택",
|
||||
"Select points of the sketch being edited": "편집 중인 스케치의 점을 선택하세요",
|
||||
"Select the centre the pattern turns around first": "패턴이 회전할 중심을 먼저 선택하세요",
|
||||
"Select the curves to divide first": "먼저 분할할 곡선을 선택하세요",
|
||||
"Select the curves to offset first": "오프셋할 곡선을 먼저 선택하세요",
|
||||
"Select the mirror line": "대칭선을 선택하세요",
|
||||
"Select the sketch curves or points to mirror first": "대칭 복사할 스케치 곡선 또는 점을 먼저 선택하세요",
|
||||
@@ -1028,9 +1027,6 @@
|
||||
"cmd.pointcloud_edit": "포인트 클라우드 편집",
|
||||
"cmd.pointcloud_insert": "포인트 클라우드 삽입",
|
||||
"cmd.pointcloud_list": "포인트 클라우드",
|
||||
"cmd.pointcloud_reload": "포인트 클라우드 다시 불러오기",
|
||||
"cmd.render.coat.bind": "클리어 코트 저장",
|
||||
"cmd.render.coat.unbind": "클리어 코트 제거",
|
||||
"cmd.render.compare.divider": "렌더 비교 구분선 이동",
|
||||
"cmd.render.result.discard": "렌더 결과 버리기",
|
||||
"cmd.render.result.select": "렌더 결과 선택",
|
||||
@@ -1170,7 +1166,6 @@
|
||||
"cmd.sketch_options": "스케치 팔레트 옵션",
|
||||
"cmd.sketch_paste": "스케치 요소 붙여넣기",
|
||||
"cmd.sketch_point3d": "3D 점",
|
||||
"cmd.sketch_point_divide": "곡선 분할",
|
||||
"cmd.sketch_polygon_circumscribed": "외접 다각형",
|
||||
"cmd.sketch_polygon_edge": "변 다각형",
|
||||
"cmd.sketch_polygon_inscribed": "내접 다각형",
|
||||
@@ -1245,7 +1240,6 @@
|
||||
"cmd.view.render_browser": "렌더 브라우저",
|
||||
"cmd.view.render_dock": "렌더 도크 이동",
|
||||
"cmd.view.render_focus": "렌더 집중 보기",
|
||||
"cmd.view.render_live": "실시간 렌더링 일시 중지 또는 재개",
|
||||
"cmd.view.status_details": "뷰포트 상태 상세 정보",
|
||||
"cmd.view.status_items": "뷰포트 상태 항목",
|
||||
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
|
||||
@@ -1392,7 +1386,6 @@
|
||||
"file_load.reading_percent": "파일 읽는 중... {percent}%",
|
||||
"file_load.rebuilding": "피처 다시 빌드 중 {done}/{total}",
|
||||
"file_load.recovering": "디자인 복구 중",
|
||||
"file_load.reloading_cloud": "포인트 클라우드 다시 불러오는 중",
|
||||
"file_load.step_build": "모델 만드는 중",
|
||||
"file_load.step_feature": "피처 {done}/{total}",
|
||||
"file_load.step_part": "부품 {done}/{total}",
|
||||
@@ -1451,7 +1444,6 @@
|
||||
"help.sketch.construction_line.tangent_two": "두 원 또는 원호를 선택한 뒤 미리보기에서 공통 접선을 선택합니다. 접점은 그려진 곡선 위에 있어야 합니다.",
|
||||
"help.sketch.construction_line.vertical": "한 점을 지나는 무한 수직 보조 직선을 만듭니다. 나중에 편집해도 수직 구속이 유지됩니다.",
|
||||
"help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.",
|
||||
"help.sketch.point.divide": "선택한 곡선을 같은 길이로 나누는 점을 배치합니다. 점은 각 곡선 위에 남고 곡선이 바뀌어도 간격을 유지합니다.",
|
||||
"help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.",
|
||||
"help.solid.combine": "바디를 결합하거나, 대상 바디에서 잘라내거나, 서로 겹치는 체적만 남기며, 도구 바디를 유지할 수도 있습니다. 부품을 합치거나, 한 형상으로 다른 형상을 잘라내거나, 두 바디가 겹치는 곳을 찾을 때 사용합니다.",
|
||||
"help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.",
|
||||
@@ -2002,10 +1994,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "축소 시 구속 조건 기호 숨기기",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "스케치를 충분히 축소하면 구속 조건 기호가 가려지지 않도록 숨깁니다. 끄기: 확대/축소와 관계없이 고정된 화면 크기를 유지합니다.",
|
||||
"prefs.history": "이력",
|
||||
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
|
||||
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
|
||||
"prefs.history_group_joints": "이력에서 조인트 묶기",
|
||||
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
|
||||
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
|
||||
"prefs.history_origin_bodies_note": "왼쪽 또는 브라우저 아래의 히스토리가 활성 구성 요소의 바디 폴더와 원점으로 시작하며, 브라우저처럼 표시 아이콘과 메뉴가 있습니다. 아래쪽 히스토리는 바뀌지 않습니다.",
|
||||
"prefs.history_play_button": "기록 컨트롤에 기록 재생 버튼 표시",
|
||||
@@ -2111,8 +2099,6 @@
|
||||
"prefs.quality_smoother": "더 부드럽게",
|
||||
"prefs.remember_dialog_values": "명령 대화 상자 값 기억",
|
||||
"prefs.remember_dialog_values.help": "기본값은 꺼짐입니다. 켜면 명령이 성공한 후 SolidCraft를 닫을 때까지 같은 명령을 다음에 열 때 안전한 값과 옵션을 다시 사용합니다.",
|
||||
"prefs.render_legacy_image_defaults": "이전 렌더 이미지 기본값 사용",
|
||||
"prefs.render_legacy_image_defaults_help": "이전의 수동 이미지 작업 방식을 유지합니다. 새 설정은 CPU 래스터를 사용하고 CPU 광선 추적은 고정 샘플링을 사용하며 결과는 직접 엽니다. 렌더에 들어갈 때 뷰를 다시 맞추고, 샷을 전환해도 저장된 카메라를 복원하지 않으며, 이전의 프레이밍과 탐색 방식을 사용합니다. 자동 실시간 이미지 피드백을 끕니다. 저장된 설정은 변경하지 않습니다.",
|
||||
"prefs.reports_saved": "{n}개 저장됨",
|
||||
"prefs.restore_page_defaults": "페이지 기본값 복원",
|
||||
"prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료",
|
||||
@@ -2227,8 +2213,6 @@
|
||||
"prefs.structure.work": "작업",
|
||||
"prefs.supersampling_brief": "뷰를 더 크게 그린 뒤 줄여서 필터링합니다. 매우 선명하지만 더 무겁습니다.",
|
||||
"prefs.tab_accent_note": "강조 색상이 활성 탭을 따라갑니다",
|
||||
"prefs.tab_double_click_renames": "디자인 탭을 두 번 클릭하여 이름 바꾸기",
|
||||
"prefs.tab_double_click_renames_note": "새 이름을 입력한 뒤 Enter 키를 누르거나 다른 곳을 클릭하면 적용되고, Esc 키를 누르면 취소됩니다. 끄면: 탭을 두 번 클릭할 때 막대의 빈 부분처럼 창이 최대화됩니다.",
|
||||
"prefs.theme_file": "테마 파일",
|
||||
"prefs.theme_follow": "테마 파일 따르기",
|
||||
"prefs.theme_follow_error": "적용되지 않음: {error}. 마지막으로 적용된 정상 테마를 유지합니다.",
|
||||
@@ -2318,9 +2302,6 @@
|
||||
"refused.join": "{body}에 결합하지 못했습니다.",
|
||||
"refused.repair": "부품 복구",
|
||||
"refused.show_face": "면 보기",
|
||||
"render.adaptive.pilot_progress": "적응형 파일럿 · {current}/{target} 패스",
|
||||
"render.adaptive.preview_help": "파일럿 이미지는 예산 배분에 사용되며 최종 이미지에서 제외됩니다. 본 처리는 픽셀마다 별도의 예산을 사용하므로 반복 횟수는 모든 픽셀의 동일한 샘플 수를 뜻하지 않습니다.",
|
||||
"render.adaptive.production_progress": "적응형 본 처리 · {current}/{target} 회",
|
||||
"render.adaptive_absolute": "절대 RGB 오차 하한",
|
||||
"render.adaptive_coverage": "커버리지 오차 목표",
|
||||
"render.adaptive_enable": "독립 파일럿으로 샘플 할당",
|
||||
@@ -2349,7 +2330,6 @@
|
||||
"render.aging_metallic": "레이어 금속성 비율",
|
||||
"render.aging_origin": "필드 원점 · 월드 mm",
|
||||
"render.aging_patina": "구리 녹청",
|
||||
"render.aging_raytrace_help": "에이징은 CPU 광선 추적과 GPU 경로 추적에 적용됩니다. 래스터는 도식적인 렌더링을 유지합니다.",
|
||||
"render.aging_refresh_targets": "현재 바디 검사",
|
||||
"render.aging_roughness": "레이어 거칠기",
|
||||
"render.aging_rust": "산화철 / 녹",
|
||||
@@ -2370,7 +2350,6 @@
|
||||
"render.appearance_editor.closed": "닫힌 솔리드",
|
||||
"render.appearance_editor.coat": "클리어 코트",
|
||||
"render.appearance_editor.cpu_reason": "CPU 광선 추적이 필요합니다.",
|
||||
"render.appearance_editor.discard_saved_edit": "저장하지 않은 광학 편집 버리기",
|
||||
"render.appearance_editor.glass": "유리와 흡수",
|
||||
"render.appearance_editor.glass_enable": "이 바디에 물리 유리 사용",
|
||||
"render.appearance_editor.glass_note": "닫힌 유리는 형상 거리를 사용합니다. 같은 바디에 클리어 코트나 맵을 함께 사용할 수 없습니다.",
|
||||
@@ -2389,7 +2368,7 @@
|
||||
"render.appearance_editor.replace": "이 바디의 저장된 광학 설정 교체",
|
||||
"render.appearance_editor.revalidate": "이 바디 다시 검증",
|
||||
"render.appearance_editor.save": "이 광학 설정 저장",
|
||||
"render.appearance_editor.save_reason": "유리를 저장하려면 현재 CPU 또는 GPU 바디에서 활성화하세요. 클리어 코트는 샷에 코트 저장을 사용하세요. 로컬 이미지 파일은 임시 설정으로 유지됩니다.",
|
||||
"render.appearance_editor.save_reason": "저장하려면 현재 GPU 바디에 물리 유리가 필요합니다. 코트와 로컬 이미지 맵은 임시로 유지됩니다.",
|
||||
"render.appearance_editor.saved": "이 샷에 저장됨",
|
||||
"render.appearance_editor.saved_state": "이 광학 설정은 이 샷과 함께 저장되어 있습니다.",
|
||||
"render.appearance_editor.scatter_reason": "내부 산란에는 닫힌 솔리드 경계가 필요합니다.",
|
||||
@@ -2435,13 +2414,9 @@
|
||||
"render.base_metallic": "금속성 비율",
|
||||
"render.base_opaque_only": "이 편집기는 불투명 바디의 의미를 유지합니다. 광학 투과에는 명시적인 유리 컨트롤을 사용하세요.",
|
||||
"render.batch_all_shots": "모든 샷 ({count})",
|
||||
"render.batch_current_settings_help": "현재 이미지 설정과 저장된 각 샷의 카메라를 사용해 한 번에 한 샷씩 렌더링합니다.",
|
||||
"render.batch_empty": "모든 샷을 렌더링하기 전에 샷을 저장하세요.",
|
||||
"render.batch_limit": "한 번에 최대 32개의 샷을 렌더링할 수 있습니다.",
|
||||
"render.batch_owner_limit": "문서 샷 대기열은 최대 32개까지 유지할 수 있습니다. 기존 대기열을 완료하거나 취소하세요.",
|
||||
"render.batch_preparing": "다음 샷 준비 중",
|
||||
"render.batch_status": "{running}개 실행 중 · {waiting}개 대기 중",
|
||||
"render.batch_waiting_worker": "렌더링 준비 대기 중",
|
||||
"render.bloom_enable": "표시 블룸",
|
||||
"render.bloom_help": "양의 선형 하이라이트를 세 번의 정규화 필터로 확산하는 출력 전용 효과입니다. 반경은 출력 픽셀 단위이며 sRGB 전에 더합니다. 렌즈·볼륨·수렴 개선이 아니며 원본 EXR은 변하지 않습니다.",
|
||||
"render.bloom_opaque": "블룸에는 불투명 출력이 필요합니다. 투명 PNG를 몰래 합성하지 않고 거부합니다.",
|
||||
@@ -2471,16 +2446,6 @@
|
||||
"render.category_paint": "페인트",
|
||||
"render.category_plastic": "플라스틱",
|
||||
"render.category_wood": "목재",
|
||||
"render.caustics_depth": "집광 경로 깊이",
|
||||
"render.caustics_enable": "점광원 및 방향광의 유리 집광",
|
||||
"render.caustics_help": "광자 수는 노이즈와 계산량을 조절합니다. 수집 반경은 세밀한 표현과 부드러운 추정 사이를 조절합니다. CPU에는 간접 조명이 필요합니다.",
|
||||
"render.caustics_path_required": "CPU 집광에는 간접 조명이 필요합니다.",
|
||||
"render.caustics_photons": "광자 수",
|
||||
"render.caustics_radius": "수집 반경",
|
||||
"render.check_glass_cpu": "CPU는 검증된 평면 유리판과 축 정렬 상자를 지원합니다. 일반적인 닫힌 메시, 중첩 및 내부 산란에는 간접광이 필요합니다. 닫힌 유리를 통과하는 투과 그림자는 아직 지원하지 않습니다.",
|
||||
"render.check_glass_gpu": "GPU는 검증된 분리 유리 또는 명시적으로 활성화한 검증된 중첩을 지원합니다. 중첩에는 볼록한 바깥 형상, 완전히 내부에 포함된 안쪽 형상 및 서로 닿지 않는 경계가 필요합니다.",
|
||||
"render.check_media_remaining": "밀도 격자, 변위, 전체 스펙트럼 적분 및 편광 상태 전파는 지원되지 않습니다.",
|
||||
"render.check_media_scope": "활성 내부 산란에는 닫힌 유리 매질이 필요하며 직선 그림자 투과와 노이즈 제거를 꺼야 합니다. GPU에는 프레넬 계층도 하나만 필요합니다. 활성 외부 대기는 유리 또는 표면 노이즈 제거와 함께 사용할 수 없습니다.",
|
||||
"render.check_opaque": "투명도: 불투명 표면만 지원하며 배경 알파를 지원합니다.",
|
||||
"render.check_sorted_alpha": "투명도: 정렬된 알파 근사.",
|
||||
"render.check_transmission": "투명도: 물리적 투과.",
|
||||
@@ -2499,7 +2464,7 @@
|
||||
"render.children.copy_request": "요청 JSON 복사",
|
||||
"render.children.coverage_error": "커버리지 오차",
|
||||
"render.children.cpu": "CPU 레이 트레이싱",
|
||||
"render.children.denoise": "이미지 노이즈 줄이기",
|
||||
"render.children.denoise": "완성 이미지의 노이즈 줄이기",
|
||||
"render.children.depth": "경로 깊이",
|
||||
"render.children.distortion": "왜곡",
|
||||
"render.children.edge_samples": "모서리 샘플",
|
||||
@@ -2562,14 +2527,6 @@
|
||||
"render.choose_look": "먼저 외관 라이브러리에서 외관을 선택하세요",
|
||||
"render.chromatic_offset": "색수차 가장자리 오프셋",
|
||||
"render.clear_stage": "저장된 무대 제거",
|
||||
"render.coat_discard": "코트 편집 버리기",
|
||||
"render.coat_pending_edit": "이 코트 편집은 저장되지 않았습니다. 샷에 저장하거나 편집을 버리세요.",
|
||||
"render.coat_remove": "저장된 코트 제거",
|
||||
"render.coat_remove_note": "이 대상의 코트를 샷에서 제거합니다. 형상, 외관 및 물리 재료는 변경되지 않습니다.",
|
||||
"render.coat_remove_title": "저장된 클리어 코트를 제거할까요?",
|
||||
"render.coat_revalidate_saved": "저장된 코트가 변경되었습니다. 이 편집을 버리고 대상을 다시 연 뒤 편집하세요.",
|
||||
"render.coat_save": "샷에 코트 저장",
|
||||
"render.coat_saved_help": "클리어 코트는 불투명 표면에 광택 층을 추가합니다. 샷에 저장하세요. 같은 대상에 유리를 사용할 수 없습니다. CPU 사용자 지정 반사 또는 이방성 재정의도 이 대상을 함께 사용할 수 없습니다.",
|
||||
"render.compare_divider": "비교 구분선",
|
||||
"render.compare_live": "라이브",
|
||||
"render.compare_live_preview": "라이브 미리보기",
|
||||
@@ -2603,18 +2560,10 @@
|
||||
"render.cpu_budget_triangles": "삼각형 수 상한",
|
||||
"render.cpu_budget_workstation": "워크스테이션 (대규모 장면)",
|
||||
"render.cpu_captured_hdr": "이 이미지용으로 캡처된 HDR",
|
||||
"render.cpu_celestial_adopt": "간접 조명을 사용하는 CPU 레이 트레이싱 · 경로 깊이 {depth} · {spp} SPP · 추적 그림자 · 시드 {seed} · 기본 샘플1개",
|
||||
"render.cpu_celestial_adopt_label": "간접 조명을 사용하는 CPU 레이 트레이싱",
|
||||
"render.cpu_celestial_path_required": "유한 태양과 그라데이션 하늘은 CPU 간접 조명을 사용합니다.",
|
||||
"render.cpu_celestial_switch": "이 태양 또는 하늘을 유지하려면 간접 조명을 사용하는 CPU 레이 트레이싱을 선택하세요.",
|
||||
"render.cpu_celestial_unportable": "현재 투과 샘플 설정에는 GPU 경로 추적을 유지하세요.",
|
||||
"render.cpu_check_capture_help": "검사와 시작은 같은 파일 다이제스트를 확인하거나 포함된 HDR을 사용합니다. 각 이미지는 캡처된 소스를 유지합니다.",
|
||||
"render.cpu_check_changed": "이 검사 후 컨트롤이나 모델이 변경되었습니다. 렌더링 전에 다시 검사하세요.",
|
||||
"render.cpu_constant_direct_help": "간접 조명이 꺼져 있으면 이 일정한 광원은 반사나 유리를 통해 보일 수 있지만 불투명한 표면을 밝히지는 않습니다. 장면 조명으로 사용하려면 간접 조명을 켜세요.",
|
||||
"render.cpu_constant_enable_indirect_plan": "경로 깊이 {depth}, 픽셀당 {spp}개 샘플의 점진적 샘플링(시드 {seed}), 샘플당 기본 광선 1개, 추적된 그림자를 사용합니다. 이미지 크기, CPU 예산 및 다른 설정을 유지합니다.",
|
||||
"render.cpu_constant_help": "상수 방사 휘도는 반사되거나 투과된 광선에 보입니다. 직접 확산 조명은 제공하지 않습니다.",
|
||||
"render.cpu_custom_directions": "프리셋을 사용자 지정 방향성 조명으로 교체",
|
||||
"render.cpu_denoise_help": "이미지가 갱신될 때마다 노이즈를 줄입니다. 미세한 디테일과 피사계 심도 경계가 부드러워질 수 있습니다. 원본 EXR은 바뀌지 않습니다. 완성된 이미지를 조정할 때는 해당 원본 이미지를 사용하며 노이즈 감소를 다시 적용하지 않습니다.",
|
||||
"render.cpu_direction": "광원 방향 (X, Y, Z)",
|
||||
"render.cpu_directional": "방향성 조명",
|
||||
"render.cpu_directional_add": "방향성 조명 추가",
|
||||
@@ -2626,11 +2575,8 @@
|
||||
"render.cpu_environment_samples": "HDR 조명 샘플",
|
||||
"render.cpu_exposure_help": "CPU 노출은 −16~16 EV 사이여야 합니다. 이 범위를 벗어난 GPU 노출은 변경하기 전까지 유지됩니다.",
|
||||
"render.cpu_fixed": "단일 이미지",
|
||||
"render.cpu_fixed_retained": "고정 샘플링 유지",
|
||||
"render.cpu_glass_enable_path": "간접 조명 켜기",
|
||||
"render.cpu_hdr_help": "Radiance .hdr, 2:1 파노라마, 최대 2048 × 1024 및 16 MiB. CPU는 파일에 명시적인 sRGB/D65 원색이 필요하며, 원색이 지정되지 않은 파일과 EXR은 거부됩니다.",
|
||||
"render.cpu_hdr_reflection_help": "HDR 거친 반사에는 완전한 금속성 바디와 면 표면이 필요합니다. 검사가 지원되지 않는 조합을 보고합니다.",
|
||||
"render.cpu_indirect_method_required": "이 간접 조명 모드를 켜려면 CPU 레이 트레이싱을 선택하세요.",
|
||||
"render.cpu_key_rim": "키와 림",
|
||||
"render.cpu_last_check": "마지막 CPU 검사",
|
||||
"render.cpu_light_help": "방향성 조명 최대 여덟 개. 사용자 지정 목록이 비어 있으면 조명이 꺼지고, 프리셋을 선택하면 그 조명이 복원됩니다. 색상은 선형 RGB이고 강도는 와트가 아닌 상대값입니다.",
|
||||
@@ -2642,32 +2588,18 @@
|
||||
"render.cpu_lighting": "조명 프리셋",
|
||||
"render.cpu_lights": "CPU 조명",
|
||||
"render.cpu_linear_color": "선형 색상 (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "CPU 렌더링은 저장한 불투명 표면 맵을 사용할 수 있습니다. 표면을 선택하고 샷에 맵을 저장하세요. 간접광을 사용하면 다른 바디에 유리를 사용할 수 있지만, 맵이 있는 바디에는 유리를 사용할 수 없습니다. 선택 반사, 비등방성 및 절차적 효과는 끄세요. 임시 맵 파일 경로는 GPU를 사용합니다.",
|
||||
"render.cpu_material_certification": "CPU 검사는 렌더링 전에 지원되는 유리 형상을 검증합니다. 닫힌 유리는 기하학적 거리를 사용합니다.",
|
||||
"render.cpu_neutral": "중립",
|
||||
"render.cpu_opaque_hint": "CPU 레이 트레이싱은 불투명한 배경을 사용합니다.",
|
||||
"render.cpu_opaque_required": "CPU 레이 트레이싱에는 불투명 배경이 필요합니다. 계속하려면 투명 배경을 해제하세요.",
|
||||
"render.cpu_options_pending": "이 CPU 패널은 아직 발광, 표면 효과, 유리, 이방성을 편집할 수 없습니다. 사용하기 전에 해당 API 옵션을 해제하세요.",
|
||||
"render.cpu_path_depth": "경로 깊이",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_lighting": "간접 조명",
|
||||
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_point": "점 조명",
|
||||
"render.cpu_point_add": "점 조명 추가",
|
||||
"render.cpu_point_help": "선택한 방향성 조명에 더해지는 점 조명 최대 여덟 개. 거리는 mm 단위이며, 최소 거리는 기준 거리를 넘을 수 없습니다.",
|
||||
"render.cpu_primary_samples": "픽셀당 가장자리 샘플",
|
||||
"render.cpu_progressive": "CPU 프로그레시브",
|
||||
"render.cpu_progressive_adopt": "점진적 샘플링 사용",
|
||||
"render.cpu_progressive_adopt_help": "전환하면 패스마다 카메라 샘플 하나를 사용합니다. 이미지 크기, 조명 및 반사 설정은 유지됩니다.",
|
||||
"render.cpu_progressive_always": "점진적 샘플링은 항상 켜져 있습니다",
|
||||
"render.cpu_progressive_help": "기존 CPU 적분기를 1차 샘플 하나씩 패스로 누적합니다. 선택한 반사에는 샘플 하나가 필요하며, 면 조명, 유전체, 이방성에는 단일 이미지가 필요합니다.",
|
||||
"render.cpu_progressive_limits": "최대 1,048,576픽셀과 16,777,216 픽셀 × 샘플 평가. 샘플을 바꿔도 이미지 크기가 자동으로 조정되지 않습니다.",
|
||||
"render.cpu_progressive_reflection_adopt": "전환하기 전에 선택한 반사의 시도 횟수를 1로 설정하세요.",
|
||||
"render.cpu_progressive_seed": "CPU 샘플링 시드",
|
||||
"render.cpu_progressive_spp": "픽셀당 샘플",
|
||||
"render.cpu_quality_budget_help": "크기, 제작 샘플 상한, 시드와 CPU 리소스 프로필을 변경합니다. 카메라, 렌즈, 조명, GI 깊이와 효과는 유지됩니다.",
|
||||
"render.cpu_quality_progressive_required": "CPU 예산을 선택하기 전에 아래에서 점진적 샘플링을 활성화하세요.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · 샘플 상한 {samples} · 시드 {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "거친 반사 추가",
|
||||
"render.cpu_reflection_depth": "반사 깊이 (1~8)",
|
||||
"render.cpu_reflection_empty": "선택한 바디 없음: 장면 반사가 꺼져 있습니다.",
|
||||
@@ -2681,21 +2613,7 @@
|
||||
"render.cpu_runtime_help": "숫자 조명과 환경 설정은 설정과 함께 저장됩니다. 바디 반사와 HDR 파일은 임시로 유지됩니다. 기존 면 조명과 스폿 원뿔 값은 보존됩니다.",
|
||||
"render.cpu_sampling_mode": "CPU 샘플링 모드",
|
||||
"render.cpu_shadows": "그림자 드리우기",
|
||||
"render.cpu_sky_bounds": "하늘 회전은 ±1,000,000 라디안 이내의 유한 값이어야 하며 강도는 0~100이어야 합니다.",
|
||||
"render.cpu_studio_enable": "이 리그의 간접 조명 켜기",
|
||||
"render.cpu_studio_enable_apply": "간접 조명을 켜고 적용",
|
||||
"render.cpu_studio_indirect_help": "이 태양 리그에는 깊이 8의 간접 조명, 추적된 그림자, 패스당 카메라 샘플 1개가 필요합니다. 점진적 렌더링이 꺼져 있으면 픽셀당 샘플 16개와 시드 0으로 시작합니다. 기존 점진적 설정과 리소스 프로필은 유지됩니다. 호환되지 않는 설정은 변경하지 않고 적용을 중단합니다.",
|
||||
"render.cpu_studio_radiance_limit": "이 리그의 면 광원 방사 휘도 {radiance}은(는) CPU 한도 64를 넘습니다. 작성된 조명은 유지됩니다. 다른 리그나 GPU를 선택하세요.",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU 내부 산란을 사용하려면 노이즈 제거를 끄세요. 설정은 유지됩니다.",
|
||||
"render.cpu_subsurface_help": "산란은 닫힌 유리 내부에서 빛의 방향을 바꿉니다. 양수 비율에는 간접광을 사용하고 노이즈 제거를 끄세요. 비율은 mm당 충돌 횟수이며, 0은 산란을 추가하지 않습니다. 방향 편향이 양수이면 전방, 음수이면 후방을 선호합니다.",
|
||||
"render.cpu_summary": "CPU 레이 트레이싱 · 반사 · HDR · 방향성 및 점 조명",
|
||||
"render.cpu_transparent_help": "투명 PNG는 배경을 제거하고 가장자리 커버리지를 유지합니다. 바디와 면의 불투명도는 100%여야 합니다.",
|
||||
"render.cpu_volume_enable_path": "간접 조명 켜기",
|
||||
"render.cpu_volume_enable_path_plan": "CPU 레이 트레이싱 · 경로 깊이 {depth} · 픽셀당 {spp}샘플 · 시드 {seed} · 레이 트레이싱 그림자. 작성한 대기와 이미지 예산을 유지합니다.",
|
||||
"render.cpu_volume_help": "CPU 대기는 간접 조명을 사용합니다. 소광 계수가 0이면 저장된 상자는 안개 없이 유지됩니다.",
|
||||
"render.cpu_volume_method_adopt": "간접 조명을 사용하는 CPU 레이 트레이싱",
|
||||
"render.cpu_volume_method_incompatible": "이 조명, 샘플링 또는 대기 설정을 이 방식으로 옮길 수 없습니다. 설정은 유지됩니다.",
|
||||
"render.cpu_volume_path_required": "CPU 대기를 사용하려면 간접 조명을 켜세요.",
|
||||
"render.cpu_warm_cool": "따뜻함과 차가움",
|
||||
"render.diag_bodies": "표시된 바디",
|
||||
"render.diag_job_details": "캡처된 작업 세부 정보",
|
||||
@@ -2705,7 +2623,6 @@
|
||||
"render.diag_preflight_details": "사전 점검 세부 정보",
|
||||
"render.diag_request": "정확한 현재 요청",
|
||||
"render.diag_triangles": "삼각형",
|
||||
"render.directional_sun_limit": "GPU는 부드러운 태양 하나를 포함해 방향성 광원을 총 8개까지 지원합니다. 원거리 광원을 추가하려면 선명한 그림자의 방향성 조명을 복제하세요.",
|
||||
"render.discard_continue": "초안을 버리고 계속",
|
||||
"render.draft_confirm_help": "현재 렌더 초안에 적용하지 않은 편집이 있습니다. 전환하기 전에 저장하거나, 명시적으로 버리거나, 계속 편집하세요.",
|
||||
"render.draft_confirm_title": "편집한 렌더 설정 보존",
|
||||
@@ -2855,7 +2772,7 @@
|
||||
"render.export_display": "내보내기: 표시 PNG",
|
||||
"render.export_image_label": "이미지 내보내기",
|
||||
"render.export_linear": "내보내기: 원본 선형 EXR",
|
||||
"render.exr_pending": "CPU 레이 트레이싱과 GPU 패스 트레이싱은 원본 선형 EXR을 보존할 수 있습니다. 압축, 다중 레이어 이미지 및 보조 렌더 패스는 아직 사용할 수 없습니다.",
|
||||
"render.exr_pending": "GPU는 원본 선형 EXR을 보존할 수 있습니다. 압축, 다중 레이어 이미지 및 보조 렌더 패스는 아직 사용할 수 없습니다.",
|
||||
"render.film_grain": "사진 그레인",
|
||||
"render.fit": "제품에 맞추기",
|
||||
"render.fixed_camera": "카메라",
|
||||
@@ -2893,19 +2810,10 @@
|
||||
"render.frame_tall": "9:16 · 세로형",
|
||||
"render.frame_thirds": "삼분할선",
|
||||
"render.frame_wide": "16:9 · 와이드스크린",
|
||||
"render.glass_abbe": "아베 수",
|
||||
"render.glass_dispersion": "색 분산",
|
||||
"render.glass_film": "박막 간섭",
|
||||
"render.glass_film_ior": "박막 굴절률",
|
||||
"render.glass_film_thickness": "박막 두께",
|
||||
"render.glass_model_enable": "고급 유리 모델",
|
||||
"render.glass_model_help": "유리 거칠기는 투과 광선을 산란시킵니다. 분산과 박막 색상은 세 파장 대역을 사용합니다. 거친 얇은 판은 직선 투과 그림자를 꺼야 합니다.",
|
||||
"render.glass_roughness": "유리 거칠기",
|
||||
"render.gpu_depth": "최대 경로 깊이",
|
||||
"render.gpu_options_help": "샘플, 깊이, 시드, 노출은 이 설정과 함께 저장됩니다. 활성 이미지 방식만 저장됩니다.",
|
||||
"render.gpu_path_failed": "GPU 패스 트레이서를 사용할 수 없음",
|
||||
"render.gpu_path_first_setup": "이 GPU에서 첫 설정은 몇 분이 걸릴 수 있으며, 그동안 SolidCraft는 계속 사용할 수 있습니다. 드라이버가 컴파일된 셰이더를 보관하면 이후 시작에서 재사용합니다.",
|
||||
"render.gpu_path_not_started": "GPU 경로 추적기 설정이 아직 시작되지 않았습니다.",
|
||||
"render.gpu_path_preparing": "GPU 패스 트레이서: 셰이더 컴파일 중…",
|
||||
"render.gpu_seed": "난수 시드",
|
||||
"render.gpu_unavailable": "이 장치에서는 GPU 패스 트레이싱을 사용할 준비가 되지 않았습니다.",
|
||||
@@ -3100,8 +3008,6 @@
|
||||
"render.light_product_centre": "제품 중심",
|
||||
"render.light_size": "크기",
|
||||
"render.light_softness": "부드러움",
|
||||
"render.light_space_world": "월드 좌표의 조명",
|
||||
"render.light_space_world_help": "조명의 위치와 방향은 월드 좌표를 사용합니다. 카메라를 회전해도 조명은 움직이지 않습니다.",
|
||||
"render.light_temperature": "색온도",
|
||||
"render.light_temperature_help": "흑체 색상 근사값이며 선형 RGB로 저장됩니다.",
|
||||
"render.light_type": "유형",
|
||||
@@ -3158,25 +3064,6 @@
|
||||
"render.linear_help": "노이즈 제거, 노출, 톤 매핑, 필터, 배경 전의 32비트 부동 소수점 RGBA 마스터를 보존합니다. 선형 sRGB/D65, 커버리지 프리멀티플라이. 추가 메모리는 명시적으로 허용됩니다. 압축이나 추가 패스는 없습니다.",
|
||||
"render.linear_retain": "원본 선형 EXR 보존",
|
||||
"render.linear_unavailable": "선택한 완료 프레임에는 보존된 EXR이 없습니다. 렌더링 전에 보존을 켜세요. 이전 PNG로는 원본 방사 휘도를 복구할 수 없습니다.",
|
||||
"render.live.adaptive": "라이브 · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "라이브 렌더링을 중지하는 동안 렌더 요청이 변경되었습니다. 최종 이미지를 다시 시작하세요.",
|
||||
"render.live.final_waiting": "최종 이미지 준비 중 · 라이브 렌더 리소스 해제 중",
|
||||
"render.live.invalid_size": "라이브 렌더링에는 양수의 출력 너비와 높이가 필요합니다.",
|
||||
"render.live.pause": "라이브 일시 중지",
|
||||
"render.live.paused": "라이브 · 일시 중지됨",
|
||||
"render.live.paused_outdated": "라이브 · 일시 중지됨 · 업데이트 필요",
|
||||
"render.live.quality_help": "대화형 품질: 가장 긴 변은 최대 512픽셀입니다. 선택한 렌더러와 지원하는 효과를 사용합니다. 최종 해상도, 설정 및 내보내기는 별도로 유지됩니다.",
|
||||
"render.live.refused": "라이브 사용 불가: {reason}",
|
||||
"render.live.resume": "라이브 재개",
|
||||
"render.live.samples": "라이브 · {width} × {height} · {current}/{target} 샘플",
|
||||
"render.live.source_current": "{method} · 라이브",
|
||||
"render.live.source_outdated": "{method} · 이전 이미지",
|
||||
"render.live.source_outdated_help": "라이브 렌더링이 업데이트 중이거나 사용할 수 없는 동안 마지막으로 캡처한 이미지를 표시합니다. 현재 장면 또는 이미지 설정과 다를 수 있습니다.",
|
||||
"render.live.source_settings_help": "출력을 열어 렌더러와 이미지 설정을 편집합니다.",
|
||||
"render.live.stopped": "라이브 렌더링 중지됨",
|
||||
"render.live.updating": "라이브 · 대화형 품질(최대 512 px) · 업데이트 중",
|
||||
"render.live.waiting_canvas": "렌더 캔버스 대기 중",
|
||||
"render.live.worker_stopped": "라이브 렌더 캡처 작업이 결과를 반환하기 전에 중지되었습니다",
|
||||
"render.look_add": "컬러 룩 추가",
|
||||
"render.look_applied": "{name} 적용됨",
|
||||
"render.look_bleach": "블리치 바이패스 느낌",
|
||||
@@ -3206,9 +3093,6 @@
|
||||
"render.material_coat_help": "기존 불투명 외관 위의 표현용 유전체 코팅입니다. 가중치가 0이면 기본 외관이 그대로 유지됩니다.",
|
||||
"render.material_color_map": "기본 색상 이미지 (sRGB)",
|
||||
"render.material_color_replace": "기본 맵으로 바디 색상 교체",
|
||||
"render.material_cpu_glass_only": "임시 이미지 파일 맵에는 GPU가 필요합니다. CPU는 저장된 내장 표면 맵을 사용할 수 있습니다.",
|
||||
"render.material_cpu_optics_limits": "지원되는 닫힌 유리 형상, 검증된 중첩 및 내부 산란에는 간접 조명을 켜세요. 얇은 시트는 그림자에 빛을 통과시킬 수 있습니다. 그 외에는 평면 시트나 축 정렬 상자를 사용하세요. 서로 닿는 형상과 닫힌 유리를 통과하는 투과 그림자는 아직 지원하지 않습니다. 값은 유지됩니다.",
|
||||
"render.material_cpu_saved_runtime_conflict": "저장된 CPU 광학 설정은 임시 광학 바인딩과 함께 사용할 수 없습니다. 검토한 설정을 저장하거나 충돌하는 임시 바인딩을 제거하세요.",
|
||||
"render.material_custom": "사용자 지정 표현용 광학",
|
||||
"render.material_enable": "이 GPU 재질 재정의 사용",
|
||||
"render.material_filter": "장면 바디 필터…",
|
||||
@@ -3229,7 +3113,6 @@
|
||||
"render.material_normal_map": "노멀 이미지 (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "노멀 강도 (0~1)",
|
||||
"render.material_origin": "매핑 원점 X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "물리 유리에는 CPU 레이 트레이싱이나 GPU 패스 트레이싱이 필요합니다.",
|
||||
"render.material_refresh": "장면 바디 새로 고침",
|
||||
"render.material_refresh_needed": "장면이 변경되었습니다. 대상을 추가하기 전에 바디 목록을 새로 고치세요.",
|
||||
"render.material_remove": "이 지정 제거",
|
||||
@@ -3239,7 +3122,6 @@
|
||||
"render.material_revalidate_needed": "이 지정은 이전 장면 리비전에 속합니다. 렌더링 전에 정확한 바디 식별을 다시 검증하세요.",
|
||||
"render.material_roughness": "코트 거칠기 (0.045~1)",
|
||||
"render.material_roughness_map": "거칠기 맵 (선형)",
|
||||
"render.material_saved_edit_pending": "광학 편집을 아직 저장하지 않았습니다. 검토한 값을 저장하거나 저장된 할당을 제거하거나 편집을 버리세요. 임시 바인딩과 지원하지 않는 효과는 저장된 대상을 덮어쓸 수 없습니다.",
|
||||
"render.material_shadow": "직선 그림자 광선 투과",
|
||||
"render.material_target_missing": "바디 순번과 이름이 더 이상 일치하지 않습니다. 이 지정을 제거하고 의도한 바디를 명시적으로 다시 선택하세요.",
|
||||
"render.material_thickness": "얇은 판 두께 (mm)",
|
||||
@@ -3251,7 +3133,7 @@
|
||||
"render.method_cpu": "CPU 레이 트레이싱",
|
||||
"render.method_gpu": "GPU 패스 트레이싱",
|
||||
"render.method_limit_raster": "래스터는 고정된 불투명 스튜디오 미리보기를 사용합니다. 지원되는 광학 컨트롤을 쓰려면 명시적인 레이 트레이싱 방식을 선택하세요.",
|
||||
"render.method_raster": "CPU 래스터 (도식)",
|
||||
"render.method_raster": "CPU 래스터",
|
||||
"render.navigation_help": "카메라는 디자인에서와 같이 움직입니다(환경설정 › 탐색). 설정 저장은 현재 카메라를 기록합니다.",
|
||||
"render.nested_enable": "보증된 중첩 유전체",
|
||||
"render.nested_help": "최대 16개의 포함 레이어. 모든 부모는 볼록해야 하고 모든 자식은 부모 안에 완전히 들어가야 합니다. 교차, 맞닿은 표면, 얇은 판, 직선 그림자 투과는 거부됩니다. 카메라 내부 상태는 보증된 계층을 따릅니다. 액체 표면을 만들거나 맞닿은 CAD 경계를 병합하지는 않습니다.",
|
||||
@@ -3279,19 +3161,16 @@
|
||||
"render.output_exr_unavailable": "여기서는 EXR 출력을 설정할 수 없습니다. 선형 EXR 내보내기에는 선형 원본을 보관한 완료된 GPU 이미지가 필요합니다.",
|
||||
"render.output_fit": "맞춤",
|
||||
"render.output_format": "형식",
|
||||
"render.output_format_status": "PNG는 완료된 표시 이미지 그대로입니다. 선택적 CPU/GPU EXR은 표시 필터 전 선형 FLOAT32 방사 휘도를 보존합니다. 미리보기 캐시나 다른 작업으로 바꾸어 내보내지 않습니다.",
|
||||
"render.output_format_status": "PNG는 완료된 표시 이미지 그대로입니다. 선택적 GPU EXR은 표시 필터 전 선형 FLOAT32 방사 휘도를 보존합니다. 미리보기 캐시나 다른 작업으로 바꾸어 내보내지 않습니다.",
|
||||
"render.output_grading": "색 보정",
|
||||
"render.output_image": "이미지",
|
||||
"render.output_largest": "{width} × {height} 최대",
|
||||
"render.output_lock_ratio": "가로세로 비율 고정",
|
||||
"render.output_master_unavailable": "EXR을 내보내려면 원본 EXR 데이터가 있는 완성 이미지를 선택하세요. 새 이미지를 렌더링하기 전에 원본 EXR을 활성화하세요.",
|
||||
"render.output_over_budget": "요청한 이미지가 선택한 리소스 범위를 초과합니다. 호환되는 크기나 프로필을 명시적으로 선택하세요. 자동으로 낮추지 않습니다.",
|
||||
"render.output_presentation_limits": "CPU 이미지 효과와 EXR 출력은 너비와 높이가 각각 최대 8192픽셀이고 총 16,777,216픽셀 이하인 이미지를 지원합니다.",
|
||||
"render.output_presets_help": "형식, 품질 및 검사 목록은 출력 편집기에 있습니다.",
|
||||
"render.output_quality": "품질",
|
||||
"render.output_quality_waiting": "품질 규약 대기 중",
|
||||
"render.output_ratio_refused": "연결된 치수가 이 방식의 크기 제한을 초과합니다. 이전 크기가 유지됩니다. 비율 고정을 풀거나 더 작은 값을 선택하세요.",
|
||||
"render.output_raytrace_only": "이미지 효과에는 CPU 레이 트레이싱 또는 GPU 패스 트레이싱이 필요합니다.",
|
||||
"render.output_ready": "렌더링 준비 완료",
|
||||
"render.output_run_check": "검사 실행",
|
||||
"render.output_size_limit": "{method}: 한 변 최대 {dimension} px, 총 {pixels} 픽셀. 가능한 최대 크기: {width} × {height}.",
|
||||
@@ -3346,12 +3225,6 @@
|
||||
"render.post_saturation": "채도",
|
||||
"render.post_title": "표시 필터",
|
||||
"render.post_vignette": "비네트",
|
||||
"render.preparation_cancelled": "렌더링 준비가 취소되었습니다",
|
||||
"render.preparation_checking": "렌더링 확인 중",
|
||||
"render.preparation_retirement_failed": "렌더링 준비 정리에 실패했습니다. 이 창에서는 다른 이미지를 준비할 수 없습니다.",
|
||||
"render.preparation_retiring": "렌더링 준비 마무리 중",
|
||||
"render.preparation_unavailable": "렌더링 준비를 사용할 수 없음",
|
||||
"render.presentation_banner": "이미지 효과는 CPU 및 GPU 레이 트레이싱에서 사용할 수 있습니다. 이 페이지를 열어도 방식이 바뀌지 않습니다.",
|
||||
"render.preview": "통합 미리보기",
|
||||
"render.preview_help": "래스터 셰이딩과 블렌딩 투명도를 사용합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
|
||||
"render.preview_image_settings_help": "실시간 미리보기는 고정된 스튜디오 조명을 유지합니다. 노출과 이미지 조명은 검사 또는 시작할 때 이미지 작업에 적용됩니다.",
|
||||
@@ -3370,15 +3243,11 @@
|
||||
"render.quality_ack": "고정 예산 변환과 생략 항목 확인",
|
||||
"render.quality_apply": "이 고정 샘플링 예산 사용",
|
||||
"render.quality_balanced": "균형",
|
||||
"render.quality_budget_help": "크기, 샘플 수, 깊이, 시드 및 노이즈 제거를 설정합니다. 다른 이미지 설정은 유지됩니다.",
|
||||
"render.quality_custom": "사용자 지정",
|
||||
"render.quality_denoise_off": "노이즈 제거: 꺼짐",
|
||||
"render.quality_denoise_on": "노이즈 제거: 켜짐",
|
||||
"render.quality_draft": "초안",
|
||||
"render.quality_high": "높음",
|
||||
"render.quality_projection_help": "이 검토에서는 해상도, 샘플 예산, 경로 깊이, 시드 및 노이즈 제거만 변경합니다. 다른 설정은 유지되며 수렴은 보장되지 않습니다.",
|
||||
"render.quality_projection_help": "Q 프리셋에는 향후 요구 사항이 포함됩니다. 이 동작은 지원되는 고정 샘플 PNG 예산만 변환하며, 적응형 중지, 원본 선형 출력, 독립 투과 제한은 명시적으로 생략합니다.",
|
||||
"render.quality_review": "샘플링 예산 검토",
|
||||
"render.quality_summary": "{width} × {height} · 샘플 {samples}개 · 깊이 {depth} · 시드 {seed} · {denoise}",
|
||||
"render.queue_pending": "이 UI에는 자동 프로덕션 대기열이나 애니메이션 렌더러가 없습니다. 완료된 정지 이미지는 별도로 내보낼 수 있습니다.",
|
||||
"render.radial_assign": "외관 지정",
|
||||
"render.radial_compare": "비교",
|
||||
@@ -3408,7 +3277,7 @@
|
||||
"render.result_navigation": "이동과 확대/축소는 탐색 환경 설정을 따릅니다. Space + 드래그로 이동합니다.",
|
||||
"render.result_open": "완료된 이미지를 메인 캔버스에서 엽니다.",
|
||||
"render.result_preview": "실시간 미리보기",
|
||||
"render.result_provisional": "임시 이미지",
|
||||
"render.result_provisional": "임시 CPU 이미지",
|
||||
"render.result_provisional_help": "이 체크포인트는 내보낼 수 없습니다. 내보내기는 마지막 완료 결과를 유지하며, 취소하면 그 결과로 돌아갑니다.",
|
||||
"render.result_reduced_display": "표시 미리보기를 뷰포트 장치 또는 캐시 제한에 맞춰 축소했습니다. 보관된 PNG와 내보내기는 전체 해상도를 유지합니다. 이 축소 보기에서는 원본 100% 검사가 불가능합니다.",
|
||||
"render.result_retained": "마지막 완료 이미지를 표시하고 있습니다. 더 새로운 렌더링이 이를 대체하지 않았습니다.",
|
||||
@@ -3496,11 +3365,9 @@
|
||||
"render.shot_frame_subject": "피사체 맞추기",
|
||||
"render.shot_invalid_camera": "카메라 값은 범위 내의 유한한 수여야 합니다.",
|
||||
"render.shot_lens": "렌즈",
|
||||
"render.shot_lens_cpu_progressive_reason": "CPU 피사계 심도를 켜기 전에 샘플링에서 점진적 샘플링을 적용하세요.",
|
||||
"render.shot_lens_enable_reason": "조리개와 초점 거리를 편집하려면 피사계 심도를 켜세요.",
|
||||
"render.shot_lens_gpu_reason": "피사계 심도에는 GPU 경로 추적이 필요합니다.",
|
||||
"render.shot_lens_perspective_reason": "피사계 심도에는 원근 투영이 필요합니다.",
|
||||
"render.shot_lens_raytrace_reason": "피사계 심도는 CPU 레이 트레이싱과 GPU 패스 트레이싱에서 사용할 수 있습니다.",
|
||||
"render.shot_look_selection": "선택 항목 바라보기",
|
||||
"render.shot_manager": "저장된 샷",
|
||||
"render.shot_new_name": "새 샷 이름",
|
||||
@@ -3534,12 +3401,7 @@
|
||||
"render.sky_horizon": "지평선 · 선형 RGB",
|
||||
"render.sky_zenith": "천정 · 선형 RGB",
|
||||
"render.soft_focus": "소프트 포커스",
|
||||
"render.spot_axis": "빛의 방출 방향",
|
||||
"render.spot_cone": "스폿 원뿔",
|
||||
"render.spot_gpu_help": "각도는 빛의 방출 방향을 기준으로 측정합니다. 안쪽 각도와 바깥쪽 각도 사이에서 빛이 약해집니다. 감쇠 범위가 매우 좁으면 CPU 렌더링이 필요합니다.",
|
||||
"render.spot_help": "각도는 빛의 방출 방향을 기준으로 측정합니다. 안쪽 각도와 바깥쪽 각도 사이에서 빛이 약해집니다.",
|
||||
"render.spot_inner": "안쪽 반각",
|
||||
"render.spot_outer": "바깥쪽 반각",
|
||||
"render.stage_frame": "구도",
|
||||
"render.stage_layout.appearance_note": "클릭하여 선택 항목에 적용합니다.",
|
||||
"render.stage_layout.studio_note": "클릭하여 적용 전에 검토하세요.",
|
||||
@@ -3579,7 +3441,7 @@
|
||||
"render.stage_panels.prepare_plan": "생성 계획 준비",
|
||||
"render.stage_panels.projects": "투영",
|
||||
"render.stage_panels.quality_ack": "고정 예산과 생략 사항을 확인했습니다",
|
||||
"render.stage_panels.quality_omissions": "이 예산은 적응형 오차 목표, 독립적인 투과 한도, 러시안 룰렛 규칙 또는 선형 마스터 보존을 설정하지 않으며 수렴을 보장하지 않습니다.",
|
||||
"render.stage_panels.quality_omissions": "적응형 중지 및 오차 목표, 별도 투과 깊이, 러시안 룰렛 규칙, 원시 선형 출력, EXR 마스터 및 수렴 인증.",
|
||||
"render.stage_panels.reference": "참조",
|
||||
"render.stage_panels.references": "참조",
|
||||
"render.stage_panels.remove_optics": "광학 설정 제거",
|
||||
@@ -3623,7 +3485,7 @@
|
||||
"render.stage_panels.subtitle_environment": "원본 HDR 환경 · 전체 장면",
|
||||
"render.stage_panels.subtitle_maps": "제작된 공간 맵 · 임시 미리 보기",
|
||||
"render.stage_panels.subtitle_physical": "읽기 전용 계획 · 재질 생성 또는 할당 없음",
|
||||
"render.stage_panels.subtitle_quality": "GPU 샘플링 예산 · 표시된 설정만 변경",
|
||||
"render.stage_panels.subtitle_quality": "고정 GPU 예산 · 향후 요구 사항 생략",
|
||||
"render.stage_panels.subtitle_rig": "제작된 조명 리그 · 광원 및 수동 카드",
|
||||
"render.stage_panels.subtitle_saved_maps": "제작된 맵 · 이 샷에 저장",
|
||||
"render.stage_panels.subtitle_stage": "물리적 바닥 및 스테이지 · 제품에 맞춤",
|
||||
@@ -3648,7 +3510,7 @@
|
||||
"render.studio_area_help": "단면 직사각형 최대 네 개. 직교하는 반변 U와 V가 크기를 정하고, U × V가 비추는 쪽을 향합니다. 좌표는 월드 공간 밀리미터입니다.",
|
||||
"render.studio_center": "중심 X, Y, Z (mm)",
|
||||
"render.studio_denoise": "완성 이미지의 노이즈 줄이기",
|
||||
"render.studio_denoise_help": "프로그레시브 이미지와 완성 이미지에 경계를 보존하는 필터를 적용합니다. 미세한 디테일이 부드러워질 수 있습니다. 유리와 날카로운 정반사 표면은 필터를 거치지 않습니다.",
|
||||
"render.studio_denoise_help": "최종 가장자리 인식 필터입니다. 미세한 디테일이 부드러워질 수 있습니다. 유리와 날카로운 정반사 표면은 필터를 거치지 않습니다.",
|
||||
"render.studio_direction": "태양 방향 (X, Y, Z)",
|
||||
"render.studio_environment": "환경",
|
||||
"render.studio_environment_bounds": "환경 회전은 ±1,000,000라디안 이내의 유한한 값이어야 하고 강도는 0과 100 사이여야 합니다.",
|
||||
@@ -3741,15 +3603,11 @@
|
||||
"render.use_preview": "통합 미리보기 사용",
|
||||
"render.volume_albedo": "산란 알베도 · 선형 RGB",
|
||||
"render.volume_anisotropy": "위상 평균 코사인",
|
||||
"render.volume_denoise_required": "대기를 사용하려면 노이즈 감소를 끄세요.",
|
||||
"render.volume_enable": "참여 매질 사용",
|
||||
"render.volume_extinction": "소광 계수",
|
||||
"render.volume_glass_required": "이 설정에서는 대기와 유리를 함께 사용할 수 없습니다.",
|
||||
"render.volume_help": "유한한 상자 하나 안의 GPU 전송으로, 카메라 및 간접 경로에 비어 감쇠와 HG 산란을 적용합니다. 그림자 감쇠를 포함해 명시적 스튜디오/HDR/하늘 조명을 사용합니다. 양의 위상 코사인은 앞으로 산란합니다. 유리 경계와 표면 노이즈 제거는 명시적으로 꺼야 합니다. 소광이 0이면 전송은 바뀌지 않습니다. 물리적 달 대기, 밀도 그리드, 볼륨 발광, 스펙트럼 전송, 보증된 재질 속성이 아닙니다.",
|
||||
"render.volume_lighting_required": "대기를 렌더링하려면 환경 또는 명시적인 조명 구성을 선택하세요.",
|
||||
"render.volume_max": "최대 범위 · 월드 mm",
|
||||
"render.volume_min": "최소 범위 · 월드 mm",
|
||||
"render.volume_single_stratum_required": "대기를 렌더링하려면 투과 샘플을 1로 설정하세요.",
|
||||
"render.volume_title": "제한된 균질 볼륨",
|
||||
"render.wait_add_light": "아직 지원되지 않음: 이름이 있는 조명 만들기",
|
||||
"render.wait_batch": "아직 지원되지 않음: 저장된 샷 렌더 대기열",
|
||||
@@ -3865,7 +3723,6 @@
|
||||
"sketch.dimension_applied": "치수를 적용했습니다",
|
||||
"sketch.dimension_applied_defined": "치수를 적용했습니다. 매개변수 `{name}`을(를) 정의했습니다",
|
||||
"sketch.dimension_applied_renamed": "치수를 적용했습니다. 매개변수 이름을 `{name}`(으)로 지정했습니다",
|
||||
"sketch.divide_hint": "점은 각 곡선을 같은 길이로 나누고 곡선이 바뀌어도 이를 유지합니다.",
|
||||
"sketch.dof_left": "남은 자유도 {count}개",
|
||||
"sketch.dof_left_one": "남은 자유도 {count}개",
|
||||
"sketch.draw_curves": "{linetype}(으)로 그리기",
|
||||
@@ -4677,7 +4534,6 @@
|
||||
"ui.heading_custom_view_navigator": "사용자 지정 뷰 탐색기",
|
||||
"ui.heading_cylinder": "원통",
|
||||
"ui.heading_design": "디자인",
|
||||
"ui.heading_divide_curve": "곡선 분할",
|
||||
"ui.heading_draft": "구배",
|
||||
"ui.heading_edit": "편집",
|
||||
"ui.heading_equation_curve": "방정식 곡선",
|
||||
@@ -4975,7 +4831,6 @@
|
||||
"ui.point_cloud_height_high": "최고 높이",
|
||||
"ui.point_cloud_height_low": "최저 높이",
|
||||
"ui.point_cloud_no_file_colors": "파일에 색상 정보가 없습니다",
|
||||
"ui.point_cloud_reload": "파일에서 다시 불러오기",
|
||||
"ui.point_cloud_single_color": "단색",
|
||||
"ui.point_cloud_size": "점 크기",
|
||||
"ui.point_cloud_units": "파일 단위",
|
||||
@@ -5066,7 +4921,6 @@
|
||||
"ui.second_rail": "두 번째 레일",
|
||||
"ui.section_analysis": "단면 분석",
|
||||
"ui.section_size": "단면 크기",
|
||||
"ui.segments": "세그먼트 수",
|
||||
"ui.select": "선택",
|
||||
"ui.selectOther": "기타 선택…",
|
||||
"ui.selectTangentChain": "접선 체인 선택",
|
||||
|
||||
@@ -433,7 +433,6 @@
|
||||
"Select bodies (a face picks its body)": "Selecione corpos (uma face seleciona seu corpo)",
|
||||
"Select bodies or faces instead": "Selecionar corpos ou faces em vez disso",
|
||||
"Select edges": "Selecione arestas",
|
||||
"Select the curves to divide first": "Selecione primeiro as curvas a dividir",
|
||||
"Selection filter": "Filtro de seleção",
|
||||
"Semantic faces share their appearance across instances.": "Faces semânticas compartilham a aparência entre instâncias.",
|
||||
"Send your report on GitHub": "Envie seu relatório no GitHub",
|
||||
@@ -879,7 +878,6 @@
|
||||
"cmd.sketch_options": "Opções da paleta de esboço",
|
||||
"cmd.sketch_paste": "Colar entidades do esboço",
|
||||
"cmd.sketch_point3d": "Ponto 3D",
|
||||
"cmd.sketch_point_divide": "Dividir curva",
|
||||
"cmd.sketch_polygon_circumscribed": "Polígono circunscrito",
|
||||
"cmd.sketch_polygon_edge": "Polígono por aresta",
|
||||
"cmd.sketch_polygon_inscribed": "Polígono inscrito",
|
||||
@@ -1057,7 +1055,6 @@
|
||||
"help.licences": "Licenças",
|
||||
"help.mit": "Licença MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.sketch.point.divide": "Coloca pontos que dividem as curvas escolhidas em comprimentos iguais. Os pontos ficam em cada curva e mantêm o espaçamento quando ela muda.",
|
||||
"help.solid.chamfer": "Corta as arestas selecionadas com um bisel plano na distância que você definir. Use para quebrar arestas vivas ou facilitar a entrada de um pino ou parafuso.",
|
||||
"help.solid.extrude": "Empurra um perfil de esboço para o 3D ao longo de uma distância, adicionando ou removendo material. Use para transformar um contorno plano em um bloco, um ressalto ou um rebaixo.",
|
||||
"help.solid.fillet": "Arredonda as arestas selecionadas com uma concordância circular suave do raio que você definir. Use para suavizar arestas vivas, por resistência, conforto ou aparência.",
|
||||
@@ -1444,7 +1441,6 @@
|
||||
"shortcut.pin": "Fixar no topo da caixa S",
|
||||
"shortcut.unpin": "Desafixar",
|
||||
"sketch.degrees_of_freedom": "{count} graus de liberdade",
|
||||
"sketch.divide_hint": "Os pontos dividem cada curva em comprimentos iguais e os mantêm quando ela muda.",
|
||||
"sketch.dof_left": "{count} graus de liberdade restantes",
|
||||
"sketch.dof_left_one": "{count} grau de liberdade restante",
|
||||
"sketch.draw_curves": "Desenhar como {linetype}",
|
||||
@@ -2021,7 +2017,6 @@
|
||||
"ui.heading_create_sketch": "CRIAR ESBOÇO",
|
||||
"ui.heading_cylinder": "CILINDRO",
|
||||
"ui.heading_design": "PROJETO",
|
||||
"ui.heading_divide_curve": "DIVIDIR CURVA",
|
||||
"ui.heading_draft": "ÂNGULO DE SAÍDA",
|
||||
"ui.heading_edit": "EDITAR",
|
||||
"ui.heading_extrude": "EXTRUDAR",
|
||||
@@ -2279,7 +2274,6 @@
|
||||
"ui.scale": "Escala",
|
||||
"ui.second_rail": "Segundo trilho",
|
||||
"ui.section_analysis": "Análise de seção",
|
||||
"ui.segments": "Segmentos",
|
||||
"ui.select": "Selecionar",
|
||||
"ui.selectOther": "Selecionar outro…",
|
||||
"ui.selectTangentChain": "Selecionar cadeia tangente",
|
||||
|
||||
@@ -567,7 +567,6 @@
|
||||
"Select edges": "Выберите рёбра",
|
||||
"Select points of the sketch being edited": "Выберите точки редактируемого эскиза",
|
||||
"Select the centre the pattern turns around first": "Сначала выберите центр, вокруг которого поворачивается массив",
|
||||
"Select the curves to divide first": "Сначала выберите кривые для деления",
|
||||
"Select the curves to offset first": "Сначала выберите кривые для смещения",
|
||||
"Select the mirror line": "Выберите линию зеркала",
|
||||
"Select the sketch curves or points to mirror first": "Сначала выберите кривые или точки эскиза для зеркального отражения",
|
||||
@@ -1186,7 +1185,6 @@
|
||||
"cmd.sketch_options": "Параметры палитры эскиза",
|
||||
"cmd.sketch_paste": "Вставить элементы эскиза",
|
||||
"cmd.sketch_point3d": "3D-точка",
|
||||
"cmd.sketch_point_divide": "Разделить кривую",
|
||||
"cmd.sketch_polygon_circumscribed": "Описанный многоугольник",
|
||||
"cmd.sketch_polygon_edge": "Многоугольник по ребру",
|
||||
"cmd.sketch_polygon_inscribed": "Вписанный многоугольник",
|
||||
@@ -1625,7 +1623,6 @@
|
||||
"help.sketch.pattern.circular": "Копирует геометрию эскиза вокруг центральной точки на полный круг или заданный угол. Используйте для окружностей болтов, спиц и зубьев.",
|
||||
"help.sketch.pattern.rectangular": "Копирует геометрию эскиза рядами и столбцами с заданным шагом. Используйте для сеток отверстий, пазов или вентиляционных прорезей.",
|
||||
"help.sketch.point": "Ставит точку эскиза. Используйте как ориентир для размеров, ограничений, центров отверстий или последующих операций.",
|
||||
"help.sketch.point.divide": "Ставит точки, делящие выбранные кривые на равные по длине части. Точки остаются на каждой кривой и сохраняют шаг при её изменении.",
|
||||
"help.sketch.point3d": "Ставит точку в пространстве при включённом 3D-эскизе. Используйте как ориентир для 3D-кривых и траекторий.",
|
||||
"help.sketch.polygon.circumscribed": "Рисует правильный многоугольник вокруг окружности; размер задаётся от центра до середины стороны. Используйте, когда известен размер под ключ, например у шестигранной гайки.",
|
||||
"help.sketch.polygon.edge": "Рисует правильный многоугольник по двум концам одной стороны. Используйте, когда известны длина и положение стороны.",
|
||||
@@ -3843,7 +3840,6 @@
|
||||
"sketch.dimension_applied": "Размер применён",
|
||||
"sketch.dimension_applied_defined": "Размер применён; параметр `{name}` определён",
|
||||
"sketch.dimension_applied_renamed": "Размер применён; параметру присвоено имя `{name}`",
|
||||
"sketch.divide_hint": "Точки делят каждую кривую на равные по длине части и сохраняют их при её изменении.",
|
||||
"sketch.dof_left": "Осталось степеней свободы: {count}",
|
||||
"sketch.dof_left_one": "Осталась {count} степень свободы",
|
||||
"sketch.draw_curves": "Рисовать как {linetype}",
|
||||
@@ -4633,7 +4629,6 @@
|
||||
"ui.heading_create_sketch": "СОЗДАТЬ ЭСКИЗ",
|
||||
"ui.heading_cylinder": "ЦИЛИНДР",
|
||||
"ui.heading_design": "ДИЗАЙН",
|
||||
"ui.heading_divide_curve": "РАЗДЕЛИТЬ КРИВУЮ",
|
||||
"ui.heading_draft": "УКЛОН",
|
||||
"ui.heading_edit": "ИЗМЕНИТЬ",
|
||||
"ui.heading_equation_curve": "КРИВАЯ ПО УРАВНЕНИЮ",
|
||||
@@ -4961,7 +4956,6 @@
|
||||
"ui.second_rail": "Вторая направляющая",
|
||||
"ui.section_analysis": "Анализ сечения",
|
||||
"ui.section_size": "Размер сечения",
|
||||
"ui.segments": "Сегменты",
|
||||
"ui.select": "Выбрать",
|
||||
"ui.selectOther": "Выбрать другое…",
|
||||
"ui.selectTangentChain": "Выбрать касательную цепочку",
|
||||
|
||||
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "Kích thước phôi phẳng X",
|
||||
"Flat size Y": "Kích thước phôi phẳng Y",
|
||||
"Flipped": "Đã lật",
|
||||
"Fold Features": "Thu gọn tính năng",
|
||||
"Follow camera (Look At sketches)": "Đi theo camera (Nhìn vuông góc phác thảo)",
|
||||
"Follow inherited appearance": "Theo diện mạo kế thừa",
|
||||
"Follow material": "Theo vật liệu",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "Kính - Màu khói",
|
||||
"Grid": "Lưới tọa độ",
|
||||
"Groove": "Rãnh",
|
||||
"Group Features": "Nhóm tính năng",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "Tệp HDR đã thay đổi sau khi chọn; hãy kiểm tra và chọn lại trước khi lưu",
|
||||
"HISTORY": "LỊCH SỬ",
|
||||
"Halo under sketch curves": "Vầng sáng dưới đường cong phác thảo",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "Vị trí mặt cắt",
|
||||
"Sections": "Các mặt cắt",
|
||||
"Sections (one sketch each)": "Các mặt cắt (mỗi mặt cắt một phác thảo)",
|
||||
"Select Features": "Chọn tính năng",
|
||||
"Select Other": "Chọn đối tượng khác",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Chọn đối tượng khác: giữ nút chuột trái đứng yên trên mô hình (hoặc nhấp chuột phải › Chọn đối tượng khác…) để liệt kê mọi đối tượng dưới con trỏ; nhấp lại vào cùng vị trí đó để chọn đối tượng kế tiếp. Nhấp đúp chuột giữa để về Góc nhìn chuẩn (Tùy chọn cài đặt › Điều hướng).",
|
||||
"Select Same Look": "Chọn cùng diện mạo",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "Chọn các cạnh",
|
||||
"Select points of the sketch being edited": "Chọn các điểm của phác thảo đang chỉnh sửa",
|
||||
"Select the centre the pattern turns around first": "Hãy chọn tâm mà mảng xoay quanh trước",
|
||||
"Select the curves to divide first": "Hãy chọn các đường cong cần chia trước",
|
||||
"Select the curves to offset first": "Hãy chọn các đường cong cần offset trước",
|
||||
"Select the mirror line": "Chọn đường đối xứng",
|
||||
"Select the sketch curves or points to mirror first": "Hãy chọn các đường cong hoặc điểm phác thảo cần đối xứng trước",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "Tôn tấm",
|
||||
"Sheet Rules": "Quy tắc tấm kim loại",
|
||||
"Show Agent Cursor": "Hiển thị con trỏ của AI Agent",
|
||||
"Show Each Feature": "Hiện từng tính năng",
|
||||
"Show ghost": "Hiển thị bóng mờ",
|
||||
"Show in folder": "Hiển thị trong thư mục",
|
||||
"Show the pointer an AI agent moves (#35)": "Hiển thị con trỏ do AI Agent điều khiển (#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "Chỉnh sửa đám mây điểm",
|
||||
"cmd.pointcloud_insert": "Chèn đám mây điểm",
|
||||
"cmd.pointcloud_list": "Đám mây điểm",
|
||||
"cmd.pointcloud_reload": "Tải lại đám mây điểm",
|
||||
"cmd.render.coat.bind": "Lưu lớp phủ bóng",
|
||||
"cmd.render.coat.unbind": "Xóa lớp phủ bóng",
|
||||
"cmd.render.compare.divider": "Di chuyển vạch so sánh kết xuất",
|
||||
"cmd.render.result.discard": "Bỏ kết quả kết xuất",
|
||||
"cmd.render.result.select": "Chọn kết quả kết xuất",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "Tùy chọn bảng phác thảo",
|
||||
"cmd.sketch_paste": "Dán các thực thể phác thảo",
|
||||
"cmd.sketch_point3d": "3D Điểm",
|
||||
"cmd.sketch_point_divide": "Chia đường cong",
|
||||
"cmd.sketch_polygon_circumscribed": "Đa giác ngoại tiếp",
|
||||
"cmd.sketch_polygon_edge": "Đa giác theo cạnh (Edge Polygon)",
|
||||
"cmd.sketch_polygon_inscribed": "Đa giác nội tiếp",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "Trình duyệt kết xuất",
|
||||
"cmd.view.render_dock": "Di chuyển neo kết xuất",
|
||||
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
|
||||
"cmd.view.render_live": "Tạm dừng hoặc tiếp tục kết xuất trực tiếp",
|
||||
"cmd.view.status_details": "Chi tiết trạng thái khung nhìn",
|
||||
"cmd.view.status_items": "Các mục trạng thái khung nhìn",
|
||||
"cmd.view_ambient_occlusion": "Đổ bóng che khuất môi trường (AO)",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "Đang đọc tệp... {percent}%",
|
||||
"file_load.rebuilding": "Đang dựng lại tính năng {done} trên {total}",
|
||||
"file_load.recovering": "Đang khôi phục thiết kế",
|
||||
"file_load.reloading_cloud": "Đang tải lại đám mây điểm",
|
||||
"file_load.step_build": "Đang dựng mô hình",
|
||||
"file_load.step_feature": "Đặc trưng {done} trong {total}",
|
||||
"file_load.step_part": "Chi tiết {done} trong {total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "Sao chép hình học phác thảo quanh một tâm, trên cả vòng tròn hoặc một góc cho trước. Dùng cho vòng lỗ bu lông, nan hoa và răng.",
|
||||
"help.sketch.pattern.rectangular": "Sao chép hình học phác thảo theo hàng và cột với khoảng cách cho trước. Dùng cho lưới lỗ, rãnh then hoặc khe thông gió.",
|
||||
"help.sketch.point": "Đặt một điểm phác thảo. Dùng làm tham chiếu cho kích thước, ràng buộc, tâm lỗ hoặc các tính năng sau này.",
|
||||
"help.sketch.point.divide": "Đặt các điểm chia các đường cong đã chọn thành những đoạn dài bằng nhau. Các điểm nằm trên mỗi đường cong và giữ khoảng cách khi đường cong thay đổi.",
|
||||
"help.sketch.point3d": "Đặt một điểm trong không gian, khi bật 3D Phác thảo. Dùng làm tham chiếu cho đường cong và đường dẫn 3D.",
|
||||
"help.sketch.polygon.circumscribed": "Vẽ đa giác đều bao quanh một đường tròn, kích thước tính từ tâm đến trung điểm một cạnh. Dùng khi biết khoảng cách giữa hai cạnh đối diện, như với đai ốc lục giác.",
|
||||
"help.sketch.polygon.edge": "Vẽ đa giác đều từ hai đầu mút của một cạnh. Dùng khi biết chiều dài và vị trí của cạnh.",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt+kéo chuột trái để xoay, Alt+chuột giữa để trượt, Alt+chuột phải để thu phóng; kéo chuột giữa cũng trượt",
|
||||
"nav.nx.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để kéo ngang (NX: giữa + phải), Ctrl+chuột giữa để thu phóng (NX: giữa + trái); kéo chuột phải không di chuyển camera",
|
||||
"nav.openscad.help": "Kéo chuột trái trên vùng trống để xoay, Shift + chuột trái để quét chọn vùng, kéo hình học phác thảo để chỉnh sửa; kéo chuột phải hoặc chuột giữa để trượt, Shift + kéo chuột phải hoặc chuột giữa để thu phóng",
|
||||
"nav.orbit_cursor_wrap.help": "Khi thao tác kéo để xoay chạm tới cạnh khung nhìn, con trỏ được chuyển sang cạnh đối diện và tiếp tục xoay. Khi tắt: thao tác kéo dừng lại ở cạnh màn hình. Không có tác dụng trên Wayland hoặc trên trình duyệt.",
|
||||
"nav.orbit_cursor_wrap.label": "Chuyển con trỏ sang cạnh đối diện khi xoay",
|
||||
"nav.perspective.sketch": "Chế độ chỉnh sửa phác thảo sử dụng góc nhìn trực giao. Hãy hoàn tất phác thảo để chuyển sang góc nhìn phối cảnh.",
|
||||
"nav.right_drag_orbits.help": "Tắt: kéo chuột phải không di chuyển camera, và nút phải chỉ mở menu ngữ cảnh. Chọn kiểu chuột SolidWorks sẽ tắt tùy chọn này.",
|
||||
"nav.right_drag_orbits.label": "Kéo chuột phải để xoay",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "Ẩn biểu tượng ràng buộc khi thu nhỏ",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "Biểu tượng ràng buộc biến mất khi bản phác được thu nhỏ đủ xa để không làm rối khung nhìn. Tắt: chúng giữ kích thước màn hình cố định ở mọi mức thu phóng.",
|
||||
"prefs.history": "Lịch sử",
|
||||
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
|
||||
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
|
||||
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
|
||||
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
|
||||
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
|
||||
"prefs.history_origin_bodies_note": "Cây Lịch sử ở bên trái hoặc dưới cây thư mục sẽ bắt đầu với thư mục Thân khối và Gốc tọa độ của cụm chi tiết đang kích hoạt, cùng biểu tượng mắt bật/tắt và trình đơn tương tự như trong cây thư mục. Dòng Lịch sử ở cạnh dưới không thay đổi.",
|
||||
"prefs.history_play_button": "Nút Phát lịch sử trong các điều khiển Lịch sử",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "Hiển thị kích thước của đối tượng được chọn ở góc dưới bên phải khung nhìn, phía trên Lịch sử: chiều dài của cạnh (hoặc Ø và R với cạnh tròn), diện tích của mặt, vị trí của điểm, khoảng cách và góc giữa hai đối tượng, tổng của nhiều cạnh hoặc mặt. Nhấp để sao chép.",
|
||||
"prefs.remember_dialog_values": "Ghi nhớ giá trị hộp thoại lệnh",
|
||||
"prefs.remember_dialog_values.help": "Tắt theo mặc định. Khi bật, sau khi lệnh thành công, các giá trị và tùy chọn an toàn của lệnh sẽ được dùng lại vào lần tiếp theo bạn mở cùng lệnh đó, cho đến khi SolidCraft đóng.",
|
||||
"prefs.render_legacy_image_defaults": "Dùng mặc định ảnh Kết xuất trước đây",
|
||||
"prefs.render_legacy_image_defaults_help": "Giữ quy trình hình ảnh thủ công trước đây: thiết lập mới dùng raster CPU, dò tia CPU dùng lấy mẫu cố định và mở kết quả thủ công. Căn vừa lại khung nhìn khi vào Kết xuất, chuyển góc chụp mà không khôi phục camera đã lưu, đồng thời dùng cách lấy khung hình và điều hướng trước đây. Tắt phản hồi ảnh trực tiếp tự động. Các thiết lập đã lưu không thay đổi.",
|
||||
"prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất",
|
||||
"prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.",
|
||||
"prefs.reports_saved": "Đã lưu {n}",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "Công việc",
|
||||
"prefs.supersampling_brief": "Vẽ khung nhìn ở độ phân giải lớn hơn rồi lọc giảm tỷ lệ: rất sắc nét, nhưng tốn tài nguyên hơn.",
|
||||
"prefs.tab_accent_note": "Màu nhấn theo tab đang kích hoạt",
|
||||
"prefs.tab_double_click_renames": "Nhấp đúp vào thẻ thiết kế để đổi tên",
|
||||
"prefs.tab_double_click_renames_note": "Nhập tên mới, rồi nhấn Enter hoặc nhấp ra chỗ khác để giữ, hoặc Esc để hủy. Khi tắt: nhấp đúp vào thẻ sẽ phóng to cửa sổ, như ở phần trống của thanh.",
|
||||
"prefs.theme_file": "tệp chủ đề",
|
||||
"prefs.theme_file_or_paste": "tệp hoặc dán",
|
||||
"prefs.theme_follow": "Theo dõi tệp chủ đề",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "Không thể nối vào {body}.",
|
||||
"refused.repair": "Sửa chi tiết",
|
||||
"refused.show_face": "Hiện mặt",
|
||||
"render.adaptive.pilot_progress": "Các lượt thăm dò thích ứng · {current}/{target}",
|
||||
"render.adaptive.preview_help": "Ảnh thăm dò định hướng việc phân bổ mẫu và không được đưa vào ảnh cuối cùng. Các vòng kết xuất sử dụng ngân sách riêng cho từng pixel; số vòng không phải số mẫu đồng đều trên mỗi pixel.",
|
||||
"render.adaptive.production_progress": "Kết xuất thích ứng · {current}/{target} vòng lặp",
|
||||
"render.adaptive_absolute": "Ngưỡng lỗi RGB tuyệt đối",
|
||||
"render.adaptive_coverage": "Mục tiêu lỗi độ phủ (Coverage error)",
|
||||
"render.adaptive_enable": "Phân bổ mẫu bằng các mẫu thử độc lập",
|
||||
"render.adaptive_help": "Các mẫu thăm dò độc lập được loại bỏ, sau đó chọn ít nhất 16 mẫu sản xuất cho mỗi điểm ảnh, tối đa bằng Số mẫu mỗi điểm ảnh. Giới hạn tài nguyên CPU bao gồm cả hai giai đoạn. Phương sai RGB và độ phủ hướng dẫn phân bổ; điểm ảnh đạt trần vẫn có thể nhiễu. Báo cáo cuối đếm số mẫu thực tế.",
|
||||
"render.adaptive_help": "Chỉ dành cho GPU. Các mẫu thử độc lập sẽ bị loại bỏ, sau đó phân bổ tối thiểu 16 mẫu chính thức trên mỗi pixel cho đến khi đạt giới hạn mẫu. Phương sai RGB và độ phủ chỉ dùng để định hướng phân bổ; các đường truyền tia hiếm có thể bị bỏ sót và pixel đạt giới hạn vẫn có thể còn nhiễu. Báo cáo cuối cùng ghi nhận khối lượng tính toán thực tế. Không đảm bảo tuyệt đối về độ nhiễu hay thời gian.",
|
||||
"render.adaptive_minimum": "Số mẫu chính thức tối thiểu",
|
||||
"render.adaptive_pending": "Phân bổ thích ứng bằng mẫu thăm dò độc lập có trên CPU và GPU. Các tầng Fresnel có điều kiện cần GPU. Phân bổ thống kê không chứng nhận sự hội tụ.",
|
||||
"render.adaptive_pending": "GPU hỗ trợ phân bổ thích ứng qua mẫu thử độc lập và phân tầng Fresnel có điều kiện. Chưa hỗ trợ kiểm định hội tụ, đảm bảo sự kiện hiếm và truyền dẫn thích ứng trên CPU.",
|
||||
"render.adaptive_pilots": "Số mẫu thử trên mỗi pixel",
|
||||
"render.adaptive_relative": "Mục tiêu lỗi RGB tương đối",
|
||||
"render.add_ground": "Thêm mặt sàn khớp vào thiết lập",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "Tỷ lệ kim loại của lớp",
|
||||
"render.aging_origin": "Gốc trường tọa độ · mm thế giới",
|
||||
"render.aging_patina": "Gỉ đồng xanh (Patina)",
|
||||
"render.aging_raytrace_help": "Hiệu ứng lão hoá được áp dụng khi dò tia bằng CPU và dò đường đi bằng GPU. Kết xuất raster giữ kiểu sơ đồ.",
|
||||
"render.aging_refresh_targets": "Kiểm tra các khối hiện tại",
|
||||
"render.aging_roughness": "Độ nhám của lớp",
|
||||
"render.aging_rust": "Sắt oxy hóa / rỉ sét",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "Khối đặc kín",
|
||||
"render.appearance_editor.coat": "Lớp phủ trong",
|
||||
"render.appearance_editor.cpu_reason": "Cần dò tia bằng CPU.",
|
||||
"render.appearance_editor.discard_saved_edit": "Hủy chỉnh sửa quang học chưa lưu",
|
||||
"render.appearance_editor.glass": "Kính và hấp thụ",
|
||||
"render.appearance_editor.glass_enable": "Dùng kính vật lý trên thân này",
|
||||
"render.appearance_editor.glass_note": "Kính kín dùng khoảng cách hình học. Không thể dùng chung thân với lớp phủ trong hoặc bản đồ.",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "Thay thuộc tính quang học đã lưu của thân này",
|
||||
"render.appearance_editor.revalidate": "Kiểm tra lại thân này",
|
||||
"render.appearance_editor.save": "Lưu các thuộc tính quang học này",
|
||||
"render.appearance_editor.save_reason": "Để lưu Kính, hãy bật nó trên thân CPU hoặc GPU hiện hành. Với lớp phủ bóng, dùng Lưu lớp phủ vào cảnh chụp. Các tệp ảnh cục bộ vẫn tạm thời.",
|
||||
"render.appearance_editor.save_reason": "Lưu các thuộc tính quang học này cần kính vật lý trên một thân GPU hiện hành. Lớp phủ và bản đồ ảnh cục bộ vẫn tạm thời.",
|
||||
"render.appearance_editor.saved": "Lưu cùng cảnh chụp này",
|
||||
"render.appearance_editor.saved_state": "Các thuộc tính quang học này được lưu cùng cảnh chụp này.",
|
||||
"render.appearance_editor.scatter_reason": "Tán xạ bên trong cần biên là khối đặc kín.",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "Tỷ lệ kim loại",
|
||||
"render.base_opaque_only": "Trình chỉnh sửa này giữ nguyên ngữ nghĩa khối đục. Dùng các điều khiển thủy tinh riêng biệt để chỉnh độ truyền quang học.",
|
||||
"render.batch_all_shots": "Mọi cảnh chụp ({count})",
|
||||
"render.batch_current_settings_help": "Dùng thiết lập ảnh hiện tại với máy ảnh của từng góc chụp đã lưu, lần lượt từng góc chụp.",
|
||||
"render.batch_empty": "Hãy lưu một cảnh chụp trước khi kết xuất mọi cảnh chụp.",
|
||||
"render.batch_limit": "Kết xuất mọi cảnh chụp giới hạn ở 32 cảnh chụp.",
|
||||
"render.batch_owner_limit": "Chỉ có thể giữ hàng đợi cảnh của tối đa 32 tài liệu. Hãy hoàn tất hoặc hủy một hàng đợi hiện có trước.",
|
||||
"render.batch_preparing": "Đang chuẩn bị cảnh tiếp theo",
|
||||
"render.batch_status": "{running} đang chạy · {waiting} đang chờ",
|
||||
"render.batch_waiting_worker": "Đang chờ chuẩn bị kết xuất",
|
||||
"render.bloom_enable": "Hiển thị lóe sáng (Bloom)",
|
||||
"render.bloom_help": "Hiệu ứng phát sáng sáng tạo cho đầu ra từ các điểm nhấn tuyến tính dương, được khuếch tán qua ba lượt lọc chuẩn hóa. Bán kính tính theo pixel đầu ra. Được cộng vào ánh sáng tuyến tính hiển thị trước sRGB; không phải ống kính thực tế, hiệu ứng thể tích hay cải thiện hội tụ. Tệp EXR thô không đổi.",
|
||||
"render.bloom_opaque": "Hiệu ứng lóe sáng yêu cầu đầu ra đục. PNG trong suốt sẽ bị từ chối chứ không âm thầm làm phẳng nền.",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "Sơn",
|
||||
"render.category_plastic": "Nhựa",
|
||||
"render.category_wood": "Gỗ",
|
||||
"render.caustics_depth": "Độ sâu đường tụ quang",
|
||||
"render.caustics_enable": "Tụ quang qua kính từ nguồn điểm và nguồn định hướng",
|
||||
"render.caustics_help": "Số photon điều chỉnh nhiễu và khối lượng tính toán. Bán kính thu thập đánh đổi chi tiết sắc nét để có ước lượng mượt hơn. CPU cần chiếu sáng gián tiếp.",
|
||||
"render.caustics_path_required": "Tụ quang trên CPU cần chiếu sáng gián tiếp.",
|
||||
"render.caustics_photons": "Số photon",
|
||||
"render.caustics_radius": "Bán kính thu thập",
|
||||
"render.check_glass_cpu": "CPU hỗ trợ các tấm kính phẳng và hộp thẳng theo trục đã được kiểm chứng. Lưới kín tổng quát, cấu trúc lồng nhau và tán xạ bên trong cần Chiếu sáng gián tiếp. Chưa hỗ trợ bóng truyền qua kính kín.",
|
||||
"render.check_glass_gpu": "GPU hỗ trợ các khối kính tách rời đã được kiểm chứng hoặc cấu trúc lồng nhau được kiểm chứng và bật rõ ràng. Cấu trúc lồng nhau cần lớp bao lồi, vật thể con nằm hoàn toàn bên trong và các ranh giới không chạm nhau.",
|
||||
"render.check_media_remaining": "Lưới mật độ, dịch chuyển bề mặt, tích phân toàn phổ và truyền trạng thái phân cực vẫn chưa được hỗ trợ.",
|
||||
"render.check_media_scope": "Tán xạ bên trong đang hoạt động cần môi trường kính kín, tắt truyền tia bóng thẳng và tắt Khử nhiễu. GPU cũng cần một tầng Fresnel. Không thể kết hợp Khí quyển bên ngoài đang hoạt động với kính hoặc Khử nhiễu bề mặt.",
|
||||
"render.check_opaque": "Độ trong suốt: chỉ bề mặt đục; hỗ trợ alpha của phông nền.",
|
||||
"render.check_sorted_alpha": "Độ trong suốt: xấp xỉ alpha có sắp thứ tự.",
|
||||
"render.check_transmission": "Độ trong suốt: truyền sáng vật lý.",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "Sao chép JSON yêu cầu",
|
||||
"render.children.coverage_error": "Sai số độ phủ",
|
||||
"render.children.cpu": "Dò tia bằng CPU",
|
||||
"render.children.denoise": "Giảm nhiễu ảnh",
|
||||
"render.children.denoise": "Giảm nhiễu trong ảnh hoàn chỉnh",
|
||||
"render.children.depth": "Độ sâu đường đi",
|
||||
"render.children.distortion": "Méo",
|
||||
"render.children.edge_samples": "Mẫu cạnh",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "Hãy chọn một diện mạo trong Diện mạo trước",
|
||||
"render.chromatic_offset": "Độ lệch sắc sai viền",
|
||||
"render.clear_stage": "Xóa sân khấu đã lưu",
|
||||
"render.coat_discard": "Hủy chỉnh sửa lớp phủ",
|
||||
"render.coat_pending_edit": "Chỉnh sửa lớp phủ này chưa được lưu. Hãy lưu cùng cảnh chụp hoặc hủy chỉnh sửa.",
|
||||
"render.coat_remove": "Xóa lớp phủ đã lưu",
|
||||
"render.coat_remove_note": "Xóa lớp phủ của đích này khỏi cảnh chụp. Hình học, diện mạo và vật liệu vật lý không thay đổi.",
|
||||
"render.coat_remove_title": "Xóa lớp phủ bóng đã lưu?",
|
||||
"render.coat_revalidate_saved": "Lớp phủ đã lưu đã thay đổi. Hãy hủy chỉnh sửa này và mở lại đích trước khi sửa tiếp.",
|
||||
"render.coat_save": "Lưu lớp phủ vào cảnh chụp",
|
||||
"render.coat_saved_help": "Lớp phủ bóng thêm một lớp sáng bóng lên bề mặt đục. Hãy lưu cùng cảnh chụp. Kính không thể dùng chung đích này. Ghi đè phản xạ tùy chỉnh và tính dị hướng CPU cũng không thể dùng chung đích.",
|
||||
"render.compare_divider": "Vạch so sánh",
|
||||
"render.compare_live": "Trực tiếp",
|
||||
"render.compare_live_preview": "Xem trước trực tiếp",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "Trần số tam giác",
|
||||
"render.cpu_budget_workstation": "Máy trạm (cảnh nặng)",
|
||||
"render.cpu_captured_hdr": "HDR đã ghi nhận cho hình ảnh này",
|
||||
"render.cpu_celestial_adopt": "Dò tia CPU với chiếu sáng gián tiếp · độ sâu đường đi {depth} · {spp} SPP · bóng đổ dò tia · seed {seed} · 1 mẫu chính",
|
||||
"render.cpu_celestial_adopt_label": "Dò tia CPU với chiếu sáng gián tiếp",
|
||||
"render.cpu_celestial_path_required": "Mặt Trời hữu hạn và bầu trời chuyển sắc dùng chiếu sáng gián tiếp CPU.",
|
||||
"render.cpu_celestial_switch": "Chọn dò tia CPU với chiếu sáng gián tiếp để giữ Mặt Trời hoặc bầu trời này.",
|
||||
"render.cpu_celestial_unportable": "Giữ dò đường đi GPU cho cài đặt mẫu truyền sáng hiện tại.",
|
||||
"render.cpu_check_capture_help": "Kiểm tra và Bắt đầu sẽ xác minh cùng mã băm tệp hoặc dùng HDR nhúng. Mỗi ảnh giữ nguyên nguồn dữ liệu đã ghi nhận.",
|
||||
"render.cpu_check_changed": "Các cài đặt hoặc mô hình đã thay đổi sau lần kiểm tra này. Hãy kiểm tra lại trước khi kết xuất.",
|
||||
"render.cpu_constant_direct_help": "Khi tắt Chiếu sáng gián tiếp, nguồn sáng đồng nhất này có thể xuất hiện trong phản xạ và qua Kính nhưng không chiếu sáng các bề mặt đục. Bật Chiếu sáng gián tiếp để dùng nguồn này chiếu sáng cảnh.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Độ sâu đường đi {depth}; lấy mẫu tăng dần với {spp} mẫu/pixel (hạt giống {seed}); một tia sơ cấp cho mỗi mẫu; bóng đổ dò tia. Giữ kích thước ảnh, ngân sách CPU và các cài đặt khác.",
|
||||
"render.cpu_constant_help": "Suất bức xạ không đổi tác dụng lên tia phản xạ hoặc tia truyền qua. Nó không cung cấp ánh sáng khuếch tán trực tiếp.",
|
||||
"render.cpu_custom_directions": "Thay thế thiết lập sẵn bằng nguồn sáng định hướng tùy chỉnh",
|
||||
"render.cpu_denoise_help": "Giảm nhiễu mỗi khi ảnh được cập nhật. Có thể làm mềm chi tiết nhỏ và biên vùng mờ do độ sâu trường ảnh. EXR thô không thay đổi. Khi điều chỉnh ảnh đã hoàn tất, hệ thống dùng ảnh thô của ảnh đó và không áp dụng giảm nhiễu lần nữa.",
|
||||
"render.cpu_direction": "Hướng về phía nguồn sáng (X, Y, Z)",
|
||||
"render.cpu_directional": "Nguồn sáng định hướng",
|
||||
"render.cpu_directional_add": "Thêm nguồn sáng định hướng",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "Số mẫu ánh sáng HDR",
|
||||
"render.cpu_exposure_help": "Độ phơi sáng CPU phải từ −16 đến 16 EV. Độ phơi sáng GPU nằm ngoài khoảng này sẽ được giữ lại cho đến khi bạn thay đổi.",
|
||||
"render.cpu_fixed": "Hình ảnh đơn",
|
||||
"render.cpu_fixed_retained": "Đã giữ lấy mẫu cố định",
|
||||
"render.cpu_glass_enable_path": "Bật Chiếu sáng gián tiếp",
|
||||
"render.cpu_hdr_help": "Định dạng Radiance .hdr, toàn cảnh 2:1, tối đa 2048 × 1024 và 16 MiB. CPU yêu cầu tệp công bố rõ các màu cơ bản sRGB/D65; các màu cơ bản chưa xác định và tệp EXR sẽ bị từ chối.",
|
||||
"render.cpu_hdr_reflection_help": "Phản xạ nhám HDR yêu cầu bề mặt khối và bề mặt mặt hoàn toàn bằng kim loại. Lệnh kiểm tra sẽ báo cáo các kết hợp không được hỗ trợ.",
|
||||
"render.cpu_indirect_method_required": "Chọn dò tia CPU để bật chế độ Chiếu sáng gián tiếp này.",
|
||||
"render.cpu_key_rim": "Nguồn sáng chính và sáng ven (Key & Rim)",
|
||||
"render.cpu_last_check": "Lần kiểm tra CPU gần nhất",
|
||||
"render.cpu_light_help": "Tối đa tám nguồn sáng định hướng. Danh sách tùy chỉnh trống sẽ tắt nguồn sáng; chọn thiết lập sẵn sẽ khôi phục các đèn tương ứng. Màu sắc ở dạng RGB tuyến tính và cường độ là giá trị tương đối, không phải Oát.",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "Thiết lập sẵn chiếu sáng",
|
||||
"render.cpu_lights": "Nguồn sáng CPU",
|
||||
"render.cpu_linear_color": "Màu tuyến tính (R, G, B)",
|
||||
"render.cpu_maps_saved_help": "Kết xuất CPU hỗ trợ bản đồ bề mặt đục đã lưu: chọn bề mặt rồi lưu bản đồ vào ảnh chụp. Với chiếu sáng gián tiếp, thân khác có thể dùng kính; thân có bản đồ không thể dùng kính. Tắt phản xạ được chọn, dị hướng và hiệu ứng thủ tục. Đường dẫn tạm tới tệp bản đồ dùng GPU.",
|
||||
"render.cpu_material_certification": "Kiểm tra trên CPU xác thực hình học thủy tinh được hỗ trợ trước khi kết xuất. Thủy tinh kín sử dụng khoảng cách hình học.",
|
||||
"render.cpu_neutral": "Trung tính",
|
||||
"render.cpu_opaque_hint": "Dò tia trên CPU sử dụng nền không trong suốt.",
|
||||
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
|
||||
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
|
||||
"render.cpu_path_depth": "Độ sâu đường truyền",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
|
||||
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_point": "Nguồn sáng điểm",
|
||||
"render.cpu_point_add": "Thêm nguồn sáng điểm",
|
||||
"render.cpu_point_help": "Tối đa tám nguồn sáng điểm, được bổ sung vào ánh sáng định hướng đã chọn. Khoảng cách tính bằng mm; khoảng cách tối thiểu không được vượt quá khoảng cách tham chiếu.",
|
||||
"render.cpu_primary_samples": "Số mẫu cạnh trên mỗi pixel",
|
||||
"render.cpu_progressive": "Kết xuất lũy tiến trên CPU",
|
||||
"render.cpu_progressive_adopt": "Dùng lấy mẫu lũy tiến",
|
||||
"render.cpu_progressive_adopt_help": "Lấy mẫu lũy tiến sử dụng một mẫu máy ảnh cho mỗi lượt. Kích thước ảnh, nguồn sáng và thiết lập phản xạ không thay đổi.",
|
||||
"render.cpu_progressive_always": "Lấy mẫu lũy tiến luôn bật",
|
||||
"render.cpu_progressive_help": "Tích lũy các mẫu máy ảnh CPU mới và hiển thị từng lượt hoàn chỉnh. Phản xạ được chọn dùng một lần thử cho mỗi mẫu máy ảnh; kính được chứng nhận và dị hướng phẳng giữ các giới hạn hiện có. Mẫu thăm dò và mẫu sản xuất thích ứng dùng chung ngân sách cấu hình CPU.",
|
||||
"render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; nguồn sáng vùng, chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
|
||||
"render.cpu_progressive_limits": "Tối đa 1.048.576 pixel và tổng số tính toán (pixel × mẫu) không quá 16.777.216. Thay đổi số mẫu không bao giờ tự động đổi kích thước ảnh.",
|
||||
"render.cpu_progressive_reflection_adopt": "Đặt số lần thử phản xạ đã chọn thành 1 trước khi chuyển sang lấy mẫu lũy tiến.",
|
||||
"render.cpu_progressive_seed": "Hạt giống lấy mẫu CPU",
|
||||
"render.cpu_progressive_spp": "Mẫu trên mỗi pixel",
|
||||
"render.cpu_quality_budget_help": "Thay đổi kích thước, trần mẫu sản xuất, hạt giống và cấu hình tài nguyên CPU. Giữ nguyên máy ảnh, ống kính, ánh sáng, độ sâu GI và hiệu ứng.",
|
||||
"render.cpu_quality_progressive_required": "Bật lấy mẫu tiến dần bên dưới trước khi chọn ngân sách CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · trần mẫu {samples} · hạt giống {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "Thêm phản xạ nhám",
|
||||
"render.cpu_reflection_depth": "Độ sâu phản xạ (1–8)",
|
||||
"render.cpu_reflection_empty": "Chưa chọn khối nào: phản xạ khung cảnh đang tắt.",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "Cài đặt số của ánh sáng và môi trường được lưu cùng thiết lập. Phản xạ khối và tệp HDR vẫn là cài đặt tạm thời. Giá trị nguồn sáng vùng và góc côn nguồn sáng tụ hiện có được giữ lại.",
|
||||
"render.cpu_sampling_mode": "Chế độ lấy mẫu CPU",
|
||||
"render.cpu_shadows": "Đổ bóng",
|
||||
"render.cpu_sky_bounds": "Góc xoay bầu trời phải hữu hạn trong ±1.000.000 radian; cường độ phải từ 0–100.",
|
||||
"render.cpu_studio_enable": "Bật chiếu sáng gián tiếp cho bộ đèn này",
|
||||
"render.cpu_studio_enable_apply": "Bật chiếu sáng gián tiếp và áp dụng",
|
||||
"render.cpu_studio_indirect_help": "Bộ đèn Mặt Trời này cần chiếu sáng gián tiếp với độ sâu 8, bóng được truy vết và một mẫu máy ảnh mỗi lượt. Nếu kết xuất tăng dần đang tắt, bắt đầu với 16 mẫu mỗi điểm ảnh và hạt giống 0. Giữ nguyên thiết lập tăng dần và hồ sơ tài nguyên hiện có. Thiết lập không tương thích sẽ ngăn áp dụng mà không thay đổi chúng.",
|
||||
"render.cpu_studio_radiance_limit": "Độ chói bức xạ đèn diện tích của bộ này là {radiance}, vượt giới hạn CPU 64. Giữ nguyên ánh sáng đã tạo; hãy chọn bộ đèn khác hoặc GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "Tán xạ bên trong kính trên CPU cần tắt Khử nhiễu. Các cài đặt được giữ nguyên.",
|
||||
"render.cpu_subsurface_help": "Tán xạ đổi hướng ánh sáng bên trong kính kín. Tốc độ dương cần Chiếu sáng gián tiếp và tắt Khử nhiễu. Tốc độ là số va chạm mỗi mm; 0 không thêm tán xạ. Độ lệch hướng dương ưu tiên phía trước, âm ưu tiên phía sau.",
|
||||
"render.cpu_summary": "Dò tia CPU · phản xạ · HDR · nguồn sáng định hướng và điểm",
|
||||
"render.cpu_transparent_help": "PNG trong suốt loại bỏ nền và giữ độ phủ ở biên. Độ đục của thân và mặt phải giữ ở 100%.",
|
||||
"render.cpu_volume_enable_path": "Bật chiếu sáng gián tiếp",
|
||||
"render.cpu_volume_enable_path_plan": "Dò tia CPU · độ sâu đường đi {depth} · {spp} mẫu/pixel · hạt giống {seed} · bóng dò tia. Giữ nguyên khí quyển và ngân sách ảnh đã đặt.",
|
||||
"render.cpu_volume_help": "Khí quyển trên CPU dùng chiếu sáng gián tiếp. Hệ số suy giảm bằng không vẫn giữ hộp đã lưu mà không tạo sương.",
|
||||
"render.cpu_volume_method_adopt": "Dò tia CPU với chiếu sáng gián tiếp",
|
||||
"render.cpu_volume_method_incompatible": "Không thể chuyển các thiết lập đèn, lấy mẫu hoặc khí quyển này sang phương pháp này. Thiết lập của bạn được giữ nguyên.",
|
||||
"render.cpu_volume_path_required": "Bật chiếu sáng gián tiếp để dùng Khí quyển trên CPU.",
|
||||
"render.cpu_warm_cool": "Ấm và lạnh",
|
||||
"render.diag_bodies": "Số khối hiển thị",
|
||||
"render.diag_job_details": "Chi tiết tác vụ đã ghi nhận",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "Chi tiết kiểm tra sơ bộ",
|
||||
"render.diag_request": "Yêu cầu chính xác hiện tại",
|
||||
"render.diag_triangles": "Số tam giác",
|
||||
"render.directional_sun_limit": "GPU hỗ trợ tổng cộng tám nguồn sáng định hướng, trong đó có tối đa một Mặt trời với bóng mềm. Nhân đôi đèn định hướng có bóng sắc để thêm nguồn sáng ở xa.",
|
||||
"render.discard_continue": "Hủy bản nháp và tiếp tục",
|
||||
"render.draft_confirm_help": "Bản nháp kết xuất hiện tại có các chỉnh sửa chưa được áp dụng. Hãy lưu trước khi chuyển, hủy bỏ dứt khoát hoặc tiếp tục chỉnh sửa.",
|
||||
"render.draft_confirm_title": "Giữ lại cài đặt kết xuất đã chỉnh sửa",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "Xuất: ảnh PNG hiển thị",
|
||||
"render.export_image_label": "Xuất ảnh",
|
||||
"render.export_linear": "Xuất: ảnh EXR tuyến tính thô",
|
||||
"render.exr_pending": "Dò tia trên CPU và dò đường đi trên GPU có thể giữ lại EXR tuyến tính thô. Tính năng nén, ảnh nhiều lớp và các lượt kết xuất phụ trợ vẫn chưa khả dụng.",
|
||||
"render.exr_pending": "GPU có thể giữ lại tệp EXR tuyến tính thô. Tính năng nén, ảnh nhiều lớp và các kênh kết xuất phụ trợ vẫn chưa được triển khai.",
|
||||
"render.film_grain": "Hạt nhiễu nhiếp ảnh (Film Grain)",
|
||||
"render.fit": "Thu phóng vừa sản phẩm",
|
||||
"render.fixed_camera": "Máy ảnh",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · Dọc",
|
||||
"render.frame_thirds": "Một phần ba",
|
||||
"render.frame_wide": "16:9 · Màn ảnh rộng",
|
||||
"render.glass_abbe": "Số Abbe",
|
||||
"render.glass_dispersion": "Tán sắc màu",
|
||||
"render.glass_film": "Giao thoa màng mỏng",
|
||||
"render.glass_film_ior": "Chiết suất màng",
|
||||
"render.glass_film_thickness": "Độ dày màng",
|
||||
"render.glass_model_enable": "Mô hình kính nâng cao",
|
||||
"render.glass_model_help": "Độ nhám kính làm tán xạ tia truyền qua. Tán sắc và màu màng dùng ba dải bước sóng. Tấm nhám cần tắt bóng truyền thẳng.",
|
||||
"render.glass_roughness": "Độ nhám kính",
|
||||
"render.gpu_depth": "Độ sâu đường truyền tia tối đa",
|
||||
"render.gpu_options_help": "Số mẫu, độ sâu, hạt giống và độ phơi sáng được lưu cùng thiết lập này. Chỉ lưu phương thức tạo ảnh đang hoạt động.",
|
||||
"render.gpu_path_crashed": "Lần trước SolidCraft đã đóng khi đang thiết lập nó trên bộ điều hợp đồ họa này, nhiều khả năng là trong trình điều khiển đồ họa, nên nó sẽ không tự thiết lập lại. Hãy dùng dò tia trên CPU, hoặc thử lại (cập nhật trình điều khiển cũng sẽ thử lại).",
|
||||
"render.gpu_path_failed": "Bộ dò đường truyền tia GPU không khả dụng",
|
||||
"render.gpu_path_first_setup": "Lần thiết lập đầu tiên trên GPU này có thể mất vài phút; SolidCraft vẫn sử dụng được bình thường. Các lần khởi động sau sẽ tái sử dụng mã đổ bóng đã biên dịch nếu trình điều khiển lưu giữ chúng.",
|
||||
"render.gpu_path_not_started": "Quá trình thiết lập bộ dò đường đi GPU chưa bắt đầu.",
|
||||
"render.gpu_path_preparing": "Bộ dò đường truyền tia GPU: đang biên dịch mã đổ bóng…",
|
||||
"render.gpu_path_retry": "Thử lại",
|
||||
"render.gpu_seed": "Hạt giống ngẫu nhiên",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "Tâm sản phẩm",
|
||||
"render.light_size": "Kích thước",
|
||||
"render.light_softness": "Độ mềm",
|
||||
"render.light_space_world": "Đèn trong hệ tọa độ thế giới",
|
||||
"render.light_space_world_help": "Vị trí và hướng của đèn dùng hệ tọa độ thế giới. Xoay camera quanh mô hình không làm đèn di chuyển.",
|
||||
"render.light_temperature": "Nhiệt độ màu",
|
||||
"render.light_temperature_help": "Sắc vật đen xấp xỉ; lưu dưới dạng RGB tuyến tính.",
|
||||
"render.light_type": "Loại",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "Giữ lại bản gốc RGBA số thực 32-bit trước khi khử nhiễu, phơi sáng, ánh xạ sắc điệu, lọc và phông nền. Tuyến tính sRGB/D65; nhân trước với độ phủ. Bộ nhớ bổ sung được tiếp nhận rõ ràng. Không nén hay thêm kênh phụ.",
|
||||
"render.linear_retain": "Giữ lại tệp EXR tuyến tính thô",
|
||||
"render.linear_unavailable": "Khung hình đã hoàn thành được chọn không giữ lại EXR. Hãy bật tính năng lưu giữ trước khi kết xuất; ảnh PNG cũ không thể khôi phục lại suất bức xạ thô.",
|
||||
"render.live.adaptive": "Trực tiếp · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "Yêu cầu kết xuất đã thay đổi trong lúc dừng kết xuất trực tiếp. Hãy bắt đầu lại ảnh cuối cùng.",
|
||||
"render.live.final_waiting": "Đang chuẩn bị ảnh cuối · Giải phóng tài nguyên kết xuất trực tiếp",
|
||||
"render.live.invalid_size": "Kết xuất trực tiếp yêu cầu chiều rộng và chiều cao đầu ra lớn hơn không.",
|
||||
"render.live.pause": "Tạm dừng trực tiếp",
|
||||
"render.live.paused": "Trực tiếp · Tạm dừng",
|
||||
"render.live.paused_outdated": "Trực tiếp · Tạm dừng · Chưa cập nhật",
|
||||
"render.live.quality_help": "Chất lượng tương tác: tối đa 512 pixel ở cạnh dài nhất. Sử dụng bộ kết xuất đã chọn và các hiệu ứng được bộ đó hỗ trợ. Độ phân giải cuối, thiết lập và thao tác xuất vẫn độc lập.",
|
||||
"render.live.refused": "Kết xuất trực tiếp không khả dụng: {reason}",
|
||||
"render.live.resume": "Tiếp tục trực tiếp",
|
||||
"render.live.samples": "Trực tiếp · {width} × {height} · {current}/{target} mẫu",
|
||||
"render.live.source_current": "{method} · Trực tiếp",
|
||||
"render.live.source_outdated": "{method} · Ảnh trước",
|
||||
"render.live.source_outdated_help": "Hiển thị ảnh đã chụp gần nhất trong khi kết xuất trực tiếp đang cập nhật hoặc không khả dụng. Ảnh có thể khác cảnh hoặc thiết lập ảnh hiện tại.",
|
||||
"render.live.source_settings_help": "Mở mục Đầu ra để sửa bộ kết xuất và thiết lập ảnh.",
|
||||
"render.live.stopped": "Kết xuất trực tiếp đã dừng",
|
||||
"render.live.updating": "Trực tiếp · Chất lượng tương tác (tối đa 512 px) · Đang cập nhật",
|
||||
"render.live.waiting_canvas": "Đang chờ khung vẽ Kết xuất",
|
||||
"render.live.worker_stopped": "Luồng thu nhận dữ liệu cho kết xuất trực tiếp đã dừng trước khi trả về kết quả",
|
||||
"render.look_add": "Thêm phong cách màu sắc",
|
||||
"render.look_applied": "Đã áp dụng {name}",
|
||||
"render.look_bleach": "Phong cách bỏ qua tẩy trắng (Bleach Bypass)",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "Lớp phủ điện môi minh họa trên diện mạo đục hiện có. Trọng số bằng 0 giữ nguyên vật liệu cơ sở.",
|
||||
"render.material_color_map": "Ảnh màu cơ sở (sRGB)",
|
||||
"render.material_color_replace": "Bản đồ cơ sở thay thế màu khối",
|
||||
"render.material_cpu_glass_only": "Bản đồ từ tệp ảnh tạm thời yêu cầu GPU. CPU có thể dùng bản đồ bề mặt tích hợp đã lưu.",
|
||||
"render.material_cpu_optics_limits": "Bật Chiếu sáng gián tiếp cho hình kính kín được hỗ trợ, cấu trúc lồng nhau đã kiểm chứng và tán xạ bên trong kính. Tấm mỏng có thể truyền ánh sáng cho bóng. Nếu không, dùng tấm phẳng hoặc hộp thẳng theo trục. Chưa hỗ trợ hình chạm nhau và bóng truyền qua kính kín. Các giá trị được giữ nguyên.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Không thể kết hợp quang học CPU đã lưu với các liên kết quang học tạm thời kiểu cũ. Hãy lưu quang học đã xem xét hoặc xóa liên kết tạm thời xung đột.",
|
||||
"render.material_custom": "Quang học minh họa tùy chỉnh",
|
||||
"render.material_enable": "Sử dụng các ghi đè vật liệu GPU này",
|
||||
"render.material_filter": "Lọc các khối trong cảnh…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "Ảnh pháp tuyến (OpenGL, +Y)",
|
||||
"render.material_normal_strength": "Cường độ pháp tuyến (0–1)",
|
||||
"render.material_origin": "Gốc ánh xạ X · Y · Z (mm)",
|
||||
"render.material_raytrace_required": "Kính vật lý yêu cầu dò tia trên CPU hoặc dò đường đi trên GPU.",
|
||||
"render.material_refresh": "Làm mới danh sách khối cảnh",
|
||||
"render.material_refresh_needed": "Khung cảnh đã thay đổi. Hãy làm mới danh sách khối trước khi thêm mục tiêu.",
|
||||
"render.material_remove": "Xóa phép gán này",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "Phép gán này thuộc về phiên bản cảnh trước đó. Hãy xác thực lại danh tính khối chính xác trước khi kết xuất.",
|
||||
"render.material_roughness": "Độ nhám lớp phủ bóng (0.045–1)",
|
||||
"render.material_roughness_map": "Bản đồ độ nhám (tuyến tính)",
|
||||
"render.material_saved_edit_pending": "Các chỉnh sửa quang học này chưa được lưu. Hãy lưu các giá trị đã xem xét, xóa phép gán đã lưu hoặc hủy chỉnh sửa. Liên kết tạm thời kiểu cũ và hiệu ứng chưa được hỗ trợ không thể ghi đè đích đã lưu.",
|
||||
"render.material_shadow": "Truyền các tia bóng đổ thẳng",
|
||||
"render.material_target_missing": "Thứ tự khối và tên không còn khớp. Hãy xóa phép gán này và chọn lại khối mong muốn một cách rõ ràng.",
|
||||
"render.material_thickness": "Độ dày tấm mỏng (mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "Dò tia trên CPU",
|
||||
"render.method_gpu": "Dò đường truyền tia trên GPU (Path Tracing)",
|
||||
"render.method_limit_raster": "Quét mảng (Raster) dùng bản xem trước studio đục cố định. Chọn phương thức dò tia rõ ràng để dùng các điều khiển quang học được hỗ trợ.",
|
||||
"render.method_raster": "Raster trên CPU (dạng sơ đồ)",
|
||||
"render.method_raster": "Quét mảng CPU (Raster)",
|
||||
"render.navigation_help": "Camera di chuyển tương tự như trong Thiết kế (Tùy chọn cài đặt › Điều hướng). Lưu thiết lập sẽ ghi lại camera hiện tại.",
|
||||
"render.nested_enable": "Chất điện môi lồng nhau đã xác thực",
|
||||
"render.nested_help": "Tối đa 16 lớp chứa nhau; mỗi khối cha phải là khối lồi và mọi khối con phải nằm hoàn toàn bên trong. Từ chối giao cắt, bề mặt tiếp xúc, tấm mỏng và bóng đổ thẳng. Trạng thái camera bên trong tuân theo hệ thống phân cấp đã xác thực. Không tạo bề mặt chất lỏng hoặc hợp nhất các ranh giới CAD tiếp xúc.",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "Thiết lập đầu ra EXR không khả dụng ở đây. Xuất EXR tuyến tính cần ảnh GPU đã hoàn tất với bản gốc tuyến tính được giữ lại.",
|
||||
"render.output_fit": "Vừa khít",
|
||||
"render.output_format": "Định dạng",
|
||||
"render.output_format_status": "PNG là ảnh hiển thị hoàn chỉnh chính xác. EXR tùy chọn trên CPU hoặc GPU giữ lại suất bức xạ FLOAT32 thô trong không gian tuyến tính của cảnh trước các bộ lọc hiển thị. Xuất không bao giờ thay thế ảnh bằng bộ nhớ đệm xem trước hoặc tác vụ khác.",
|
||||
"render.output_format_status": "PNG là hình ảnh hiển thị hoàn chỉnh chính xác. EXR tùy chọn trên GPU giữ lại suất bức xạ FLOAT32 tuyến tính cảnh thô trước các bộ lọc hiển thị. Xuất không bao giờ thay thế bằng bộ nhớ đệm xem trước hoặc tác vụ khác.",
|
||||
"render.output_grading": "Chỉnh màu",
|
||||
"render.output_image": "Ảnh",
|
||||
"render.output_largest": "Tối đa {width} × {height}",
|
||||
"render.output_lock_ratio": "Khóa tỷ lệ khung hình",
|
||||
"render.output_master_unavailable": "Để xuất EXR, hãy chọn ảnh đã hoàn tất có dữ liệu EXR thô. Bật EXR thô trước khi kết xuất ảnh mới.",
|
||||
"render.output_over_budget": "Hình ảnh yêu cầu vượt quá giới hạn tài nguyên đã chọn. Hãy chủ động chọn kích thước hoặc cấu hình tương thích; không tự động hạ cấp chất lượng.",
|
||||
"render.output_presentation_limits": "Hiệu ứng ảnh trên CPU và đầu ra EXR hỗ trợ ảnh có chiều rộng và chiều cao tối đa 8192 pixel, với tổng cộng không quá 16.777.216 pixel.",
|
||||
"render.output_presets_help": "Định dạng, chất lượng và danh sách Kiểm tra nằm trong trình chỉnh Đầu ra.",
|
||||
"render.output_quality": "Chất lượng",
|
||||
"render.output_quality_waiting": "đang chờ hợp đồng chất lượng",
|
||||
"render.output_ratio_refused": "Kích thước liên kết vượt quá giới hạn của phương thức này. Kích thước cũ được giữ lại; hãy mở khóa tỷ lệ hoặc chọn giá trị nhỏ hơn.",
|
||||
"render.output_raytrace_only": "Hiệu ứng ảnh yêu cầu dò tia trên CPU hoặc dò đường đi trên GPU.",
|
||||
"render.output_ready": "Sẵn sàng kết xuất",
|
||||
"render.output_run_check": "Chạy kiểm tra",
|
||||
"render.output_size_limit": "{method}: tối đa {dimension} px mỗi cạnh và {pixels} điểm ảnh. Kích thước lớn nhất vừa khít: {width} × {height}.",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "Độ bão hòa",
|
||||
"render.post_title": "Bộ lọc trình chiếu",
|
||||
"render.post_vignette": "Hiệu ứng tối góc (Vignette)",
|
||||
"render.preparation_cancelled": "Đã hủy chuẩn bị kết xuất",
|
||||
"render.preparation_checking": "Đang kiểm tra kết xuất",
|
||||
"render.preparation_retirement_failed": "Không thể giải phóng tài nguyên chuẩn bị kết xuất. Cửa sổ này không thể chuẩn bị ảnh khác.",
|
||||
"render.preparation_retiring": "Đang hoàn tất chuẩn bị kết xuất",
|
||||
"render.preparation_unavailable": "Không thể chuẩn bị kết xuất",
|
||||
"render.presentation_banner": "Các hiệu ứng hình ảnh hoạt động với dò tia trên CPU và GPU. Mở trang này không bao giờ thay đổi phương pháp.",
|
||||
"render.preview": "Xem trước tích hợp",
|
||||
"render.preview_help": "Đổ bóng raster và trộn độ trong suốt. Khúc xạ, HDRI và kết xuất cuối cùng hiện chưa khả dụng.",
|
||||
"render.preview_image_settings_help": "Xem trước trực tiếp giữ nguyên ánh sáng studio cố định. Độ phơi sáng và ánh sáng hình ảnh sẽ áp dụng cho tác vụ khi bạn Kiểm tra hoặc Bắt đầu.",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "Xác nhận phép chiếu định mức cố định và các mục lược bỏ",
|
||||
"render.quality_apply": "Sử dụng định mức lấy mẫu cố định này",
|
||||
"render.quality_balanced": "Cân bằng",
|
||||
"render.quality_budget_help": "Đặt kích thước, số mẫu, độ sâu, hạt giống và giảm nhiễu. Giữ nguyên các thiết lập ảnh khác.",
|
||||
"render.quality_custom": "Tùy chỉnh",
|
||||
"render.quality_denoise_off": "Giảm nhiễu: Tắt",
|
||||
"render.quality_denoise_on": "Giảm nhiễu: Bật",
|
||||
"render.quality_draft": "Bản nháp",
|
||||
"render.quality_high": "Cao",
|
||||
"render.quality_projection_help": "Bản xem xét này chỉ thay đổi độ phân giải, ngân sách mẫu, độ sâu đường đi, hạt giống và giảm nhiễu. Các thiết lập khác được giữ nguyên; ngân sách không chứng nhận ảnh đã hội tụ.",
|
||||
"render.quality_projection_help": "Các thiết lập sẵn Q bao gồm các yêu cầu tương lai. Hành động này chỉ chiếu định mức mẫu PNG cố định được hỗ trợ và lược bỏ rõ ràng việc dừng thích ứng, đầu ra tuyến tính thô và các giới hạn truyền dẫn độc lập.",
|
||||
"render.quality_review": "Xem xét định mức lấy mẫu",
|
||||
"render.quality_summary": "{width} × {height} · {samples} mẫu · độ sâu {depth} · hạt giống {seed} · {denoise}",
|
||||
"render.queue_pending": "Không có hàng đợi sản xuất tự động hay bộ kết xuất hoạt hình trong giao diện này. Ảnh tĩnh đã hoàn thành vẫn có thể xuất riêng lẻ.",
|
||||
"render.radial_assign": "Gán diện mạo",
|
||||
"render.radial_compare": "So sánh",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "Thu phóng và lia theo tùy chọn Điều hướng. Nhấn phím cách + kéo rê để lia.",
|
||||
"render.result_open": "Mở hình ảnh đã hoàn thành trong khung vẽ chính.",
|
||||
"render.result_preview": "Xem trước trực tiếp",
|
||||
"render.result_provisional": "Ảnh tạm",
|
||||
"render.result_provisional": "Hình ảnh CPU tạm thời",
|
||||
"render.result_provisional_help": "Điểm kiểm tra trung gian này không thể xuất. Lệnh xuất giữ lại kết quả hoàn thành gần nhất; hủy bỏ sẽ quay lại kết quả đó.",
|
||||
"render.result_reduced_display": "Bản xem trước hiển thị bị thu nhỏ theo giới hạn bộ nhớ đệm/thiết bị của khung nhìn. Tệp PNG được giữ lại và tệp xuất vẫn giữ nguyên độ phân giải đầy đủ; chế độ kiểm tra gốc 100% không khả dụng cho chế độ xem thu nhỏ này.",
|
||||
"render.result_retained": "Đang hiển thị hình ảnh hoàn thành gần nhất; kết xuất mới hơn chưa thay thế nó.",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "Lấy khung đối tượng",
|
||||
"render.shot_invalid_camera": "Giá trị máy ảnh phải hữu hạn và trong phạm vi.",
|
||||
"render.shot_lens": "Ống kính",
|
||||
"render.shot_lens_cpu_progressive_reason": "Bật lấy mẫu lũy tiến trong mục Lấy mẫu trước khi bật độ sâu trường ảnh trên CPU.",
|
||||
"render.shot_lens_enable_reason": "Bật độ sâu trường ảnh để chỉnh khẩu độ và khoảng cách lấy nét.",
|
||||
"render.shot_lens_gpu_reason": "Độ sâu trường ảnh cần dò đường đi của tia bằng GPU.",
|
||||
"render.shot_lens_perspective_reason": "Độ sâu trường ảnh cần phép chiếu phối cảnh.",
|
||||
"render.shot_lens_raytrace_reason": "Độ sâu trường ảnh khả dụng với dò tia trên CPU và dò đường đi trên GPU.",
|
||||
"render.shot_look_selection": "Nhìn vào vùng chọn",
|
||||
"render.shot_manager": "Khung hình đã lưu",
|
||||
"render.shot_new_name": "Tên khung hình mới",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "Đường chân trời · RGB tuyến tính",
|
||||
"render.sky_zenith": "Thiên đỉnh · RGB tuyến tính",
|
||||
"render.soft_focus": "Lấy nét mềm (Soft focus)",
|
||||
"render.spot_axis": "Hướng phát sáng",
|
||||
"render.spot_cone": "Chóp đèn rọi",
|
||||
"render.spot_gpu_help": "Các góc được đo từ hướng phát sáng. Ánh sáng yếu dần giữa góc trong và góc ngoài. Dải chuyển tiếp rất hẹp cần kết xuất bằng CPU.",
|
||||
"render.spot_help": "Các góc được đo từ hướng phát sáng. Ánh sáng yếu dần giữa góc trong và góc ngoài.",
|
||||
"render.spot_inner": "Nửa góc trong",
|
||||
"render.spot_outer": "Nửa góc ngoài",
|
||||
"render.stage_frame": "Khung",
|
||||
"render.stage_layout.appearance_note": "Bấm để áp dụng cho vùng chọn.",
|
||||
"render.stage_layout.studio_note": "Bấm để xem xét trước khi áp dụng.",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "Chuẩn bị kế hoạch tạo",
|
||||
"render.stage_panels.projects": "Chiếu",
|
||||
"render.stage_panels.quality_ack": "Tôi xác nhận ngân sách cố định và các mục bị lược bỏ",
|
||||
"render.stage_panels.quality_omissions": "Ngân sách này không đặt mục tiêu sai số thích ứng, giới hạn truyền sáng độc lập, quy tắc roulette Nga hay việc lưu giữ bản gốc thô, và không bảo đảm ảnh hội tụ.",
|
||||
"render.stage_panels.quality_omissions": "Dừng thích ứng và mục tiêu sai số, độ sâu truyền dẫn riêng, quy tắc roulette Nga, đầu ra tuyến tính thô, bản gốc EXR và chứng nhận hội tụ.",
|
||||
"render.stage_panels.reference": "Tham chiếu",
|
||||
"render.stage_panels.references": "Các tham chiếu",
|
||||
"render.stage_panels.remove_optics": "Xóa cài đặt quang học",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Môi trường HDR nguyên bản · toàn bộ cảnh",
|
||||
"render.stage_panels.subtitle_maps": "Bản đồ không gian đã tạo · xem trước tạm thời",
|
||||
"render.stage_panels.subtitle_physical": "Kế hoạch chỉ đọc · không tạo hay gán vật liệu",
|
||||
"render.stage_panels.subtitle_quality": "Ngân sách lấy mẫu GPU · chỉ các trường được liệt kê",
|
||||
"render.stage_panels.subtitle_quality": "Ngân sách GPU cố định · lược bỏ các yêu cầu tương lai",
|
||||
"render.stage_panels.subtitle_rig": "Bộ đèn chiếu sáng đã thiết kế · bộ phát sáng và tấm thụ động",
|
||||
"render.stage_panels.subtitle_saved_maps": "Bản đồ đã tạo · lưu cùng khung hình này",
|
||||
"render.stage_panels.subtitle_stage": "Mặt sàn và sân khấu vật lý · khớp với sản phẩm",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "Tối đa bốn hình chữ nhật một mặt. Nửa cạnh trực giao U và V xác định kích thước; U × V hướng về phía chiếu sáng. Tọa độ tính theo milimét không gian thế giới.",
|
||||
"render.studio_center": "Tâm X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Giảm nhiễu trong ảnh thành phẩm",
|
||||
"render.studio_denoise_help": "Ảnh lũy tiến và ảnh cuối sử dụng bộ lọc bảo toàn biên; chi tiết nhỏ có thể bị làm mềm. Kính và bề mặt phản xạ sắc nét không qua bộ lọc này.",
|
||||
"render.studio_denoise_help": "Bộ lọc cuối cùng bảo toàn cạnh; chi tiết tinh xảo có thể bị làm mềm. Thủy tinh và bề mặt phản xạ gương sắc nét bỏ qua bộ lọc này.",
|
||||
"render.studio_direction": "Hướng về phía mặt trời (X, Y, Z)",
|
||||
"render.studio_environment": "Môi trường",
|
||||
"render.studio_environment_bounds": "Góc xoay môi trường phải hữu hạn trong khoảng ±1.000.000 radian và cường độ từ 0 đến 100.",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "Sử dụng xem trước tích hợp",
|
||||
"render.volume_albedo": "Độ phản xạ khuếch tán tán xạ (Albedo) · RGB tuyến tính",
|
||||
"render.volume_anisotropy": "Cos trung bình hàm pha (Anisotropy)",
|
||||
"render.volume_denoise_required": "Tắt giảm nhiễu để dùng Khí quyển.",
|
||||
"render.volume_enable": "Bật môi trường tham gia (Participating medium)",
|
||||
"render.volume_extinction": "Hệ số suy giảm quang học (Extinction)",
|
||||
"render.volume_glass_required": "Không thể kết hợp Khí quyển và Kính trong thiết lập này.",
|
||||
"render.volume_help": "Dò tia GPU bên trong một hộp hữu hạn, với định luật suy giảm Beer và tán xạ HG trên các đường truyền tia camera và gián tiếp. Sử dụng ánh sáng studio/HDR/bầu trời rõ ràng, bao gồm cả suy giảm bóng đổ. Cos pha dương tán xạ về phía trước. Ranh giới thủy tinh và khử nhiễu bề mặt phải được tắt rõ ràng. Hệ số suy giảm bằng 0 giữ nguyên truyền dẫn. Đây không phải khí quyển mặt trăng vật lý, lưới mật độ, phát xạ thể tích, truyền dẫn quang phổ hay đặc tính vật liệu chứng nhận.",
|
||||
"render.volume_lighting_required": "Chọn môi trường hoặc bộ đèn tường minh để kết xuất Khí quyển.",
|
||||
"render.volume_max": "Ranh giới tối đa · mm thế giới",
|
||||
"render.volume_min": "Ranh giới tối thiểu · mm thế giới",
|
||||
"render.volume_single_stratum_required": "Dùng một mẫu truyền sáng để kết xuất Khí quyển.",
|
||||
"render.volume_title": "Thể tích đồng nhất có giới hạn",
|
||||
"render.wait_add_light": "chưa hỗ trợ: tạo đèn có tên",
|
||||
"render.wait_batch": "chưa hỗ trợ: hàng đợi kết xuất cảnh chụp đã lưu",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "Đã áp dụng kích thước",
|
||||
"sketch.dimension_applied_defined": "Đã áp dụng kích thước; đã định nghĩa tham số `{name}`",
|
||||
"sketch.dimension_applied_renamed": "Đã áp dụng kích thước; tham số được đặt tên là `{name}`",
|
||||
"sketch.divide_hint": "Các điểm chia mỗi đường cong thành những đoạn dài bằng nhau và giữ nguyên khi đường cong thay đổi.",
|
||||
"sketch.dof_left": "Còn {count} bậc tự do",
|
||||
"sketch.dof_left_one": "Còn {count} bậc tự do",
|
||||
"sketch.draw_curves": "Vẽ dưới dạng {linetype}",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "Mịn",
|
||||
"ui.finest": "Mịn nhất",
|
||||
"ui.finish_sketch": "Hoàn tất phác thảo",
|
||||
"ui.first_dimension_scales": "Kích thước đầu tiên co giãn bản vẽ",
|
||||
"ui.first_dimension_scales_tip": "Bật: kích thước chiều dài đầu tiên của bản phác thảo co giãn toàn bộ hình mà nó đo, nên hình dạng được giữ nguyên. Tắt: kích thước chỉ đổi cỡ phần mà nó đo.",
|
||||
"ui.fit": "Vừa vặn (Fit)",
|
||||
"ui.fix_unfix": "Cố định/Uncố định",
|
||||
"ui.flat": "Phẳng",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "KHỐI ĐIỀU HƯỚNG GÓC NHÌN TÙY CHỈNH",
|
||||
"ui.heading_cylinder": "HÌNH TRỤ",
|
||||
"ui.heading_design": "THIẾT KẾ",
|
||||
"ui.heading_divide_curve": "CHIA ĐƯỜNG CONG",
|
||||
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
|
||||
"ui.heading_edit": "CHỈNH SỬA",
|
||||
"ui.heading_equation_curve": "ĐƯỜNG CONG PHƯƠNG TRÌNH",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "Hiện nút Phát",
|
||||
"ui.history_play_finish_action": "Hoàn tất thao tác hiện tại trước khi phát lịch sử",
|
||||
"ui.history_play_roll_back": "Đưa điểm đánh dấu lịch sử lùi lại trước khi phát",
|
||||
"ui.history_scope_all": "Hiện các tính năng của toàn bộ thiết kế",
|
||||
"ui.history_scope_all_shown": "Các tính năng của toàn bộ thiết kế",
|
||||
"ui.history_scope_pick": "Sửa một cụm chi tiết, hoặc chọn một cụm trong Trình duyệt, để chỉ hiện các tính năng của nó",
|
||||
"ui.history_scope_show": "Chỉ hiện các tính năng của {name} và các cụm chi tiết bên trong",
|
||||
"ui.history_scope_shown": "Các tính năng của {name} và các cụm chi tiết bên trong",
|
||||
"ui.history_show_hidden": "Hiện các thành phần bị ẩn",
|
||||
"ui.history_show_names": "Hiện tên",
|
||||
"ui.history_sketch_listed_under": "Được liệt kê dưới {feature}",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "Tệp không có màu",
|
||||
"ui.point_cloud_pause": "Tạm dừng",
|
||||
"ui.point_cloud_play": "Phát",
|
||||
"ui.point_cloud_reload": "Tải lại từ tệp",
|
||||
"ui.point_cloud_single_color": "Một màu",
|
||||
"ui.point_cloud_size": "Kích thước điểm",
|
||||
"ui.point_cloud_units": "Đơn vị của tệp",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "Đường ray dẫn hướng thứ hai",
|
||||
"ui.section_analysis": "Phân tích mặt cắt",
|
||||
"ui.section_size": "Kích thước mặt cắt",
|
||||
"ui.segments": "Số đoạn",
|
||||
"ui.select": "Chọn",
|
||||
"ui.selectOther": "Chọn đối tượng khác…",
|
||||
"ui.selectTangentChain": "Chọn chuỗi tiếp tuyến",
|
||||
|
||||
+2914
-2920
File diff suppressed because it is too large
Load Diff
+13
-172
@@ -337,7 +337,6 @@
|
||||
"Flat size X": "平板尺寸 X",
|
||||
"Flat size Y": "平板尺寸 Y",
|
||||
"Flipped": "已翻转",
|
||||
"Fold Features": "折叠特征",
|
||||
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Follow inherited appearance": "跟随继承的外观",
|
||||
"Follow material": "跟随材质",
|
||||
@@ -360,7 +359,6 @@
|
||||
"Glass - Smoked": "玻璃 - 烟色",
|
||||
"Grid": "网格",
|
||||
"Groove": "凹槽",
|
||||
"Group Features": "将特征编组",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "选择后 HDR 文件已更改;保存前请检查并重新选择",
|
||||
"HISTORY": "历史",
|
||||
"Halo under sketch curves": "在草图曲线下方显示光晕",
|
||||
@@ -722,7 +720,6 @@
|
||||
"Section position": "截面位置",
|
||||
"Sections": "截面",
|
||||
"Sections (one sketch each)": "截面(每个一个草图)",
|
||||
"Select Features": "选择特征",
|
||||
"Select Other": "选择其他",
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。双击中键转到主视图(首选项 › 导航)。",
|
||||
"Select Same Look": "选择相同外观",
|
||||
@@ -731,7 +728,6 @@
|
||||
"Select edges": "选择边",
|
||||
"Select points of the sketch being edited": "请选择正在编辑的草图中的点",
|
||||
"Select the centre the pattern turns around first": "请先选择阵列旋转所绕的中心",
|
||||
"Select the curves to divide first": "请先选择要等分的曲线",
|
||||
"Select the curves to offset first": "请先选择要偏移的曲线",
|
||||
"Select the mirror line": "请选择镜像线",
|
||||
"Select the sketch curves or points to mirror first": "请先选择要镜像的草图曲线或点",
|
||||
@@ -747,7 +743,6 @@
|
||||
"Sheet Metal": "钣金",
|
||||
"Sheet Rules": "钣金规则",
|
||||
"Show Agent Cursor": "显示代理光标",
|
||||
"Show Each Feature": "显示每个特征",
|
||||
"Show ghost": "显示虚影",
|
||||
"Show in folder": "在文件夹中显示",
|
||||
"Show the pointer an AI agent moves (#35)": "显示 AI 代理移动的指针(#35)",
|
||||
@@ -1270,9 +1265,6 @@
|
||||
"cmd.pointcloud_edit": "编辑点云",
|
||||
"cmd.pointcloud_insert": "插入点云",
|
||||
"cmd.pointcloud_list": "点云",
|
||||
"cmd.pointcloud_reload": "重新加载点云",
|
||||
"cmd.render.coat.bind": "保存清漆",
|
||||
"cmd.render.coat.unbind": "移除清漆",
|
||||
"cmd.render.compare.divider": "移动渲染比较分隔线",
|
||||
"cmd.render.result.discard": "丢弃渲染结果",
|
||||
"cmd.render.result.select": "选择渲染结果",
|
||||
@@ -1422,7 +1414,6 @@
|
||||
"cmd.sketch_options": "草图选项板选项",
|
||||
"cmd.sketch_paste": "粘贴草图图元",
|
||||
"cmd.sketch_point3d": "三维点",
|
||||
"cmd.sketch_point_divide": "等分曲线",
|
||||
"cmd.sketch_polygon_circumscribed": "外切多边形",
|
||||
"cmd.sketch_polygon_edge": "边长多边形",
|
||||
"cmd.sketch_polygon_inscribed": "内接多边形",
|
||||
@@ -1500,7 +1491,6 @@
|
||||
"cmd.view.render_browser": "渲染浏览器",
|
||||
"cmd.view.render_dock": "移动渲染停靠面板",
|
||||
"cmd.view.render_focus": "渲染专注视图",
|
||||
"cmd.view.render_live": "暂停或继续实时渲染",
|
||||
"cmd.view.status_details": "视口状态详情",
|
||||
"cmd.view.status_items": "视口状态项",
|
||||
"cmd.view_ambient_occlusion": "环境光遮蔽",
|
||||
@@ -1673,7 +1663,6 @@
|
||||
"file_load.reading_percent": "正在读取文件... {percent}%",
|
||||
"file_load.rebuilding": "正在重建特征 {done}/{total}",
|
||||
"file_load.recovering": "正在恢复设计",
|
||||
"file_load.reloading_cloud": "正在重新加载点云",
|
||||
"file_load.step_build": "正在构建模型",
|
||||
"file_load.step_feature": "特征 {done}/{total}",
|
||||
"file_load.step_part": "零件 {done}/{total}",
|
||||
@@ -1913,7 +1902,6 @@
|
||||
"help.sketch.pattern.circular": "绕中心点复制草图几何,可绕整圆或按设定角度。用于螺栓圆、辐条和齿。",
|
||||
"help.sketch.pattern.rectangular": "按设定间距以行和列复制草图几何。用于孔、槽或通风口的阵列。",
|
||||
"help.sketch.point": "放置一个草图点。用作尺寸、约束、孔心或后续特征的参照。",
|
||||
"help.sketch.point.divide": "放置将所选曲线分成等长段的点。这些点留在各曲线上,曲线改变时保持间距。",
|
||||
"help.sketch.point3d": "开启3D 草图后,在空间中放置一个点。用作三维曲线和路径的参照。",
|
||||
"help.sketch.polygon.circumscribed": "在圆外绘制正多边形,大小由中心到边中点的距离决定。适用于已知对边距的情况,例如六角螺母。",
|
||||
"help.sketch.polygon.edge": "根据一条边的两个端点绘制正多边形。适用于已知边的长度和位置的情况。",
|
||||
@@ -2267,8 +2255,6 @@
|
||||
"nav.maya.help": "Alt+左键拖动旋转,Alt+中键拖动平移,Alt+右键拖动缩放;中键拖动也可平移",
|
||||
"nav.nx.help": "中键拖动旋转,Shift+中键平移(对应 NX 的中键+右键),Ctrl+中键缩放(对应 NX 的中键+左键);右键拖动不旋转视图",
|
||||
"nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放",
|
||||
"nav.orbit_cursor_wrap.help": "旋转拖动到达视图边缘时,光标移到对侧边缘并继续旋转。关闭时,拖动在屏幕边缘停止。在 Wayland 和浏览器中无效。",
|
||||
"nav.orbit_cursor_wrap.label": "旋转时光标循环",
|
||||
"nav.perspective.sketch": "编辑草图时使用正交视图。完成草图后才能切换透视。",
|
||||
"nav.right_drag_orbits.help": "关闭:右键拖动不会移动相机,右键只打开上下文菜单。选择 SolidWorks 鼠标映射时会关闭此项。",
|
||||
"nav.right_drag_orbits.label": "右键拖动旋转",
|
||||
@@ -2634,10 +2620,6 @@
|
||||
"prefs.hide_constraint_glyphs_zoomed_out": "缩小时隐藏约束符号",
|
||||
"prefs.hide_constraint_glyphs_zoomed_out_note": "草图缩小到一定程度时隐藏约束符号,以免遮挡草图。关闭:任意缩放比例下符号仍保持固定屏幕大小。",
|
||||
"prefs.history": "历史",
|
||||
"prefs.history_bar_groups": "将历史栏折叠为分组",
|
||||
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
|
||||
"prefs.history_group_joints": "在历史中将关节分组",
|
||||
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
|
||||
"prefs.history_origin_bodies": "在顶部显示原点和实体",
|
||||
"prefs.history_origin_bodies_note": "左侧或浏览器下方的历史以活动组件的实体文件夹和原点开头,并带有与浏览器相同的显示切换和菜单。底部的历史保持不变。",
|
||||
"prefs.history_play_button": "在历史控件中显示“播放历史”按钮",
|
||||
@@ -2800,8 +2782,6 @@
|
||||
"prefs.quick_measure_note": "在视图右下角、历史记录上方显示所选对象的尺寸:边的长度(圆形边显示 Ø 和 R)、面的面积、点的位置、两个对象之间的距离和角度、多条边或多个面的总和。点击即可复制。",
|
||||
"prefs.remember_dialog_values": "记住命令对话框的值",
|
||||
"prefs.remember_dialog_values.help": "默认关闭。开启后,命令成功执行后,在关闭 SolidCraft 前,下次打开同一命令时会重用其安全的值和选项。",
|
||||
"prefs.render_legacy_image_defaults": "使用以前的渲染图像默认值",
|
||||
"prefs.render_legacy_image_defaults_help": "保留以前的手动图像流程:新设置使用 CPU 光栅渲染,CPU 光线追踪使用固定采样,并手动打开结果。进入渲染时重新适配视图,切换镜头时不恢复其保存的相机,并使用以前的取景和导航方式。禁用自动实时图像反馈。已保存的设置保持不变。",
|
||||
"prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算",
|
||||
"prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。",
|
||||
"prefs.reports_saved": "已保存 {n} 份",
|
||||
@@ -2935,8 +2915,6 @@
|
||||
"prefs.structure.work": "工作",
|
||||
"prefs.supersampling_brief": "以更高分辨率绘制后滤波缩小:更清晰,但负载更高。",
|
||||
"prefs.tab_accent_note": "强调色跟随当前选项卡",
|
||||
"prefs.tab_double_click_renames": "双击设计选项卡以重命名",
|
||||
"prefs.tab_double_click_renames_note": "输入新名称,然后按 Enter 或点击别处即可保留,按 Esc 取消。关闭时:双击选项卡会最大化窗口,与栏的空白处相同。",
|
||||
"prefs.theme_file": "主题文件",
|
||||
"prefs.theme_file_or_paste": "文件或粘贴",
|
||||
"prefs.theme_follow": "跟随主题文件",
|
||||
@@ -3039,15 +3017,12 @@
|
||||
"refused.join": "无法合并到 {body}。",
|
||||
"refused.repair": "修复零件",
|
||||
"refused.show_face": "显示面",
|
||||
"render.adaptive.pilot_progress": "自适应预采样 · {current}/{target} 轮",
|
||||
"render.adaptive.preview_help": "预采样图像用于分配预算,不计入最终图像。正式采样为每个像素使用独立预算;迭代次数不表示所有像素具有相同的采样数。",
|
||||
"render.adaptive.production_progress": "自适应正式采样 · {current}/{target} 轮迭代",
|
||||
"render.adaptive_absolute": "RGB 绝对误差下限",
|
||||
"render.adaptive_coverage": "覆盖率误差目标",
|
||||
"render.adaptive_enable": "使用独立预采样分配样本",
|
||||
"render.adaptive_help": "独立的先导样本会被丢弃,随后为每个像素分配至少16个、不超过“每像素样本数”的正式样本。CPU资源限制包含两个阶段。RGB与覆盖率方差决定分配,达到上限的像素仍可能有噪点。最终报告记录实际样本数。",
|
||||
"render.adaptive_help": "仅限 GPU。丢弃独立预采样后,每像素分配至少16个正式样本,最多达到每像素样本数上限。RGB 和覆盖率方差仅用于指导分配;可能遗漏罕见路径,达到上限后仍可能有噪声。最终报告记录实际工作量,不保证噪声或耗时。",
|
||||
"render.adaptive_minimum": "最少正式样本数",
|
||||
"render.adaptive_pending": "CPU和GPU均支持独立先导样本的自适应分配。条件菲涅耳分层需要GPU。统计分配不保证收敛。",
|
||||
"render.adaptive_pending": "GPU 支持独立预采样自适应分配和条件菲涅耳分层。尚不支持认证收敛、罕见事件保证及 CPU 自适应光传输。",
|
||||
"render.adaptive_pilots": "每像素预采样数",
|
||||
"render.adaptive_relative": "RGB 相对误差目标",
|
||||
"render.add_ground": "向设置添加适配地面",
|
||||
@@ -3070,7 +3045,6 @@
|
||||
"render.aging_metallic": "层金属比例",
|
||||
"render.aging_origin": "场原点 · 世界坐标 mm",
|
||||
"render.aging_patina": "铜绿",
|
||||
"render.aging_raytrace_help": "老化效果适用于CPU光线追踪和GPU路径追踪。光栅保持示意渲染。",
|
||||
"render.aging_refresh_targets": "检查当前实体",
|
||||
"render.aging_roughness": "层粗糙度",
|
||||
"render.aging_rust": "铁锈",
|
||||
@@ -3091,7 +3065,6 @@
|
||||
"render.appearance_editor.closed": "封闭实体",
|
||||
"render.appearance_editor.coat": "清漆涂层",
|
||||
"render.appearance_editor.cpu_reason": "需要 CPU 光线追踪。",
|
||||
"render.appearance_editor.discard_saved_edit": "放弃未保存的光学编辑",
|
||||
"render.appearance_editor.glass": "玻璃与吸收",
|
||||
"render.appearance_editor.glass_enable": "对此实体使用物理玻璃",
|
||||
"render.appearance_editor.glass_note": "封闭玻璃使用几何距离。同一实体不能同时使用清漆或贴图。",
|
||||
@@ -3110,7 +3083,7 @@
|
||||
"render.appearance_editor.replace": "替换此实体已保存的光学设置",
|
||||
"render.appearance_editor.revalidate": "重新验证此实体",
|
||||
"render.appearance_editor.save": "保存这些光学设置",
|
||||
"render.appearance_editor.save_reason": "要保存玻璃,请在当前CPU或GPU实体上启用它。清漆请使用“将清漆保存到镜头”。本地图像文件仍为临时设置。",
|
||||
"render.appearance_editor.save_reason": "保存需要当前 GPU 实体上的物理玻璃。清漆和本地图像贴图仍为临时设置。",
|
||||
"render.appearance_editor.saved": "随此镜头保存",
|
||||
"render.appearance_editor.saved_state": "这些光学设置已随此镜头保存。",
|
||||
"render.appearance_editor.scatter_reason": "内部散射需要封闭实体边界。",
|
||||
@@ -3156,13 +3129,9 @@
|
||||
"render.base_metallic": "金属比例",
|
||||
"render.base_opaque_only": "此编辑器保留不透明实体语义;光学透射请使用明确的玻璃控件。",
|
||||
"render.batch_all_shots": "所有镜头 ({count})",
|
||||
"render.batch_current_settings_help": "使用当前图像设置和各个已保存镜头的相机,逐个渲染镜头。",
|
||||
"render.batch_empty": "渲染所有镜头前请先保存镜头。",
|
||||
"render.batch_limit": "一次最多渲染32个镜头。",
|
||||
"render.batch_owner_limit": "最多可保留32个文档的镜头队列。请先完成或取消现有队列。",
|
||||
"render.batch_preparing": "正在准备下一个镜头",
|
||||
"render.batch_status": "{running} 个运行中 · {waiting} 个等待中",
|
||||
"render.batch_waiting_worker": "等待渲染准备",
|
||||
"render.bloom_enable": "显示泛光",
|
||||
"render.bloom_help": "仅用于输出的创意辉光,将正线性高光经过三次归一化滤波扩散。半径以输出像素计,在 sRGB 前叠加;不模拟镜头或体积,也不改善收敛。原始 EXR 不变。",
|
||||
"render.bloom_opaque": "泛光需要不透明输出;会拒绝透明 PNG,而非暗中合成背景。",
|
||||
@@ -3192,16 +3161,6 @@
|
||||
"render.category_paint": "涂漆",
|
||||
"render.category_plastic": "塑料",
|
||||
"render.category_wood": "木材",
|
||||
"render.caustics_depth": "焦散路径深度",
|
||||
"render.caustics_enable": "点光源和方向光的玻璃焦散",
|
||||
"render.caustics_help": "光子数控制噪点和计算量。增大收集半径可得到更平滑的估计,但会损失细节。CPU需要间接照明。",
|
||||
"render.caustics_path_required": "CPU焦散需要间接照明。",
|
||||
"render.caustics_photons": "光子数",
|
||||
"render.caustics_radius": "收集半径",
|
||||
"render.check_glass_cpu": "CPU 支持经验证的平面玻璃薄片和轴对齐盒体。一般闭合网格、嵌套和内部散射需要间接光照。仍不支持穿过闭合玻璃的透射阴影。",
|
||||
"render.check_glass_gpu": "GPU 支持经验证的互不相交玻璃介质,或明确启用的经验证嵌套。嵌套要求外层物体为凸体、内层物体严格包含于其中,且边界互不接触。",
|
||||
"render.check_media_remaining": "密度网格、置换、完整光谱积分和偏振状态传播仍不受支持。",
|
||||
"render.check_media_scope": "启用内部散射需要闭合玻璃介质,并关闭直线阴影透射和降噪。GPU 还需要只使用一个菲涅耳分层。启用的外部大气不能与玻璃或表面降噪同时使用。",
|
||||
"render.check_opaque": "透明度:仅不透明表面;支持背景 Alpha。",
|
||||
"render.check_sorted_alpha": "透明度:排序 Alpha 近似。",
|
||||
"render.check_transmission": "透明度:物理透射。",
|
||||
@@ -3220,7 +3179,7 @@
|
||||
"render.children.copy_request": "复制请求JSON",
|
||||
"render.children.coverage_error": "覆盖误差",
|
||||
"render.children.cpu": "CPU光线追踪",
|
||||
"render.children.denoise": "降低图像噪点",
|
||||
"render.children.denoise": "降低成品图像噪点",
|
||||
"render.children.depth": "路径深度",
|
||||
"render.children.distortion": "畸变",
|
||||
"render.children.edge_samples": "边缘采样",
|
||||
@@ -3283,14 +3242,6 @@
|
||||
"render.choose_look": "请先在外观库中选择外观",
|
||||
"render.chromatic_offset": "边缘色差偏移",
|
||||
"render.clear_stage": "移除已保存的舞台",
|
||||
"render.coat_discard": "放弃清漆编辑",
|
||||
"render.coat_pending_edit": "清漆编辑尚未保存。请保存到镜头或放弃编辑。",
|
||||
"render.coat_remove": "移除已保存清漆",
|
||||
"render.coat_remove_note": "从镜头中移除此目标的清漆。几何、外观和物理材料保持不变。",
|
||||
"render.coat_remove_title": "移除已保存的清漆?",
|
||||
"render.coat_revalidate_saved": "已保存的清漆发生了变化。请放弃此编辑,重新打开目标后再编辑。",
|
||||
"render.coat_save": "将清漆保存到镜头",
|
||||
"render.coat_saved_help": "清漆为不透明表面添加光泽层。请随镜头保存。同一目标不能使用玻璃,也不能使用CPU自定义反射或各向异性覆盖。",
|
||||
"render.compare_divider": "比较分隔线",
|
||||
"render.compare_live": "实时",
|
||||
"render.compare_live_preview": "实时预览",
|
||||
@@ -3324,18 +3275,10 @@
|
||||
"render.cpu_budget_triangles": "三角形数上限",
|
||||
"render.cpu_budget_workstation": "工作站型(重型场景)",
|
||||
"render.cpu_captured_hdr": "此图像捕获的HDR",
|
||||
"render.cpu_celestial_adopt": "CPU 光线追踪与间接照明 · 路径深度 {depth} · {spp} SPP · 追踪阴影 · 种子 {seed} · 1 个主样本",
|
||||
"render.cpu_celestial_adopt_label": "CPU 光线追踪与间接照明",
|
||||
"render.cpu_celestial_path_required": "有限太阳和渐变天空使用 CPU 间接照明。",
|
||||
"render.cpu_celestial_switch": "选择带间接照明的 CPU 光线追踪,以保留此太阳或天空。",
|
||||
"render.cpu_celestial_unportable": "当前透射采样设置请继续使用 GPU 路径追踪。",
|
||||
"render.cpu_check_capture_help": "检查和开始会验证相同的文件哈希,或使用嵌入的HDR。每张图像保留各自捕获的源数据。",
|
||||
"render.cpu_check_changed": "设置或模型在上次检查后发生了变化。请在渲染前重新检查。",
|
||||
"render.cpu_constant_direct_help": "未启用间接照明时,此恒定光源可能出现在反射中或透过玻璃可见,但不会照亮不透明表面。要用它照亮场景,请启用间接照明。",
|
||||
"render.cpu_constant_enable_indirect_plan": "路径深度 {depth};每像素 {spp} 个样本的渐进采样(种子 {seed});每个样本一条主光线;光线追踪阴影。保留图像尺寸、CPU预算和其他设置。",
|
||||
"render.cpu_constant_help": "恒定辐亮度作用于反射或透射光线,不提供直接漫反射照明。",
|
||||
"render.cpu_custom_directions": "用自定义平行光替代预设",
|
||||
"render.cpu_denoise_help": "每次图像更新时减少噪点,可能使细节和景深模糊边缘变柔。原始EXR保持不变。调整已完成的图像时使用其原始图像,不会再次降噪。",
|
||||
"render.cpu_direction": "朝向光源的方向(X、Y、Z)",
|
||||
"render.cpu_directional": "平行光",
|
||||
"render.cpu_directional_add": "添加平行光",
|
||||
@@ -3347,11 +3290,8 @@
|
||||
"render.cpu_environment_samples": "HDR照明采样数",
|
||||
"render.cpu_exposure_help": "CPU曝光必须在−16至16 EV之间。超出此范围的GPU曝光会保留,直到你修改。",
|
||||
"render.cpu_fixed": "固定图像",
|
||||
"render.cpu_fixed_retained": "保留固定采样",
|
||||
"render.cpu_glass_enable_path": "启用间接照明",
|
||||
"render.cpu_hdr_help": "支持Radiance .hdr、2:1全景图,最大2048 × 1024、16 MiB。CPU要求文件明确声明sRGB/D65原色,不接受未指定原色或EXR。",
|
||||
"render.cpu_hdr_reflection_help": "HDR粗糙反射要求实体及其面的表面完全为金属。检查会报告不支持的组合。",
|
||||
"render.cpu_indirect_method_required": "请选择CPU光线追踪以启用此间接照明模式。",
|
||||
"render.cpu_key_rim": "主光与轮廓光",
|
||||
"render.cpu_last_check": "上次CPU检查",
|
||||
"render.cpu_light_help": "最多8个平行光。自定义列表为空时关闭平行光;选择预设可恢复预设光源。颜色为线性RGB,强度为相对值而非瓦特。",
|
||||
@@ -3363,32 +3303,18 @@
|
||||
"render.cpu_lighting": "照明预设",
|
||||
"render.cpu_lights": "CPU光源",
|
||||
"render.cpu_linear_color": "线性颜色(R、G、B)",
|
||||
"render.cpu_maps_saved_help": "CPU 渲染可使用已保存的不透明表面贴图:在此选择表面,再将贴图保存到镜头。使用间接光照时,其他实体可以使用玻璃;带有贴图的实体不能使用玻璃。请关闭选定的反射、各向异性和程序化表面效果。临时贴图文件路径使用 GPU。",
|
||||
"render.cpu_material_certification": "CPU 检查会在渲染前验证所支持的玻璃几何体。封闭玻璃使用几何距离。",
|
||||
"render.cpu_neutral": "中性",
|
||||
"render.cpu_opaque_hint": "CPU光线追踪使用不透明背景。",
|
||||
"render.cpu_opaque_required": "CPU 光线追踪需要不透明背景。请关闭透明背景。",
|
||||
"render.cpu_options_pending": "此CPU面板暂不支持编辑自发光、表面效果、玻璃或各向异性。使用前请清除这些API选项。",
|
||||
"render.cpu_path_depth": "路径深度",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_lighting": "间接照明",
|
||||
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_point": "点光源",
|
||||
"render.cpu_point_add": "添加点光源",
|
||||
"render.cpu_point_help": "最多可在所选平行光照明基础上添加8个点光源。距离单位为mm,最小距离不得大于参考距离。",
|
||||
"render.cpu_primary_samples": "每像素边缘采样",
|
||||
"render.cpu_progressive": "CPU渐进渲染",
|
||||
"render.cpu_progressive_adopt": "使用渐进采样",
|
||||
"render.cpu_progressive_adopt_help": "启用后每轮使用一个相机样本。图像大小、灯光和反射设置保持不变。",
|
||||
"render.cpu_progressive_always": "渐进采样始终开启",
|
||||
"render.cpu_progressive_help": "累积新的CPU相机样本并显示每个完整的渲染轮次。选定反射在每个相机样本中尝试一次;经认证的玻璃和平面各向异性保留原有限制。自适应先导样本和正式样本共享CPU配置预算。",
|
||||
"render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;面光源、电介质和各向异性需要固定图像模式。",
|
||||
"render.cpu_progressive_limits": "最多1,048,576像素,像素×采样总数不超过16,777,216。更改采样数不会自动调整图像尺寸。",
|
||||
"render.cpu_progressive_reflection_adopt": "启用渐进采样前,请将所选反射的尝试次数设为1。",
|
||||
"render.cpu_progressive_seed": "CPU采样种子",
|
||||
"render.cpu_progressive_spp": "每像素采样数",
|
||||
"render.cpu_quality_budget_help": "更改尺寸、正式样本上限、种子和CPU资源配置。保留相机、镜头、照明、GI深度和效果。",
|
||||
"render.cpu_quality_progressive_required": "选择CPU预算前,请先在下方启用渐进采样。",
|
||||
"render.cpu_quality_summary": "{width} × {height} · 样本上限 {samples} · 种子 {seed} · {profile}",
|
||||
"render.cpu_reflection_add": "添加粗糙反射",
|
||||
"render.cpu_reflection_depth": "反射深度(1–8)",
|
||||
"render.cpu_reflection_empty": "尚未选择实体,场景反射已关闭。",
|
||||
@@ -3402,21 +3328,7 @@
|
||||
"render.cpu_runtime_help": "照明和环境的数值设置会随配置保存。实体反射和HDR文件仍为临时设置。已有面光源和聚光锥数值会保留。",
|
||||
"render.cpu_sampling_mode": "CPU采样模式",
|
||||
"render.cpu_shadows": "投射阴影",
|
||||
"render.cpu_sky_bounds": "天空旋转必须为 ±1,000,000 弧度内的有限值;强度必须为 0–100。",
|
||||
"render.cpu_studio_enable": "为此灯组启用间接光照",
|
||||
"render.cpu_studio_enable_apply": "启用间接光照并应用",
|
||||
"render.cpu_studio_indirect_help": "此太阳灯组需要深度为8的间接光照、追踪阴影,以及每轮一个相机采样。如果渐进渲染关闭,则以每像素16个采样和种子0开始。保留现有渐进设置和资源配置。不兼容的设置会阻止应用,且不会被更改。",
|
||||
"render.cpu_studio_radiance_limit": "此灯组的面光源辐射亮度为{radiance},超过CPU上限64。保留原有灯光,请选择其他灯组或GPU。",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU 内部散射需要关闭降噪。设置会保留。",
|
||||
"render.cpu_subsurface_help": "散射会改变闭合玻璃内部的光线方向。正散射率需要开启间接光照并关闭降噪。散射率表示每毫米的碰撞次数;0 不增加散射。方向偏置为正时偏向前方,为负时偏向后方。",
|
||||
"render.cpu_summary": "CPU光线追踪 · 反射 · HDR · 平行光与点光源",
|
||||
"render.cpu_transparent_help": "透明PNG移除背景并保留边缘覆盖率。实体和面的不透明度必须保持100%。",
|
||||
"render.cpu_volume_enable_path": "启用间接照明",
|
||||
"render.cpu_volume_enable_path_plan": "CPU 光线追踪 · 路径深度 {depth} · 每像素 {spp} 个采样 · 种子 {seed} · 追踪阴影。保留已编写的大气和图像预算。",
|
||||
"render.cpu_volume_help": "CPU 大气使用间接照明。消光系数为零时,已保存的包围盒仍会保留,但不会产生雾。",
|
||||
"render.cpu_volume_method_adopt": "使用间接照明的 CPU 光线追踪",
|
||||
"render.cpu_volume_method_incompatible": "这些灯光、采样或大气设置无法转移到此方式。您的设置会保留。",
|
||||
"render.cpu_volume_path_required": "启用间接照明以使用 CPU 大气。",
|
||||
"render.cpu_warm_cool": "暖色与冷色",
|
||||
"render.diag_bodies": "可见实体数",
|
||||
"render.diag_job_details": "已捕获任务详情",
|
||||
@@ -3426,7 +3338,6 @@
|
||||
"render.diag_preflight_details": "预检详情",
|
||||
"render.diag_request": "当前精确请求",
|
||||
"render.diag_triangles": "三角形数",
|
||||
"render.directional_sun_limit": "GPU 总共支持八个平行光源,其中最多一个为柔和太阳光。复制硬阴影平行光可添加另一个远处光源。",
|
||||
"render.discard_continue": "放弃草稿并继续",
|
||||
"render.draft_confirm_help": "当前渲染草稿有尚未应用的修改;切换前请保存、明确放弃,或继续编辑。",
|
||||
"render.draft_confirm_title": "保留已编辑的渲染设置",
|
||||
@@ -3576,7 +3487,7 @@
|
||||
"render.export_display": "导出:显示 PNG",
|
||||
"render.export_image_label": "导出图像",
|
||||
"render.export_linear": "导出:原始线性 EXR",
|
||||
"render.exr_pending": "CPU 光线追踪和 GPU 路径追踪可保留原始线性 EXR;压缩、多层和辅助通道仍未实现。",
|
||||
"render.exr_pending": "GPU 可保留线性 EXR;压缩、多层和辅助通道仍未实现。",
|
||||
"render.film_grain": "摄影颗粒",
|
||||
"render.fit": "适配产品视图",
|
||||
"render.fixed_camera": "相机",
|
||||
@@ -3614,20 +3525,11 @@
|
||||
"render.frame_tall": "9:16 · 竖屏",
|
||||
"render.frame_thirds": "三分线",
|
||||
"render.frame_wide": "16:9 · 宽屏",
|
||||
"render.glass_abbe": "阿贝数",
|
||||
"render.glass_dispersion": "色散",
|
||||
"render.glass_film": "薄膜干涉",
|
||||
"render.glass_film_ior": "薄膜折射率",
|
||||
"render.glass_film_thickness": "薄膜厚度",
|
||||
"render.glass_model_enable": "高级玻璃模型",
|
||||
"render.glass_model_help": "玻璃粗糙度会散射透射光线。色散和薄膜颜色使用三个波长波段。粗糙薄片需要关闭直线透射阴影。",
|
||||
"render.glass_roughness": "玻璃粗糙度",
|
||||
"render.gpu_depth": "最大路径深度",
|
||||
"render.gpu_options_help": "采样数、深度、种子和曝光会随此配置保存。仅保存当前图像方法。",
|
||||
"render.gpu_path_crashed": "上次在此图形适配器上设置时 SolidCraft 关闭了,很可能是在图形驱动程序内部,因此不会再自动设置。请改用 CPU 光线追踪,或重试(更新驱动程序后也会重试)。",
|
||||
"render.gpu_path_failed": "GPU 路径追踪器不可用",
|
||||
"render.gpu_path_first_setup": "首次在此 GPU 上设置可能需要几分钟,期间 SolidCraft 仍可正常使用。之后启动时,如果驱动程序保留了已编译的着色器,将直接复用。",
|
||||
"render.gpu_path_not_started": "GPU路径追踪器的设置尚未开始。",
|
||||
"render.gpu_path_preparing": "GPU 路径追踪器:正在编译着色器…",
|
||||
"render.gpu_path_retry": "重试",
|
||||
"render.gpu_seed": "随机种子",
|
||||
@@ -3823,8 +3725,6 @@
|
||||
"render.light_product_centre": "产品中心",
|
||||
"render.light_size": "尺寸",
|
||||
"render.light_softness": "柔和度",
|
||||
"render.light_space_world": "世界坐标光源",
|
||||
"render.light_space_world_help": "光源的位置和方向使用世界坐标。环绕旋转相机不会移动光源。",
|
||||
"render.light_temperature": "色温",
|
||||
"render.light_temperature_help": "近似黑体色调,以线性 RGB 保存。",
|
||||
"render.light_type": "类型",
|
||||
@@ -3881,25 +3781,6 @@
|
||||
"render.linear_help": "保留降噪、曝光、色调映射、滤镜和背景处理前的 32 位浮点 RGBA。线性 sRGB/D65,预乘覆盖率。额外内存经明确准入检查;无压缩或额外通道。",
|
||||
"render.linear_retain": "保留原始线性 EXR",
|
||||
"render.linear_unavailable": "所选已完成图像没有保留 EXR。请在渲染前启用保留;旧 PNG 无法恢复原始辐亮度。",
|
||||
"render.live.adaptive": "实时 · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "停止实时渲染时渲染请求已更改。请重新开始最终图像渲染。",
|
||||
"render.live.final_waiting": "正在准备最终图像 · 释放实时渲染资源",
|
||||
"render.live.invalid_size": "实时渲染需要正的输出宽度和高度。",
|
||||
"render.live.pause": "暂停实时渲染",
|
||||
"render.live.paused": "实时 · 已暂停",
|
||||
"render.live.paused_outdated": "实时 · 已暂停 · 已过时",
|
||||
"render.live.quality_help": "交互质量:最长边最多为 512 像素。使用所选渲染器及其支持的效果。最终分辨率、设置和导出保持独立。",
|
||||
"render.live.refused": "实时渲染不可用:{reason}",
|
||||
"render.live.resume": "恢复实时渲染",
|
||||
"render.live.samples": "实时 · {width} × {height} · {current}/{target} 次采样",
|
||||
"render.live.source_current": "{method} · 实时",
|
||||
"render.live.source_outdated": "{method} · 上一幅图像",
|
||||
"render.live.source_outdated_help": "实时渲染更新或不可用时显示上次捕获的图像。它可能与当前场景或图像设置不同。",
|
||||
"render.live.source_settings_help": "打开输出以编辑渲染器和图像设置。",
|
||||
"render.live.stopped": "实时渲染已停止",
|
||||
"render.live.updating": "实时 · 交互质量(最多 512 px)· 正在更新",
|
||||
"render.live.waiting_canvas": "正在等待渲染画布",
|
||||
"render.live.worker_stopped": "实时渲染捕获工作线程在返回结果之前已停止",
|
||||
"render.look_add": "添加颜色风格",
|
||||
"render.look_applied": "已应用 {name}",
|
||||
"render.look_bleach": "漂白旁路风格",
|
||||
@@ -3929,9 +3810,6 @@
|
||||
"render.material_coat_help": "在现有不透明外观上叠加示意介电涂层。权重为零时保持基础材质不变。",
|
||||
"render.material_color_map": "基础颜色图像(sRGB)",
|
||||
"render.material_color_replace": "基础贴图替换实体颜色",
|
||||
"render.material_cpu_glass_only": "临时图像文件贴图需要GPU。CPU可以使用已保存的内置表面贴图。",
|
||||
"render.material_cpu_optics_limits": "启用间接光照可使用受支持的闭合玻璃形状、经验证的嵌套结构及内部散射。薄片可以透射阴影。否则请使用平面薄片或轴对齐盒体。仍不支持相互接触的形状及穿过闭合玻璃的透射阴影。设置值会保留。",
|
||||
"render.material_cpu_saved_runtime_conflict": "已保存的CPU光学设置不能与临时光学绑定组合。请保存已审阅的设置或移除冲突的临时绑定。",
|
||||
"render.material_custom": "自定义示意光学设置",
|
||||
"render.material_enable": "使用这些 GPU 材质设置",
|
||||
"render.material_filter": "筛选场景实体…",
|
||||
@@ -3952,7 +3830,6 @@
|
||||
"render.material_normal_map": "法线图像(OpenGL,+Y)",
|
||||
"render.material_normal_strength": "法线强度(0–1)",
|
||||
"render.material_origin": "映射原点 X · Y · Z(mm)",
|
||||
"render.material_raytrace_required": "物理玻璃需要CPU光线追踪或GPU路径追踪。",
|
||||
"render.material_refresh": "刷新场景实体",
|
||||
"render.material_refresh_needed": "场景已变化。添加目标前请刷新实体列表。",
|
||||
"render.material_remove": "删除此分配",
|
||||
@@ -3962,7 +3839,6 @@
|
||||
"render.material_revalidate_needed": "此分配属于较早的场景版本。渲染前请重新验证实体的确切身份。",
|
||||
"render.material_roughness": "涂层粗糙度(0.045–1)",
|
||||
"render.material_roughness_map": "粗糙度贴图(线性)",
|
||||
"render.material_saved_edit_pending": "这些光学编辑尚未保存。请保存已审阅的值、移除已保存的分配或放弃编辑。临时绑定和不支持的效果不能覆盖已保存的目标。",
|
||||
"render.material_shadow": "透射直线阴影光线",
|
||||
"render.material_target_missing": "实体序号与名称不再匹配。请删除此分配,并重新明确选择目标实体。",
|
||||
"render.material_thickness": "薄片厚度(mm)",
|
||||
@@ -3974,7 +3850,7 @@
|
||||
"render.method_cpu": "CPU 光线追踪",
|
||||
"render.method_gpu": "GPU 路径追踪",
|
||||
"render.method_limit_raster": "光栅化使用固定的不透明工作室预览;要使用受支持的光学控件,请明确选择光线追踪方式。",
|
||||
"render.method_raster": "CPU 光栅(示意图)",
|
||||
"render.method_raster": "CPU 光栅",
|
||||
"render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。",
|
||||
"render.nested_enable": "经验证的嵌套介质",
|
||||
"render.nested_help": "最多16层包含关系。父体必须凸,子体必须严格位于内部。拒绝相交、接触、薄片和直线透射阴影。内部相机使用经验证的层次结构。不会创建液面或合并接触的CAD边界。",
|
||||
@@ -4002,19 +3878,16 @@
|
||||
"render.output_exr_unavailable": "此处无法设置 EXR 输出。线性 EXR 导出需要保留线性母版的已完成 GPU 图像。",
|
||||
"render.output_fit": "适合窗口",
|
||||
"render.output_format": "格式",
|
||||
"render.output_format_status": "PNG 是完成的显示图像。可选 CPU/GPU EXR 保留显示滤镜前的原始线性 FLOAT32 辐亮度;导出不会替换成预览缓存或其他任务。",
|
||||
"render.output_format_status": "PNG 是完成的显示图像。可选 GPU EXR 保留显示滤镜前的原始线性 FLOAT32 辐亮度;导出不会替换成预览缓存或其他任务。",
|
||||
"render.output_grading": "调色",
|
||||
"render.output_image": "图像",
|
||||
"render.output_largest": "{width} × {height} 最大",
|
||||
"render.output_lock_ratio": "锁定宽高比",
|
||||
"render.output_master_unavailable": "要导出EXR,请选择包含原始EXR数据的已完成图像。渲染新图像前请先启用原始EXR。",
|
||||
"render.output_over_budget": "请求图像超出所选资源范围;请明确选择兼容尺寸或配置,不会自动降级。",
|
||||
"render.output_presentation_limits": "CPU图像效果和EXR输出支持宽度、高度均不超过8192像素且总像素数不超过16,777,216的图像。",
|
||||
"render.output_presets_help": "格式、质量和检查列表位于输出编辑器中。",
|
||||
"render.output_quality": "质量",
|
||||
"render.output_quality_waiting": "等待质量约定",
|
||||
"render.output_ratio_refused": "关联尺寸超出此方式的上限;保留原尺寸。请解锁宽高比或选择较小值。",
|
||||
"render.output_raytrace_only": "图像效果需要CPU光线追踪或GPU路径追踪。",
|
||||
"render.output_ready": "已就绪,可渲染",
|
||||
"render.output_run_check": "运行检查",
|
||||
"render.output_size_limit": "{method}:每边最多 {dimension} px,总计 {pixels} 像素。可用的最大尺寸:{width} × {height}。",
|
||||
@@ -4069,12 +3942,6 @@
|
||||
"render.post_saturation": "饱和度",
|
||||
"render.post_title": "呈现滤镜",
|
||||
"render.post_vignette": "暗角",
|
||||
"render.preparation_cancelled": "渲染准备已取消",
|
||||
"render.preparation_checking": "正在检查渲染",
|
||||
"render.preparation_retirement_failed": "渲染准备清理失败。此窗口无法再准备其他图像。",
|
||||
"render.preparation_retiring": "正在完成渲染准备",
|
||||
"render.preparation_unavailable": "渲染准备不可用",
|
||||
"render.presentation_banner": "图像效果支持CPU和GPU光线追踪。打开此页面不会更改渲染方式。",
|
||||
"render.preview": "集成预览",
|
||||
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
|
||||
"render.preview_image_settings_help": "实时预览使用固定工作室照明。曝光和图像照明在检查或开始时应用于图像任务。",
|
||||
@@ -4093,15 +3960,11 @@
|
||||
"render.quality_ack": "确认固定预算映射和省略项",
|
||||
"render.quality_apply": "使用此固定采样预算",
|
||||
"render.quality_balanced": "均衡",
|
||||
"render.quality_budget_help": "设置尺寸、采样数、深度、种子和降噪。其他图像设置保持不变。",
|
||||
"render.quality_custom": "自定义",
|
||||
"render.quality_denoise_off": "降噪:关闭",
|
||||
"render.quality_denoise_on": "降噪:开启",
|
||||
"render.quality_draft": "草稿",
|
||||
"render.quality_high": "高",
|
||||
"render.quality_projection_help": "此检查仅更改分辨率、采样预算、路径深度、种子和降噪。其他设置保持不变,预算不保证收敛。",
|
||||
"render.quality_projection_help": "Q 预设包含未来需求。此操作仅映射受支持的固定样本 PNG 预算,明确省略自适应停止、原始线性输出和独立透射限制。",
|
||||
"render.quality_review": "审阅采样预算",
|
||||
"render.quality_summary": "{width} × {height} · {samples} 次采样 · 深度 {depth} · 种子 {seed} · {denoise}",
|
||||
"render.queue_pending": "此界面没有自动生产队列或动画渲染器;已完成的静止图像仍可单独导出。",
|
||||
"render.radial_assign": "指定外观",
|
||||
"render.radial_compare": "比较",
|
||||
@@ -4131,7 +3994,7 @@
|
||||
"render.result_navigation": "平移和缩放遵循导航设置。按住空格键并拖动可平移。",
|
||||
"render.result_open": "在主画布中打开已完成的图像。",
|
||||
"render.result_preview": "实时预览",
|
||||
"render.result_provisional": "临时图像",
|
||||
"render.result_provisional": "CPU临时图像",
|
||||
"render.result_provisional_help": "此中间结果无法导出。导出始终使用最后完成的结果;取消后会返回该结果。",
|
||||
"render.result_reduced_display": "显示预览已缩小到视口设备或缓存限制。保留的 PNG 和导出仍为完整分辨率;此缩小视图不能进行原生 100% 检查。",
|
||||
"render.result_retained": "正在显示最后完成的图像;较新的渲染尚未替换它。",
|
||||
@@ -4220,11 +4083,9 @@
|
||||
"render.shot_frame_subject": "框选主体",
|
||||
"render.shot_invalid_camera": "相机值必须为范围内的有限数。",
|
||||
"render.shot_lens": "镜头",
|
||||
"render.shot_lens_cpu_progressive_reason": "启用CPU景深前,请在采样中应用渐进式采样。",
|
||||
"render.shot_lens_enable_reason": "启用景深以编辑光圈和对焦距离。",
|
||||
"render.shot_lens_gpu_reason": "景深需要 GPU 路径追踪。",
|
||||
"render.shot_lens_perspective_reason": "景深需要透视投影。",
|
||||
"render.shot_lens_raytrace_reason": "CPU光线追踪和GPU路径追踪均支持景深。",
|
||||
"render.shot_look_selection": "看向所选内容",
|
||||
"render.shot_manager": "已保存镜头",
|
||||
"render.shot_new_name": "新镜头名称",
|
||||
@@ -4258,12 +4119,7 @@
|
||||
"render.sky_horizon": "地平线 · 线性 RGB",
|
||||
"render.sky_zenith": "天顶 · 线性 RGB",
|
||||
"render.soft_focus": "柔焦",
|
||||
"render.spot_axis": "光线发射方向",
|
||||
"render.spot_cone": "聚光锥",
|
||||
"render.spot_gpu_help": "角度以光线发射方向为基准测量。光线在内角和外角之间逐渐减弱。极窄的衰减范围需要使用CPU渲染。",
|
||||
"render.spot_help": "角度以光线发射方向为基准测量。光线在内角和外角之间逐渐减弱。",
|
||||
"render.spot_inner": "内半角",
|
||||
"render.spot_outer": "外半角",
|
||||
"render.stage_frame": "构图",
|
||||
"render.stage_layout.appearance_note": "点击应用到所选对象。",
|
||||
"render.stage_layout.studio_note": "点击以在应用前检查。",
|
||||
@@ -4303,7 +4159,7 @@
|
||||
"render.stage_panels.prepare_plan": "准备创建计划",
|
||||
"render.stage_panels.projects": "投影",
|
||||
"render.stage_panels.quality_ack": "我确认此固定预算及其省略内容",
|
||||
"render.stage_panels.quality_omissions": "此预算不设置自适应误差目标、独立透射限制、俄罗斯轮盘规则或线性母版保留,也不保证收敛。",
|
||||
"render.stage_panels.quality_omissions": "自适应停止和误差目标、独立透射深度、俄罗斯轮盘规则、原始线性输出、EXR 母版及收敛认证。",
|
||||
"render.stage_panels.reference": "参考",
|
||||
"render.stage_panels.references": "参考",
|
||||
"render.stage_panels.remove_optics": "移除光学设置",
|
||||
@@ -4347,7 +4203,7 @@
|
||||
"render.stage_panels.subtitle_environment": "原创 HDR 环境 · 整个场景",
|
||||
"render.stage_panels.subtitle_maps": "创作的空间贴图 · 临时预览",
|
||||
"render.stage_panels.subtitle_physical": "只读计划 · 未创建或分配材料",
|
||||
"render.stage_panels.subtitle_quality": "GPU 采样预算 · 仅更改列出的设置",
|
||||
"render.stage_panels.subtitle_quality": "固定 GPU 预算 · 省略未来要求",
|
||||
"render.stage_panels.subtitle_rig": "创作的灯光装置 · 发光体和被动卡片",
|
||||
"render.stage_panels.subtitle_saved_maps": "创作的贴图 · 保存在此镜头中",
|
||||
"render.stage_panels.subtitle_stage": "物理地面和舞台 · 适配产品",
|
||||
@@ -4372,7 +4228,7 @@
|
||||
"render.studio_area_help": "最多四个单面矩形。正交半边 U 和 V 定义尺寸;U × V 指向发光侧。坐标采用世界空间毫米。",
|
||||
"render.studio_center": "中心 X, Y, Z(mm)",
|
||||
"render.studio_denoise": "降低最终图像噪点",
|
||||
"render.studio_denoise_help": "渐进图像和最终图像使用保留边缘的滤波,但可能柔化细节。玻璃与锐利镜面不参与滤波。",
|
||||
"render.studio_denoise_help": "最终滤波保留边缘,但可能柔化细节。玻璃与锐利镜面不参与滤波。",
|
||||
"render.studio_direction": "朝向太阳的方向 (X, Y, Z)",
|
||||
"render.studio_environment": "环境",
|
||||
"render.studio_environment_bounds": "环境旋转必须为±1,000,000弧度以内的有限值,强度必须在0至100之间。",
|
||||
@@ -4465,15 +4321,11 @@
|
||||
"render.use_preview": "使用集成预览",
|
||||
"render.volume_albedo": "散射反照率 · 线性 RGB",
|
||||
"render.volume_anisotropy": "相函数平均余弦",
|
||||
"render.volume_denoise_required": "关闭降噪以使用大气。",
|
||||
"render.volume_enable": "启用参与介质",
|
||||
"render.volume_extinction": "消光系数",
|
||||
"render.volume_glass_required": "此设置无法同时使用大气和玻璃。",
|
||||
"render.volume_help": "在一个有限盒内进行 GPU 光传输,对相机和间接路径应用 Beer 衰减与 HG 散射。使用明确设置的工作室灯光、HDR 或天空,并衰减阴影连接。平均余弦为正表示前向散射。必须明确关闭玻璃边界和表面降噪。零消光不改变光传输。这不是月球大气、密度网格、体积发光、光谱传输或认证材料属性。",
|
||||
"render.volume_lighting_required": "选择环境或显式灯光配置以渲染大气。",
|
||||
"render.volume_max": "最大边界 · 世界坐标 mm",
|
||||
"render.volume_min": "最小边界 · 世界坐标 mm",
|
||||
"render.volume_single_stratum_required": "将透射采样数设为 1 以渲染大气。",
|
||||
"render.volume_title": "有界均匀体积",
|
||||
"render.wait_add_light": "尚不支持:创建命名灯光",
|
||||
"render.wait_batch": "暂不支持:已保存镜头渲染队列",
|
||||
@@ -4590,7 +4442,6 @@
|
||||
"sketch.dimension_applied": "已应用尺寸",
|
||||
"sketch.dimension_applied_defined": "已应用尺寸;已定义参数 `{name}`",
|
||||
"sketch.dimension_applied_renamed": "已应用尺寸;参数已命名为 `{name}`",
|
||||
"sketch.divide_hint": "点将每条曲线分成等长的段,并在曲线改变时保持等分。",
|
||||
"sketch.dof_left": "剩余 {count} 个自由度",
|
||||
"sketch.dof_left_one": "剩余 {count} 个自由度",
|
||||
"sketch.draw_curves": "绘制为 {linetype}",
|
||||
@@ -5440,8 +5291,6 @@
|
||||
"ui.fine": "精细",
|
||||
"ui.finest": "最精细",
|
||||
"ui.finish_sketch": "完成草图",
|
||||
"ui.first_dimension_scales": "第一个尺寸缩放整个图形",
|
||||
"ui.first_dimension_scales_tip": "开启:草图的第一个长度尺寸会缩放它所测量的图形,保持其形状不变。关闭:尺寸只改变所测量对象的大小。",
|
||||
"ui.fit": "适合",
|
||||
"ui.fix_unfix": "固定/取消固定",
|
||||
"ui.flat": "平面",
|
||||
@@ -5481,7 +5330,6 @@
|
||||
"ui.heading_custom_view_navigator": "自定义视图导航器",
|
||||
"ui.heading_cylinder": "圆柱体",
|
||||
"ui.heading_design": "设计",
|
||||
"ui.heading_divide_curve": "等分曲线",
|
||||
"ui.heading_draft": "拔模",
|
||||
"ui.heading_edit": "编辑",
|
||||
"ui.heading_equation_curve": "方程曲线",
|
||||
@@ -5564,11 +5412,6 @@
|
||||
"ui.history_play_button_show": "显示播放按钮",
|
||||
"ui.history_play_finish_action": "请先完成当前操作,再播放历史",
|
||||
"ui.history_play_roll_back": "播放前请将历史标记向后移动",
|
||||
"ui.history_scope_all": "显示整个设计的特征",
|
||||
"ui.history_scope_all_shown": "整个设计的特征",
|
||||
"ui.history_scope_pick": "编辑一个零部件,或在浏览器中选中一个,即可只显示它的特征",
|
||||
"ui.history_scope_show": "只显示 {name} 及其内部零部件的特征",
|
||||
"ui.history_scope_shown": "{name} 及其内部零部件的特征",
|
||||
"ui.history_show_hidden": "显示隐藏的组件",
|
||||
"ui.history_show_names": "显示名称",
|
||||
"ui.history_sketch_listed_under": "列在 {feature} 下",
|
||||
@@ -5801,7 +5644,6 @@
|
||||
"ui.point_cloud_no_file_colors": "文件中没有颜色",
|
||||
"ui.point_cloud_pause": "暂停",
|
||||
"ui.point_cloud_play": "播放",
|
||||
"ui.point_cloud_reload": "从文件重新加载",
|
||||
"ui.point_cloud_single_color": "单色",
|
||||
"ui.point_cloud_size": "点大小",
|
||||
"ui.point_cloud_units": "文件单位",
|
||||
@@ -5898,7 +5740,6 @@
|
||||
"ui.second_rail": "第二导轨",
|
||||
"ui.section_analysis": "截面分析",
|
||||
"ui.section_size": "截面尺寸",
|
||||
"ui.segments": "段数",
|
||||
"ui.select": "选择",
|
||||
"ui.selectOther": "选择其他…",
|
||||
"ui.selectTangentChain": "选择相切链",
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
{
|
||||
"order": 155,
|
||||
"provenance": "Original SolidCraft mapping of Solarized (MIT palette)",
|
||||
"base": "dark",
|
||||
"borders": {},
|
||||
"colors": {
|
||||
"accent": "#268bd2",
|
||||
"accent_soft": "#073642",
|
||||
"agent_busy": "#268bd2",
|
||||
"agent_ok": "#859900",
|
||||
"app_bar": "#002b36",
|
||||
"app_bar_hover": "#073642",
|
||||
"app_bar_hover_button": "#073642",
|
||||
"app_bar_hover_strong": "#586e75",
|
||||
"app_bar_hover_subtle": "#073642",
|
||||
"app_bar_separator": "#586e75",
|
||||
"app_bar_tab_active": "#073642",
|
||||
"app_bar_text": "#93a1a1",
|
||||
"axis_x": "#dc322f",
|
||||
"axis_y": "#859900",
|
||||
"axis_z": "#346e88",
|
||||
"bend_down": "#b58900",
|
||||
"bend_up": "#859900",
|
||||
"body": "#586e75",
|
||||
"body_edge": "#346e88",
|
||||
"body_selected": "#073642",
|
||||
"border": "#586e75",
|
||||
"box_crossing": "#859900",
|
||||
"box_window": "#268bd2",
|
||||
"chip": "#268bd2",
|
||||
"construction_geom": "#cb4b16",
|
||||
"construction_plane": "#b4870022",
|
||||
"continuity": [
|
||||
"#859900",
|
||||
"#346e88",
|
||||
"#b58900",
|
||||
"#6c71c4"
|
||||
],
|
||||
"control_border": "#586e75",
|
||||
"control_fill": "#073642",
|
||||
"cube_border": "#586e75",
|
||||
"cube_face": "#073642",
|
||||
"cube_hover": "#586e75",
|
||||
"cube_text": "#93a1a1",
|
||||
"dialog_bg": "#073642",
|
||||
"dialog_body": "#073642",
|
||||
"dialog_border": "#586e75",
|
||||
"dialog_footer": "#073642",
|
||||
"dialog_label": "#93a1a1",
|
||||
"dialog_rule": "#586e75",
|
||||
"dimension": "#2aa198",
|
||||
"dimension_driven": "#1f7f78",
|
||||
"error": "#dc322f",
|
||||
"field": "#002b36",
|
||||
"file_background": "#073642",
|
||||
"file_border": "#586e75",
|
||||
"file_dim": "#93a1a1",
|
||||
"file_image_tint": "#002b36",
|
||||
"file_primary": "#586e75",
|
||||
"file_primary_border": "#268bd2",
|
||||
"file_primary_ink": "#ffffff",
|
||||
"file_rail": "#1b6397",
|
||||
"file_rail_hover": "#2670a6",
|
||||
"file_rail_ink": "#ffffff",
|
||||
"file_recovery": "#073642",
|
||||
"file_recovery_border": "#b58900",
|
||||
"file_recovery_ink": "#93a1a1",
|
||||
"file_recovery_well": "#002b36",
|
||||
"file_surface": "#073642",
|
||||
"form_accent": "#6b70c3",
|
||||
"form_dim": "#839496",
|
||||
"form_error": "#dc322f",
|
||||
"form_error_fill": "#586e75",
|
||||
"form_fill": "#a2a9f8",
|
||||
"form_ink": "#93a1a1",
|
||||
"form_primary": "#268bd2",
|
||||
"form_soft": "#073642",
|
||||
"form_success": "#859900",
|
||||
"form_success_fill": "#586e75",
|
||||
"form_tab_ink": "#565a9d",
|
||||
"glyph_bg": "#073642",
|
||||
"glyph_edge": "#da2f2d",
|
||||
"glyph_text": "#da2f2d",
|
||||
"grid": "#073642",
|
||||
"grid_major": "#586e75",
|
||||
"hover": "#586e75",
|
||||
"hover_edge": "#346e88",
|
||||
"hover_edge_halo": "#002b36",
|
||||
"hover_profile": "#268cd228",
|
||||
"hover_profile_edge": "#346e88",
|
||||
"icon": "#93a1a1",
|
||||
"icons.agent": "#6c71c4",
|
||||
"icons.highlight": "#93a1a1",
|
||||
"icons.marker_halo": "#002b36cc",
|
||||
"interference": "#dc322f",
|
||||
"logo": [
|
||||
"#268bd2",
|
||||
"#346e88",
|
||||
"#586e75"
|
||||
],
|
||||
"manipulator": "#268bd2",
|
||||
"mesh_accent": "#97751d",
|
||||
"mesh_fill": "#d7aa3c",
|
||||
"mesh_ink": "#7b5d00",
|
||||
"mesh_soft": "#073642",
|
||||
"on_accent": "#ffffff",
|
||||
"origin_plane": "#b3860028",
|
||||
"origin_plane_edge": "#b6890088",
|
||||
"origin_plane_hover": "#288bd340",
|
||||
"origin_point": "#839496",
|
||||
"overlay": "#073642",
|
||||
"panel": "#073642",
|
||||
"panel_header": "#002b36",
|
||||
"preview_add": "#346e88",
|
||||
"preview_cut": "#dc323066",
|
||||
"preview_new": "#b58900",
|
||||
"preview_sketch": "#859900",
|
||||
"profile_fill": "#35718722",
|
||||
"render_accent": "#cf317e",
|
||||
"render_fill": "#f986b4",
|
||||
"render_ink": "#ad2c6b",
|
||||
"render_soft": "#073642",
|
||||
"rubber_band": "#268bd2",
|
||||
"section_cap": "#b58900",
|
||||
"sel_edge": "#346e88",
|
||||
"sel_edge_rim": "#346e88",
|
||||
"sel_face": "#073642",
|
||||
"sel_profile": "#073642",
|
||||
"sel_profile_edge": "#346e88",
|
||||
"sel_vertex": "#346e88",
|
||||
"selected_bg": "#0a3e50",
|
||||
"selected_ink": "#93a1a1",
|
||||
"sheet_fill": "#58c6bc",
|
||||
"sheet_ink": "#1d6d67",
|
||||
"sheet_soft": "#073642",
|
||||
"sketch": "#346e88",
|
||||
"sketch_accent": "#738400",
|
||||
"sketch_centerline": "#b58900",
|
||||
"sketch_construction": "#b58900",
|
||||
"sketch_fixed": "#93a1a1",
|
||||
"sketch_halo": "#002b36",
|
||||
"sketch_ink": "#5a6800",
|
||||
"sketch_locked": "#859900",
|
||||
"sketch_lost": "#dc322f",
|
||||
"sketch_point": "#93a1a1",
|
||||
"sketch_point_free": "#346e88",
|
||||
"sketch_projected": "#6c71c4",
|
||||
"sketch_soft": "#073642",
|
||||
"surface_accent": "#cb4b16",
|
||||
"surface_fill": "#fb906b",
|
||||
"surface_ink": "#a83e12",
|
||||
"surface_soft": "#073642",
|
||||
"tab_on_fill_dark": "#002b36",
|
||||
"text": "#93a1a1",
|
||||
"text_dim": "#839496",
|
||||
"timeline": "#073642",
|
||||
"timeline_item": "#073642",
|
||||
"timeline_item_error": "#eabcad",
|
||||
"timeline_marker": "#839496",
|
||||
"toolbar": "#073642",
|
||||
"viewport_bottom": "#002b36",
|
||||
"viewport_top": "#002b36",
|
||||
"warning": "#b58900"
|
||||
},
|
||||
"format": "solidcraft-theme/1",
|
||||
"icon_palette": {
|
||||
"constraint": [
|
||||
"#fee0dc",
|
||||
"#fb8d81",
|
||||
"#da2f2d"
|
||||
],
|
||||
"construction": [
|
||||
"#fee1d7",
|
||||
"#fb906b",
|
||||
"#cb4b16"
|
||||
],
|
||||
"form": [
|
||||
"#e3e6fe",
|
||||
"#a2a9f8",
|
||||
"#6b70c3"
|
||||
],
|
||||
"guide": "#839496",
|
||||
"ink": "#fdf6e3",
|
||||
"line": "#93a1a1",
|
||||
"mesh": [
|
||||
"#fde5b2",
|
||||
"#d7aa3c",
|
||||
"#97751d"
|
||||
],
|
||||
"passive-1": "#586e75",
|
||||
"passive-2": "#073642",
|
||||
"passive-3": "#002b36",
|
||||
"sketch": [
|
||||
"#def67b",
|
||||
"#a8be40",
|
||||
"#738400"
|
||||
],
|
||||
"solid": [
|
||||
"#d5ebff",
|
||||
"#67b9fb",
|
||||
"#1480c6"
|
||||
],
|
||||
"surface": [
|
||||
"#fee1d7",
|
||||
"#fb906b",
|
||||
"#cb4b16"
|
||||
]
|
||||
},
|
||||
"name": "Solarized Dark",
|
||||
"radii": {
|
||||
"control_radius": 6,
|
||||
"radius_lg": 8,
|
||||
"radius_md": 6,
|
||||
"radius_sm": 4,
|
||||
"radius_xl": 10,
|
||||
"sketch_mode_radius": 7
|
||||
}
|
||||
}
|
||||
+50
-99
@@ -3703,27 +3703,6 @@ fn body_at(st: &ModelState, body: &Option<crate::BodyName>, points: &[Vec3]) ->
|
||||
.ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
}
|
||||
|
||||
/// Whether the whole line through `p` along `dir` (both ways) meets the box (slab test). A zero
|
||||
/// component of `dir` keeps the line in its slab only when `p` is already within it.
|
||||
fn line_meets_box(p: Vec3, dir: Vec3, b: &solidcraft_geom::Aabb3) -> bool {
|
||||
if b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
for (o, d, min, max) in [(p.x, dir.x, b.min.x, b.max.x), (p.y, dir.y, b.min.y, b.max.y), (p.z, dir.z, b.min.z, b.max.z)] {
|
||||
if d == 0.0 || !d.is_finite() {
|
||||
if !(min..=max).contains(&o) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (t0, t1) = ((min - o) / d, (max - o) / d);
|
||||
lo = lo.max(t0.min(t1));
|
||||
hi = hi.min(t0.max(t1));
|
||||
}
|
||||
lo <= hi
|
||||
}
|
||||
|
||||
/// A hole stays on its face when the model changes: a position now inside a body moves back
|
||||
/// against the drilling direction to the surface, one now above it moves down onto it. Only the
|
||||
/// bodies in `scope` count (every body when it is empty, see [`hole_scope`]). Edit Hole seeds from
|
||||
@@ -3734,12 +3713,6 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
|
||||
let mut best: Option<Vec3> = None;
|
||||
for b in st.bodies.iter().filter(|b| in_hole_scope(scope, b)) {
|
||||
let m = b.mesh();
|
||||
// Every test below casts along the line through `p`: a mesh whose box that line misses
|
||||
// has no triangle to hit, so skip it before scanning its triangles (in an assembly of
|
||||
// hundreds of parts, these scans were most of a hole's time).
|
||||
if !line_meets_box(p, dir, &m.bounds().inflated(eps * 8.0)) {
|
||||
continue;
|
||||
}
|
||||
// Already on the surface: the drill enters right here.
|
||||
if let Some((t, _)) = m.raycast(p - dir * eps, dir)
|
||||
&& t < eps * 4.0
|
||||
@@ -3756,18 +3729,15 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
|
||||
best.unwrap_or(p)
|
||||
}
|
||||
|
||||
/// The faces of the model near `p`, nearest first, before their surfaces are looked at: for
|
||||
/// each, how far `p` is from it, its body, its index and the outward normal of its nearest mesh
|
||||
/// facet. Only faces within `reach` of `p`, of the bodies in `scope` (every body when it is
|
||||
/// empty). Ties keep body order, then face order.
|
||||
fn faces_near(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<NearFace> {
|
||||
let mut out: Vec<NearFace> = Vec::new();
|
||||
for (i, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && in_hole_scope(scope, b)) {
|
||||
/// The faces of the model near `p`: for each, how far `p` is from it, the outward normal there
|
||||
/// (the surface's own normal for planes, cylinders and spheres, turned the way the face looks out
|
||||
/// of its body; the nearest mesh facet's otherwise) and, for a plane, how far `p` is from the
|
||||
/// plane itself. Only faces within `reach` of `p`, nearest first, of the bodies in `scope`
|
||||
/// (every body when it is empty).
|
||||
fn faces_through(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
|
||||
let mut out: Vec<(f64, Vec3, Option<f64>)> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh() && in_hole_scope(scope, b)) {
|
||||
let m = b.mesh();
|
||||
// No facet is nearer `p` than the mesh's box: one beyond `reach` has none to give.
|
||||
if !m.bounds().inflated(reach).contains(p) {
|
||||
continue;
|
||||
}
|
||||
// The nearest facet of each face of this body.
|
||||
let mut near: BTreeMap<u32, (f64, Vec3)> = BTreeMap::new();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
@@ -3778,67 +3748,53 @@ fn faces_near(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<Nea
|
||||
near.insert(*f, (d, n));
|
||||
}
|
||||
}
|
||||
out.extend(near.into_iter().map(|(f, (d, n))| (d, i, f, n)));
|
||||
for (f, (d, facet)) in near {
|
||||
let (exact, off) = match kernel::face_surface(&b.body, f as usize) {
|
||||
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(*normal), Some((p - point).dot(normal).abs())),
|
||||
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => {
|
||||
let v = p - origin;
|
||||
((v - axis * v.dot(axis)).normalized(), None)
|
||||
}
|
||||
Some(kernel::FaceSurface::Sphere { center, .. }) => ((p - center).normalized(), None),
|
||||
// Square to the line through the apex, not the nearest facet's normal (#10698).
|
||||
Some(kernel::FaceSurface::Cone { apex, axis, half_angle }) => {
|
||||
let v = p - apex;
|
||||
let out = (v - axis * v.dot(axis)).normalized();
|
||||
(out.and_then(|u| (u * half_angle.cos() - axis * half_angle.sin()).normalized()), None)
|
||||
}
|
||||
// Away from the nearest point of the ring the tube goes round.
|
||||
Some(kernel::FaceSurface::Torus { center, axis, major, .. }) => {
|
||||
let v = p - center;
|
||||
let ring = (v - axis * v.dot(axis)).normalized().map(|u| center + u * major);
|
||||
(ring.and_then(|q| (p - q).normalized()), None)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let n = match exact {
|
||||
Some(n) if n.dot(facet) < 0.0 => -n,
|
||||
Some(n) => n,
|
||||
None => facet,
|
||||
};
|
||||
out.push((d, n, off));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// A face of [`faces_near`]: its distance from the point, its body, its index and the outward
|
||||
/// normal of its nearest mesh facet.
|
||||
type NearFace = (f64, usize, u32, Vec3);
|
||||
|
||||
/// One face of [`faces_near`] as [`faces_through`] gives it: the distance, the outward normal
|
||||
/// (the surface's own normal for planes, cylinders, spheres, cones and tori, turned the way the
|
||||
/// face looks out of its body; the nearest mesh facet's otherwise) and, for a plane, how far `p`
|
||||
/// is from the plane itself. Classifying the surface is the costly part, so callers that stop at
|
||||
/// the first face that answers them ask for no more than that.
|
||||
fn face_through(st: &ModelState, p: Vec3, (d, body, f, facet): NearFace) -> (f64, Vec3, Option<f64>) {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.surfaces += 1);
|
||||
let exact = st.bodies.get(body).and_then(|b| kernel::face_surface(&b.body, f as usize));
|
||||
let (exact, off) = match exact {
|
||||
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(*normal), Some((p - point).dot(normal).abs())),
|
||||
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => {
|
||||
let v = p - origin;
|
||||
((v - axis * v.dot(axis)).normalized(), None)
|
||||
}
|
||||
Some(kernel::FaceSurface::Sphere { center, .. }) => ((p - center).normalized(), None),
|
||||
// Square to the line through the apex, not the nearest facet's normal (#10698).
|
||||
Some(kernel::FaceSurface::Cone { apex, axis, half_angle }) => {
|
||||
let v = p - apex;
|
||||
let out = (v - axis * v.dot(axis)).normalized();
|
||||
(out.and_then(|u| (u * half_angle.cos() - axis * half_angle.sin()).normalized()), None)
|
||||
}
|
||||
// Away from the nearest point of the ring the tube goes round.
|
||||
Some(kernel::FaceSurface::Torus { center, axis, major, .. }) => {
|
||||
let v = p - center;
|
||||
let ring = (v - axis * v.dot(axis)).normalized().map(|u| center + u * major);
|
||||
(ring.and_then(|q| (p - q).normalized()), None)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let n = match exact {
|
||||
Some(n) if n.dot(facet) < 0.0 => -n,
|
||||
Some(n) => n,
|
||||
None => facet,
|
||||
};
|
||||
(d, n, off)
|
||||
}
|
||||
|
||||
/// The faces near `p` a hole placed there goes by (as [`faces_near`]): those of the bodies in
|
||||
/// The faces near `p` a hole placed there goes by (as [`faces_through`]): those of the bodies in
|
||||
/// `scope`, the hole's own, when one of them passes within `on` of `p`, else those of every body.
|
||||
/// Where a plate's face touches another body both faces pass through `p`, and a hole that drills
|
||||
/// the plate goes by the plate's face, never the other body's (#12066). A hole placed on another
|
||||
/// body's face, to drill through it into its own body behind, still goes by that face.
|
||||
fn hole_faces_near(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[String]) -> Vec<NearFace> {
|
||||
fn hole_faces_through(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
|
||||
if !scope.is_empty() {
|
||||
let own = faces_near(st, p, reach, scope);
|
||||
if own.first().is_some_and(|c| c.0 <= on) {
|
||||
let own = faces_through(st, p, reach, scope);
|
||||
if own.first().is_some_and(|(d, _, _)| *d <= on) {
|
||||
return own;
|
||||
}
|
||||
}
|
||||
faces_near(st, p, reach, &[])
|
||||
faces_through(st, p, reach, &[])
|
||||
}
|
||||
|
||||
/// The way a hole placed at `p` drills: into the material, against the outward normal of the
|
||||
@@ -3848,22 +3804,17 @@ fn hole_faces_near(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[Strin
|
||||
/// of the bodies in `scope` (the bodies the hole drills; empty for every body) decide.
|
||||
pub fn hole_direction_at(st: &ModelState, p: Vec3, scope: &[String]) -> Option<Vec3> {
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
// Every face of the model, nearest first; each surface is classified only when the rules
|
||||
// below reach it. Classifying all of them first took most of a hole's time in an assembly
|
||||
// of hundreds of parts, where the nearest face nearly always answers.
|
||||
let near = hole_faces_near(st, p, f64::INFINITY, size * 1e-4, scope);
|
||||
let mut faces = near.iter().map(|c| face_through(st, p, *c));
|
||||
let first = faces.next();
|
||||
if let Some((d, n, _)) = first
|
||||
&& d <= size * 1e-4
|
||||
let faces = hole_faces_through(st, p, f64::INFINITY, size * 1e-4, scope);
|
||||
if let Some((d, n, _)) = faces.first()
|
||||
&& *d <= size * 1e-4
|
||||
{
|
||||
return Some(-n);
|
||||
return Some(-*n);
|
||||
}
|
||||
if let Some((_, n, _)) = first.into_iter().chain(faces).find(|(_, _, off)| off.is_some_and(|o| o <= size * 1e-4)) {
|
||||
return Some(-n);
|
||||
if let Some((_, n, _)) = faces.iter().find(|(_, _, off)| off.is_some_and(|o| o <= size * 1e-4)) {
|
||||
return Some(-*n);
|
||||
}
|
||||
let reach = st.bodies.iter().map(crate::appearance::pick_tolerance).fold(0.0, f64::max);
|
||||
first.filter(|(d, _, _)| *d <= reach).map(|(_, n, _)| -n)
|
||||
faces.first().filter(|(d, _, _)| *d <= reach).map(|(_, n, _)| -*n)
|
||||
}
|
||||
|
||||
/// A hole placed on a face drills into the material: when its direction leaves every face it
|
||||
@@ -3876,7 +3827,7 @@ pub fn hole_direction_at(st: &ModelState, p: Vec3, scope: &[String]) -> Option<V
|
||||
/// the plate there, drills the plate (#12066).
|
||||
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 {
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let faces: Vec<_> = hole_faces_near(st, p, size * 1e-5, size * 1e-5, scope).into_iter().map(|c| face_through(st, p, c)).collect();
|
||||
let faces = hole_faces_through(st, p, size * 1e-5, size * 1e-5, scope);
|
||||
if faces.is_empty() || faces.iter().any(|(_, n, _)| n.dot(dir) < -1e-9) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::*;
|
||||
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on,
|
||||
/// bodies whose faces a tapped hole read for its wall, intersects run to find a pattern's copies
|
||||
/// that cut nothing, and the tools a cut of many tools is made with (one when they are cut in one
|
||||
/// go), and face surfaces classified for a hole's direction.
|
||||
/// go).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Counts {
|
||||
pub moved: usize,
|
||||
@@ -36,7 +36,6 @@ pub(super) struct Counts {
|
||||
pub tapped_wall: usize,
|
||||
pub miss_intersects: usize,
|
||||
pub cut_tools: usize,
|
||||
pub surfaces: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -54,7 +53,6 @@ thread_local! {
|
||||
tapped_wall: 0,
|
||||
miss_intersects: 0,
|
||||
cut_tools: 0,
|
||||
surfaces: 0,
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1788,51 +1786,3 @@ fn an_added_and_deleted_sketch_entity_keeps_its_fingerprint() {
|
||||
assert_eq!(model.state().sketch_keys.get(&id).copied(), key);
|
||||
assert_eq!((model.last_recomputed, model.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_skips_the_parts_its_line_cannot_reach() {
|
||||
use solidcraft_geom::Aabb3;
|
||||
let unit = Aabb3 { min: Vec3::ZERO, max: Vec3::new(1.0, 1.0, 1.0) };
|
||||
// Down through the box, from above and from below (the line runs both ways).
|
||||
assert!(super::line_meets_box(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0), &unit));
|
||||
assert!(super::line_meets_box(Vec3::new(0.5, 0.5, -5.0), Vec3::new(0.0, 0.0, -1.0), &unit));
|
||||
// Beside it, parallel to a side: a zero direction component keeps the line out of the slab.
|
||||
assert!(!super::line_meets_box(Vec3::new(2.0, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0), &unit));
|
||||
// Slanted past a corner, and slanted through it.
|
||||
assert!(!super::line_meets_box(Vec3::new(3.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), &unit));
|
||||
assert!(super::line_meets_box(Vec3::new(-1.0, -1.0, 0.5), Vec3::new(1.0, 1.0, 0.0), &unit));
|
||||
// Touching a face counts; an empty box never meets.
|
||||
assert!(super::line_meets_box(Vec3::new(1.0, 0.5, 9.0), Vec3::new(0.0, 0.0, 1.0), &unit));
|
||||
assert!(!super::line_meets_box(Vec3::new(0.5, 0.5, 0.5), Vec3::new(0.0, 0.0, 1.0), &Aabb3::EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_classifies_the_faces_it_reads_not_every_face_of_the_design() {
|
||||
// Forty separate cylinders, three faces each. A hole placed on the top of one drills down,
|
||||
// and finding that out reads the nearest face only: it used to classify every face of every
|
||||
// body first, which in an assembly of hundreds of imported parts took seconds per hole.
|
||||
let mut doc = Document::new("hole direction work");
|
||||
for k in 0..40 {
|
||||
let kind = FeatureKind::Cylinder {
|
||||
base: point_expr(Vec3::new(10.0 * f64::from(k), 0.0, 0.0)),
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: "2".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
references: Vec::new(),
|
||||
};
|
||||
doc.add_feature(kind, None).unwrap();
|
||||
}
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let st = model.state();
|
||||
assert_eq!(st.bodies.len(), 40);
|
||||
let mut dir = None;
|
||||
let work = counted(|| dir = super::hole_direction_at(&st, Vec3::new(250.5, 0.5, 10.0), &[]));
|
||||
let dir = dir.unwrap();
|
||||
assert!((dir - Vec3::new(0.0, 0.0, -1.0)).len() < 1e-9, "drills down into the top: {dir:?}");
|
||||
assert!(work.surfaces <= 2, "classified {} face surfaces for one hole", work.surfaces);
|
||||
// A point on a side wall drills into that wall.
|
||||
let side = super::hole_direction_at(&st, Vec3::new(252.0, 0.0, 5.0), &[]).unwrap();
|
||||
assert!((side - Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-6, "into the side wall: {side:?}");
|
||||
}
|
||||
|
||||
+11
-15
@@ -785,24 +785,20 @@ pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) ->
|
||||
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
|
||||
}
|
||||
}
|
||||
// Each folder's chain of parents ends at the top, walked once: a chain stops at a folder
|
||||
// already known to end there. Walking every chain with a search per step took minutes on a
|
||||
// design with 10 000 nested folders.
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = d.timeline_groups.iter().map(|g| (g.id, g.parent)).collect();
|
||||
let mut ends = std::collections::BTreeSet::new();
|
||||
for group in &d.timeline_groups {
|
||||
if group.parent.is_some_and(|parent| !group_ids.contains(&parent)) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
|
||||
}
|
||||
let mut chain = std::collections::BTreeSet::new();
|
||||
let mut current = Some(group.id);
|
||||
while let Some(id) = current.filter(|id| !ends.contains(id)) {
|
||||
if !chain.insert(id) {
|
||||
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
|
||||
if let Some(parent) = group.parent {
|
||||
if !group_ids.contains(&parent) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut current = Some(parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
|
||||
}
|
||||
current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent);
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
ends.extend(chain);
|
||||
}
|
||||
if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) {
|
||||
return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous")));
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! CPU render jobs can save optional `options.output` presentation: tone map, finishing,
|
||||
//! backdrop and raw linear EXR retention. Older designs omit it and retain the exact previous
|
||||
//! f64 PNG output; exposure remains owned by RenderSetup. Readers missing these changes open
|
||||
//! the newer design with a warning and preserve saved values they cannot read.
|
||||
//! CPU jobs also save optional `options.lens` using the shared physical thin-lens
|
||||
//! aperture radius and axial focus distance. Positive aperture requires perspective
|
||||
//! plus explicit progressive camera sampling; absent/zero aperture keeps old pixels.
|
||||
//! CPU raytrace jobs also retain default-false `transparent_background` beside options.
|
||||
//! It exports straight-alpha primary geometry coverage; absent fields keep opaque PNGs.
|
||||
//! CPU jobs also save opt-in `options.denoise`; omission/false preserves the prior
|
||||
//! raw PNG calculation. Every complete published pass is filtered without changing
|
||||
//! raw film or the final unfiltered EXR. Older files need no rewrite for this flag.
|
||||
//! CPU jobs also preserve optional `options.path: {max_depth: 1..16}` for explicit
|
||||
//! progressive diffuse/specular transport and certified Glass boundaries. The depth
|
||||
//! counts shaded surfaces, optical boundaries and active scattering events. Traced shadows are required;
|
||||
//! selected reflection/anisotropy overrides and unsupported closed-Glass
|
||||
//! shadow connections refuse with authored intent retained. Omission keeps the legacy producer.
|
||||
//!
|
||||
//! CPU raytrace jobs save optional `options.aging` using the existing ordered
|
||||
//! SavedAgingStack schema and stable definition/feature/component/occurrence targets.
|
||||
//! Empty/absent stacks preserve prior saved JSON, CPU pixel arithmetic and work.
|
||||
//! Check and Start resolve stable targets against the captured scene; stale/hidden
|
||||
//! targets are refused rather than made global. Captured indices/names remain
|
||||
//! runtime-only. Aging changes the opaque BRDF before lighting and denoising guides;
|
||||
//! it does not change geometry, emission, physical materials or mass. Certified
|
||||
//! glass targets and passive stages remain excluded; independently authored anisotropic axes
|
||||
//! cannot be replaced by scalar aging roughness and are refused explicitly.
|
||||
//!
|
||||
//! Saves up to eight hard GPU directional sources alongside the existing optional
|
||||
//! finite Sun (eight combined). Older designs omit the new list and retain their
|
||||
//! exact implicit studio or finite-Sun lighting. Readers missing the new list warn
|
||||
//! about the newer design and preserve these unread sources.
|
||||
//! Also saves up to eight GPU point/spot sources with exact world-mm positions,
|
||||
//! reference/minimum distances and optional world-axis/radian half cones. Missing
|
||||
//! punctual lists stay empty. CPU cone intent retains its wider f64 domain; GPU
|
||||
//! cone feathers below the documented precision bound refuse without rewriting.
|
||||
//!
|
||||
//! CPU jobs may also consume the existing portable saved `surfaces` bindings for
|
||||
//! opaque built-in base color, roughness, metallic and normal maps. No new map
|
||||
//! schema is added; raster and unsupported CPU optical combinations refuse the
|
||||
//! authored bindings instead of dropping them. Old files without maps keep the
|
||||
//! old CPU allocation/work/pixel path.
|
||||
//! CPU jobs also save optional `options.adaptive` using the existing independent-pilot
|
||||
//! policy. Explicit progressive production sampling is required; the unchanged CPU
|
||||
//! profile budgets pilots plus the production cap. Exact per-pixel counts and pilot
|
||||
//! statistics stay runtime-only; final SamplingReport and PreviewInfo.adaptive reuse
|
||||
//! the existing metadata contract without changing linear-master schema/channels.
|
||||
|
||||
//! CPU `options.path` may consume the existing saved ClosedMesh optical boundary
|
||||
//! after exact f64 edge, vertex-link, embedding/contact, outward and conservative
|
||||
//! nested-parent certification. Runtime-only ClosedMesh and thin-shadow lowering
|
||||
//! preserve stable authored optics. No new saved field or geometry repair is added.
|
||||
//! Absent-path sheet/box transport and default serialized bytes remain unchanged.
|
||||
//! Closed-shadow connections, Glass combined with external volume and selected
|
||||
//! reflection/anisotropy combinations refuse explicitly with authored values retained.
|
||||
//! Perspective CPU Path captures classify each physical eye/pupil cooperatively
|
||||
//! against the certified boundaries and initialize the nested Beer medium stack.
|
||||
//! Exact origin/near-clip boundary contacts refuse without changing saved cameras
|
||||
//! or optics. This runtime-only extension adds no serialized field.
|
||||
//! Existing saved OpticalMaterial.subsurface may also lower to optional captured
|
||||
//! CPU dielectric subsurface parameters with explicit progressive Path. Selected
|
||||
//! certified closed media use scalar free flight, HG directions and separate Beer
|
||||
//! absorption, including physical eye/pupil starts and nested current media. No
|
||||
//! refracting-boundary next-event approximation is added. Active scattering and
|
||||
//! surface denoising refuse together; unsupported thin/legacy routes retain intent.
|
||||
//! Some(rate0) remains serialized but adds no collision work/RNG or pixel change.
|
||||
//! CPU jobs may also preserve optional options.volume using the same bounded
|
||||
//! HomogeneousVolume world-box/extinction/RGB albedo/HG contract as GPU Atmosphere.
|
||||
//! Positive extinction requires explicit progressive Path and disables no existing
|
||||
//! option implicitly: Glass/SSS and surface denoising refuse with intent retained.
|
||||
//! Absent/zero volume keeps the original transport work/RNG/heap owner count/pixels.
|
||||
//! Inline intent's actual fixed retained-request size is prepaid even when absent;
|
||||
//! regrade owns its separate header reservation until its last status handle drops.
|
||||
//! Physical eye/pupil prefixes and the orthographic signed line/box union do not
|
||||
//! alter saved camera/framing/model bounds or its model near interval. Existing
|
||||
//! ray-distance/profile/task/frame/codec/host caps remain unchanged.
|
||||
|
||||
//! CPU jobs also retain optional `sun` and `sky` intent. `sun` reuses the shared
|
||||
//! finite-cap direction/angular-radius/radiance domain; `sky` reuses the portable
|
||||
//! gradient/rotation/intensity schema. Both require explicit CPU Path transport.
|
||||
//! Omitted fields preserve old designs and the absent producer allocation/work/RNG
|
||||
//! path. Constant, Sky and HDR sources are exclusive; finite Sun is additive.
|
||||
//! No generated sky image or runtime resource is serialized.
|
||||
//!
|
||||
//! CPU and GPU jobs also save `coats`, up to the existing 64 runtime coat target
|
||||
//! bound, with stable definition/feature/component/occurrence identities and exact
|
||||
//! weight, roughness and dielectric IOR. Empty/absent lists keep prior JSON and
|
||||
//! capture behavior. Coats require opaque targets; even a zero-weight authored coat
|
||||
//! cannot share a target with Glass. Capture resolves targets without changing
|
||||
//! appearances, engineering materials or geometry. Unsupported CPU selected
|
||||
//! reflection/anisotropy overrides refuse instead of dropping the coat.
|
||||
@@ -1,8 +0,0 @@
|
||||
//! Glass assignments can save optional `model` roughness, Abbe dispersion and a
|
||||
//! coherent dielectric film (IOR and thickness in nanometres). Omission retains
|
||||
//! the preceding smooth, nondispersive interface and incoherent sheet response.
|
||||
//! CPU and GPU jobs can save optional `caustics` photon count, gather radius in
|
||||
//! millimetres, light-path depth and seed. Omission creates no photon map and
|
||||
//! retains preceding sampling. Older releases ask for an update rather than
|
||||
//! discard these authored material and light-transport choices. No migration of
|
||||
//! existing designs, geometry, engineering material or saved camera is required.
|
||||
@@ -1,4 +0,0 @@
|
||||
//! A point cloud remembers the file it was read from (#8818): `source`, its path, which
|
||||
//! `pointcloud.reload` reads again, and `max_points`, the limit it reads it under. Older designs
|
||||
//! need no transformation: their clouds have no source and cannot be reloaded, only inserted again. The last release refuses a design saved
|
||||
//! with this (the newer format number) instead of opening its clouds without their source.
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A sketch constraint can hold a point at a share of a curve's length (`"type": "point_along"`,
|
||||
//! with the point `p`, the curve `c` and the share `at` from 0 to 1), as Divide Curve places its
|
||||
//! points (#8969). Older designs need no transformation: they have no such constraint. The last
|
||||
//! release refuses a design that has one and asks for an update, instead of failing to read its
|
||||
//! sketch.
|
||||
@@ -95,17 +95,9 @@ fn with_unit(rhs: &str, units: &str) -> String {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// `name = expression` typed again exactly as an existing user parameter was defined (each
|
||||
/// of several holes made one at a time, or one command per hole, given `ScrewDepth = 5`):
|
||||
/// the same value in the same unit, so it uses that parameter instead of being refused.
|
||||
fn defines_again(&self, d: &Pending) -> bool {
|
||||
self.param(&d.name).is_some_and(|p| !p.model && p.expr.trim() == d.expression.trim() && d.unit.as_ref().is_none_or(|u| *u == p.unit))
|
||||
}
|
||||
|
||||
/// Store every value typed as `name = expression`: the user parameter `name` with that
|
||||
/// expression (a bare number read as the value would read it, in that unit), and `name`
|
||||
/// where the text was. A name that is taken or reserved is refused (unless it is a user
|
||||
/// parameter defined exactly so, which the text then names), as is a definition where
|
||||
/// where the text was. A name that is taken or reserved is refused, as is a definition where
|
||||
/// none can be made: in a parameter's own expression or a configuration cell. All or nothing:
|
||||
/// on an error the design is unchanged.
|
||||
pub fn define_inline(&mut self) -> Result<Vec<Defined>> {
|
||||
@@ -190,7 +182,7 @@ impl Document {
|
||||
if let Some(problem) = Self::param_name_problem(&d.name) {
|
||||
return Err(DocError::Invalid(problem));
|
||||
}
|
||||
if self.name_taken(&d.name) && !self.defines_again(d) {
|
||||
if self.name_taken(&d.name) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"there is already a parameter `{}`: type `{}` to use it, or change it in Parameters",
|
||||
d.name, d.name
|
||||
@@ -199,10 +191,6 @@ impl Document {
|
||||
}
|
||||
let mut out = Vec::with_capacity(found.defs.len());
|
||||
for d in found.defs {
|
||||
// Typed again as it was defined, the text just names the parameter already there.
|
||||
if self.defines_again(&d) {
|
||||
continue;
|
||||
}
|
||||
// A value of no fixed kind (a joint's) is evaluated without the design's units, so
|
||||
// its bare number stays a millimetre length whatever they are (#8865): `w = 5` is
|
||||
// what `5` is there.
|
||||
|
||||
@@ -74,7 +74,6 @@ pub use print_face::PrintFace;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
pub mod render_aging;
|
||||
pub mod render_coat;
|
||||
pub mod render_job;
|
||||
pub mod render_optics;
|
||||
pub mod render_resources;
|
||||
|
||||
+11
-30
@@ -371,14 +371,6 @@ impl Document {
|
||||
users.entry(x.as_str()).or_default().push(k.clone());
|
||||
}
|
||||
}
|
||||
// A feature that reads a parameter itself (a sketch point at `a`) is one of its users
|
||||
// too, named as `parameters.graph` names it (#12293).
|
||||
let reads: Vec<(String, BTreeSet<String>)> = self.features.iter().map(|f| (format!("Feature:{}", f.name), self.feature_reads(f))).collect();
|
||||
for (feature, names) in &reads {
|
||||
for n in names {
|
||||
users.entry(n.as_str()).or_default().push(feature.clone());
|
||||
}
|
||||
}
|
||||
let used_by = |n: &str| -> Vec<String> { users.get(n).cloned().unwrap_or_default() };
|
||||
let value = |n: &str, k: Kind| {
|
||||
vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }).map(expr::round_reported)
|
||||
@@ -1044,34 +1036,23 @@ impl Document {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
}
|
||||
// Sheet metal and plastic features rebuild when their rules' parameters change (#4420).
|
||||
if f.kind.is_sheet() {
|
||||
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
if f.kind.is_plastic() {
|
||||
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
names.extend(self.feature_reads(f));
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parameters a feature reads through expressions it holds itself, not through a named
|
||||
/// input or dimension of its own: a sketch's point coordinates, texts, equations and pattern
|
||||
/// expressions, and the rules a sheet metal or plastic feature is built with.
|
||||
fn feature_reads(&self, f: &crate::Feature) -> BTreeSet<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
}
|
||||
// Sheet metal and plastic features rebuild when their rules' parameters change (#4420).
|
||||
if f.kind.is_sheet() {
|
||||
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
if f.kind.is_plastic() {
|
||||
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Features whose result depends on a parameter (directly or through other parameters).
|
||||
pub fn features_using_param(&self, name: &str) -> Vec<u64> {
|
||||
let defs = self.param_defs();
|
||||
|
||||
@@ -80,13 +80,6 @@ pub struct PointCloud {
|
||||
/// Only the elements within every filter's range are shown.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub filters: Vec<Filter>,
|
||||
/// The file the cloud was read from, to read again (`pointcloud.reload`).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub source: String,
|
||||
/// The most points the file was read under, which a reload reads it under again; `None`: the
|
||||
/// reader's default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_points: Option<u64>,
|
||||
}
|
||||
|
||||
/// Where a property's values come from on one kind of element.
|
||||
@@ -563,8 +556,6 @@ mod tests {
|
||||
complex: Complex::default(),
|
||||
color_property: None,
|
||||
filters: Vec::new(),
|
||||
source: String::new(),
|
||||
max_points: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ fn cloud(points: &[[f32; 3]], segments: &[u32], triangles: &[u32], columns: Vec<
|
||||
complex: Complex { segments: Indices(segments.to_vec().into()), triangles: Indices(triangles.to_vec().into()), columns, names: Vec::new() },
|
||||
color_property: None,
|
||||
filters: Vec::new(),
|
||||
source: String::new(),
|
||||
max_points: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Portable opaque clear-coat assignments. Targets are stable definition/feature/
|
||||
//! component/occurrence identities; captured mesh ordinals never enter a design.
|
||||
use crate::{
|
||||
DocError, Result,
|
||||
render_surface::{RenderBodyTarget, bounded_sequence},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::clear_coat::ClearCoat;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Matches the existing runtime clear-coat override bound for either executor.
|
||||
pub const MAX_COAT_BINDINGS: usize = 64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CoatMaterial {
|
||||
pub weight: f64,
|
||||
pub roughness: f64,
|
||||
pub ior: f64,
|
||||
}
|
||||
impl PartialEq for CoatMaterial {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.weight.to_bits() == other.weight.to_bits()
|
||||
&& self.roughness.to_bits() == other.roughness.to_bits()
|
||||
&& self.ior.to_bits() == other.ior.to_bits()
|
||||
}
|
||||
}
|
||||
impl Default for CoatMaterial {
|
||||
fn default() -> Self {
|
||||
Self { weight: 1.0, roughness: 0.12, ior: 1.5 }
|
||||
}
|
||||
}
|
||||
impl CoatMaterial {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
self.to_runtime().map(|_| ())
|
||||
}
|
||||
pub fn to_runtime(self) -> Result<ClearCoat> {
|
||||
ClearCoat::new(self.weight, self.roughness, self.ior).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SavedCoat {
|
||||
pub target: RenderBodyTarget,
|
||||
pub coat: CoatMaterial,
|
||||
}
|
||||
impl SavedCoat {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
self.coat.validate()
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CoatBindings(Vec<SavedCoat>);
|
||||
impl CoatBindings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = &SavedCoat> {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.len() > MAX_COAT_BINDINGS || self.0.capacity() > MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("at most 64 saved clear-coat targets".into()));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for binding in self.iter() {
|
||||
binding.validate()?;
|
||||
if !seen.insert(&binding.target) {
|
||||
return Err(DocError::Invalid("duplicate saved clear-coat target".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Validate before mutation; replacement is explicit and stays on the same target.
|
||||
pub fn set(&mut self, binding: SavedCoat, replace: bool) -> Result<()> {
|
||||
binding.validate()?;
|
||||
if let Some(old) = self.0.iter_mut().find(|old| old.target == binding.target) {
|
||||
if old == &binding {
|
||||
return Ok(());
|
||||
}
|
||||
if !replace {
|
||||
return Err(DocError::Invalid("clear-coat target already has a binding; explicit replacement required".into()));
|
||||
}
|
||||
*old = binding;
|
||||
} else {
|
||||
if self.len() >= MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("at most 64 saved clear-coat targets".into()));
|
||||
}
|
||||
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("could not reserve saved clear-coat binding".into()))?;
|
||||
if self.0.capacity() > MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("saved clear-coat capacity exceeds admission".into()));
|
||||
}
|
||||
self.0.push(binding);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn remove(&mut self, target: &RenderBodyTarget) -> bool {
|
||||
let before = self.len();
|
||||
self.0.retain(|binding| &binding.target != target);
|
||||
self.len() != before
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for CoatBindings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let result = Self(bounded_sequence::<D, SavedCoat, MAX_COAT_BINDINGS>(d)?);
|
||||
result.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -28,15 +28,12 @@ pub enum RenderJob {
|
||||
},
|
||||
CpuRaytrace {
|
||||
#[serde(default)]
|
||||
transparent_background: bool,
|
||||
// Keep numerical CPU intent out of every raster/GPU enum value too.
|
||||
#[serde(default)]
|
||||
options: Box<CpuJob>,
|
||||
options: CpuJob,
|
||||
},
|
||||
GpuPath {
|
||||
#[serde(default)]
|
||||
transparent_background: bool,
|
||||
// StudioLighting contains bounded inline area/directional sources; boxing keeps the other variants small.
|
||||
// StudioLighting is a bounded four-light value; boxing keeps the other variants small.
|
||||
#[serde(default)]
|
||||
options: Box<GpuJob>,
|
||||
},
|
||||
@@ -47,19 +44,7 @@ impl RenderJob {
|
||||
match self {
|
||||
Self::Raster { .. } if exposure_ev != 0.0 => Err(DocError::Invalid("saved raster rendering requires exposure_ev: 0".into())),
|
||||
Self::Raster { .. } => Ok(()),
|
||||
Self::CpuRaytrace { options, transparent_background } => {
|
||||
options.validate(exposure_ev)?;
|
||||
if let Some(output) = &options.output {
|
||||
output
|
||||
.presentation(exposure_ev, [0; 3], *transparent_background)
|
||||
.validate()
|
||||
.map_err(|error| DocError::Invalid(error.to_string()))?;
|
||||
}
|
||||
if let Some(lens) = options.lens {
|
||||
lens.validate_camera(camera).map_err(|error| DocError::Invalid(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::CpuRaytrace { options } => options.to_runtime(exposure_ev).map(|_| ()),
|
||||
Self::GpuPath { options, transparent_background } => {
|
||||
if *transparent_background && options.backdrop.is_some() {
|
||||
return Err(DocError::Invalid("choose a camera backdrop or transparent PNG, not both".into()));
|
||||
@@ -82,26 +67,6 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sun: Option<Sun>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sky: Option<SkyEnvironment>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
pub aging: crate::render_aging::SavedAgingStack,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<solidcraft_render::job::CpuPathOptions>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<Box<solidcraft_render::job::OutputOptions>>,
|
||||
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
|
||||
pub budget: CpuBudget,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -129,16 +94,6 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
caustics: None,
|
||||
volume: None,
|
||||
sun: None,
|
||||
sky: None,
|
||||
aging: Default::default(),
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
budget: CpuBudget::Compact,
|
||||
progressive: None,
|
||||
primary_samples: 1,
|
||||
@@ -156,28 +111,12 @@ impl Default for CpuJob {
|
||||
}
|
||||
|
||||
impl CpuJob {
|
||||
/// Validate saved parameters without inventing captured targets.
|
||||
pub fn validate(&self, exposure_ev: f64) -> Result<()> {
|
||||
self.validated_parameters(exposure_ev).map(|_| ())
|
||||
}
|
||||
/// Validate portable map compatibility without claiming that stable aging
|
||||
/// identities have been resolved into captured runtime body indices.
|
||||
pub fn validate_surface_maps(&self, exposure_ev: f64) -> Result<()> {
|
||||
let options = self.validated_parameters(exposure_ev)?;
|
||||
solidcraft_render::job::textures::validate_cpu_options(&options).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
/// Pure lowering, valid on hosts without a CPU raytracer. Checks bounds before cloning lists.
|
||||
pub fn to_runtime(&self, exposure_ev: f64) -> Result<RaytraceOptions> {
|
||||
if self.aging.iter().any(|layer| layer.target.is_some()) {
|
||||
return Err(DocError::Invalid("targeted CPU aging requires stable occurrence resolution against the current scene".into()));
|
||||
}
|
||||
self.validated_parameters(exposure_ev)
|
||||
}
|
||||
fn validated_parameters(&self, exposure_ev: f64) -> Result<RaytraceOptions> {
|
||||
if let Some(hdr) = self.hdr {
|
||||
hdr.validate()?;
|
||||
if self.environment.is_some() || self.sky.is_some() {
|
||||
return Err(DocError::Invalid("choose either saved CPU HDR, gradient Sky or constant environment".into()));
|
||||
if self.environment.is_some() {
|
||||
return Err(DocError::Invalid("choose either saved CPU HDR or constant environment".into()));
|
||||
}
|
||||
}
|
||||
bounded(&self.lights, 8, "directional lights")?;
|
||||
@@ -189,27 +128,7 @@ impl CpuJob {
|
||||
// Preserve hidden editable intent in CpuJob, but do not submit tuning for
|
||||
// a light family that is explicitly absent. This does not rewrite the file.
|
||||
let active_area = self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty());
|
||||
let aging = self.aging.parameters_only()?;
|
||||
let sky = self
|
||||
.sky
|
||||
.as_ref()
|
||||
.map(|sky| -> Result<_> {
|
||||
sky.validate()?;
|
||||
let SkyEnvironment::Sky { gradient, rotation_rad, intensity } = *sky;
|
||||
Ok(Box::new(solidcraft_render::job::CpuSky { gradient, rotation_rad, intensity }))
|
||||
})
|
||||
.transpose()?;
|
||||
let options = RaytraceOptions {
|
||||
caustics: self.caustics,
|
||||
volume: self.volume,
|
||||
sun: self.sun.map(Box::new),
|
||||
sky,
|
||||
aging: (!aging.is_empty()).then(|| Box::new(aging)),
|
||||
adaptive: self.adaptive.map(Box::new),
|
||||
denoise: self.denoise,
|
||||
path: self.path,
|
||||
lens: self.lens,
|
||||
output: self.output.clone(),
|
||||
budget: self.budget,
|
||||
progressive: self.progressive,
|
||||
primary_samples: self.primary_samples,
|
||||
@@ -233,8 +152,6 @@ impl CpuJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GpuJob {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
pub aging: crate::render_aging::SavedAgingStack,
|
||||
pub nested_media: bool,
|
||||
@@ -269,7 +186,6 @@ impl Default for GpuJob {
|
||||
fn default() -> Self {
|
||||
let options = JobOptions::default();
|
||||
Self {
|
||||
caustics: None,
|
||||
aging: Default::default(),
|
||||
nested_media: options.nested_media,
|
||||
volume: options.volume,
|
||||
@@ -317,7 +233,6 @@ impl GpuJob {
|
||||
environment.validate()?;
|
||||
}
|
||||
let options = JobOptions {
|
||||
caustics: self.caustics,
|
||||
aging: self.aging.parameters_only()?,
|
||||
nested_media: self.nested_media,
|
||||
volume: self.volume,
|
||||
@@ -431,50 +346,3 @@ fn punctual_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resul
|
||||
fn area_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<Vec<RectangleLightOptions>>, D::Error> {
|
||||
optional_lights::<D, RectangleLightOptions, 4>(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_volume_portable_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuPathOptions, CpuProgressiveOptions, volume::HomogeneousVolume};
|
||||
#[test]
|
||||
fn cpu_volume_saved_parameters_roundtrip_lower_without_default_adoption_or_hidden_repairs() {
|
||||
let v = HomogeneousVolume {
|
||||
min_mm: [-2.0, -3.0, -4.0],
|
||||
max_mm: [5.0, 6.0, 7.0],
|
||||
extinction_per_mm: 0.3,
|
||||
scattering_albedo: [0.2, 0.4, 0.6],
|
||||
anisotropy: -0.37,
|
||||
};
|
||||
let old = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(old.get("volume").is_none());
|
||||
let mut job = CpuJob {
|
||||
volume: Some(v),
|
||||
path: Some(CpuPathOptions { max_depth: 8 }),
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 4, seed: 9 }),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&job).unwrap();
|
||||
let reopened: CpuJob = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(reopened, job);
|
||||
assert_eq!(reopened.to_runtime(0.0).unwrap().volume, Some(v));
|
||||
job.denoise = true;
|
||||
let before = job.clone();
|
||||
assert!(job.validate(0.0).is_err());
|
||||
assert_eq!(job, before);
|
||||
job.denoise = false;
|
||||
job.path = None;
|
||||
let before = job.clone();
|
||||
assert!(job.validate(0.0).is_err());
|
||||
assert_eq!(job, before);
|
||||
job.volume.as_mut().unwrap().extinction_per_mm = 0.0;
|
||||
job.denoise = true;
|
||||
job.validate(0.0).unwrap();
|
||||
let inactive = serde_json::to_value(&job).unwrap();
|
||||
assert_eq!(inactive["volume"]["anisotropy"], -0.37);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(inactive).unwrap(), job);
|
||||
job.volume = None;
|
||||
job.denoise = false;
|
||||
job.progressive = None;
|
||||
assert_eq!(serde_json::to_value(job).unwrap(), old);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ pub enum OpticalBoundary {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct OpticalMaterial {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<solidcraft_render::glass_model::GlassModel>,
|
||||
pub ior: f64,
|
||||
pub absorption_per_mm: [f64; 3],
|
||||
pub boundary: OpticalBoundary,
|
||||
@@ -34,79 +32,12 @@ pub struct OpticalMaterial {
|
||||
}
|
||||
impl Default for OpticalMaterial {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: None,
|
||||
ior: 1.5,
|
||||
absorption_per_mm: [0.0; 3],
|
||||
boundary: Default::default(),
|
||||
thickness_mm: 0.0,
|
||||
shadow_transmission: false,
|
||||
subsurface: None,
|
||||
}
|
||||
Self { ior: 1.5, absorption_per_mm: [0.0; 3], boundary: Default::default(), thickness_mm: 0.0, shadow_transmission: false, subsurface: None }
|
||||
}
|
||||
}
|
||||
impl OpticalMaterial {
|
||||
/// The CPU consumer is a certified subset of the existing saved optical contract.
|
||||
/// Keep authored unsupported values intact and refuse them, never drop effects.
|
||||
pub fn validate_for_cpu(self) -> Result<()> {
|
||||
self.validate_for_cpu_path(false)
|
||||
}
|
||||
/// General closed boundaries require the explicit finite progressive path.
|
||||
pub fn validate_for_cpu_path(self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.model.is_some() && !path {
|
||||
return Err(DocError::Invalid("advanced Glass models require CPU Indirect lighting".into()));
|
||||
}
|
||||
if self.shadow_transmission && (!path || self.boundary != OpticalBoundary::ThinSheet) {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU transmissive shadows require Indirect lighting and a thin sheet; closed refractive light connections remain unsupported".into(),
|
||||
));
|
||||
}
|
||||
if self.subsurface.is_some() && !path {
|
||||
return Err(DocError::Invalid("CPU subsurface scattering requires Indirect lighting and a certified closed medium".into()));
|
||||
}
|
||||
if self.absorption_per_mm.into_iter().any(|v| !(0.0..=100.0).contains(&v)) || !(0.0..=10000.0).contains(&self.thickness_mm) {
|
||||
return Err(DocError::Invalid("CPU glass absorption must be 0..100 per mm and virtual sheet thickness 0..10000 mm".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A closed optical boundary is accepted only if the CPU producer subsequently
|
||||
/// certifies the captured mesh as its exact axis-aligned-box subset. The saved
|
||||
/// boundary is not rewritten and no arbitrary closed-mesh support is implied.
|
||||
pub fn to_cpu(self, body_index: usize, body_name: &str) -> Result<solidcraft_render::job::DielectricOptions> {
|
||||
self.to_cpu_for_path(body_index, body_name, false)
|
||||
}
|
||||
pub fn to_cpu_for_path(self, body_index: usize, body_name: &str, path: bool) -> Result<solidcraft_render::job::DielectricOptions> {
|
||||
self.validate_for_cpu_path(path)?;
|
||||
if body_name.is_empty() || body_name.len() > 1024 || body_name.chars().any(char::is_control) {
|
||||
return Err(DocError::Invalid("CPU optical target requires a bounded captured body name".into()));
|
||||
}
|
||||
Ok(solidcraft_render::job::DielectricOptions {
|
||||
model: self.model,
|
||||
subsurface: self.subsurface,
|
||||
shadow_transmission: self.shadow_transmission,
|
||||
body_index,
|
||||
body_name: body_name.into(),
|
||||
boundary: match self.boundary {
|
||||
OpticalBoundary::ThinSheet => solidcraft_render::job::DielectricBoundary::ThinSheet,
|
||||
OpticalBoundary::ClosedMesh if path => solidcraft_render::job::DielectricBoundary::ClosedMesh,
|
||||
OpticalBoundary::ClosedBox | OpticalBoundary::ClosedMesh => solidcraft_render::job::DielectricBoundary::ClosedBox,
|
||||
},
|
||||
ior: self.ior,
|
||||
absorption_per_mm: self.absorption_per_mm,
|
||||
thickness_mm: self.thickness_mm,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<()> {
|
||||
let error = |message: String| DocError::Invalid(message);
|
||||
if let Some(model) = self.model {
|
||||
model.validate(self.ior).map_err(|e| error(e.to_string()))?;
|
||||
if self.boundary == OpticalBoundary::ThinSheet && self.shadow_transmission && model.roughness > 0.0 {
|
||||
return Err(error("rough thin-sheet Glass requires disabled straight transmitted shadows".into()));
|
||||
}
|
||||
}
|
||||
OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, self.ior).map_err(|e| error(e.to_string()))?;
|
||||
if self.boundary != OpticalBoundary::ThinSheet && self.thickness_mm != 0.0 {
|
||||
return Err(error("closed optical thickness comes from geometry; thickness_mm must be zero".into()));
|
||||
@@ -159,19 +90,6 @@ impl OpticalBindings {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate_for_cpu(&self) -> Result<()> {
|
||||
self.validate_for_cpu_path(false)
|
||||
}
|
||||
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.len() > 8 {
|
||||
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
|
||||
}
|
||||
for binding in self.iter() {
|
||||
binding.optics.validate_for_cpu_path(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn set(&mut self, binding: SavedOptics, replace: bool) -> Result<()> {
|
||||
binding.validate()?;
|
||||
if let Some(old) = self.0.iter_mut().find(|old| old.target == binding.target) {
|
||||
@@ -207,48 +125,6 @@ impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod advanced_model_contract_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
|
||||
#[test]
|
||||
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
|
||||
let model = GlassModel {
|
||||
roughness: 0.27,
|
||||
dispersion: Some(Dispersion { abbe_number: 45.0 }),
|
||||
thin_film: Some(ThinFilm { ior: 1.36, thickness_nm: 420.0 }),
|
||||
};
|
||||
let optics = OpticalMaterial { model: Some(model), ..Default::default() };
|
||||
let wire = serde_json::to_value(optics).unwrap();
|
||||
assert_eq!(serde_json::from_value::<OpticalMaterial>(wire.clone()).unwrap(), optics);
|
||||
assert_eq!(optics.to_cpu_for_path(2, "Original Glass", true).unwrap().model, Some(model));
|
||||
assert!(optics.to_cpu(2, "Original Glass").is_err());
|
||||
assert_eq!(serde_json::to_value(optics).unwrap(), wire);
|
||||
assert!(serde_json::to_value(OpticalMaterial::default()).unwrap().get("model").is_none());
|
||||
let sheet = OpticalMaterial {
|
||||
model: Some(GlassModel { roughness: 0.0, ..model }),
|
||||
boundary: OpticalBoundary::ThinSheet,
|
||||
shadow_transmission: true,
|
||||
..optics
|
||||
};
|
||||
sheet.validate_for_cpu_path(true).unwrap();
|
||||
assert!(OpticalMaterial { model: Some(model), ..sheet }.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
|
||||
use crate::render_job::{CpuJob, GpuJob};
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
|
||||
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
|
||||
let gpu = GpuJob { caustics, ..Default::default() };
|
||||
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
|
||||
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
|
||||
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -290,86 +166,4 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_optical_consumer_preserves_saved_boundary_and_refuses_unrepresented_effects() {
|
||||
let closed = OpticalMaterial { ior: 1.51, absorption_per_mm: [0.1, 0.02, 0.0], ..Default::default() };
|
||||
let original = serde_json::to_value(closed).unwrap();
|
||||
let cpu = closed.to_cpu(3, "Glass@part-2").unwrap();
|
||||
assert_eq!(cpu.boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
assert_eq!((cpu.body_index, cpu.body_name.as_str()), (3, "Glass@part-2"));
|
||||
assert_eq!((cpu.ior, cpu.absorption_per_mm), (1.51, [0.1, 0.02, 0.0]));
|
||||
assert_eq!(serde_json::to_value(closed).unwrap(), original, "CPU specialization never rewrites the authored closed boundary");
|
||||
let thin = OpticalMaterial { boundary: OpticalBoundary::ThinSheet, thickness_mm: 2.0, ..closed };
|
||||
assert_eq!(thin.to_cpu(0, "Sheet").unwrap().boundary, solidcraft_render::job::DielectricBoundary::ThinSheet);
|
||||
for (value, reason) in [
|
||||
(OpticalMaterial { shadow_transmission: true, ..thin }, "transmissive shadows"),
|
||||
(OpticalMaterial { subsurface: Some(Subsurface { scattering_per_mm: 0.0, anisotropy: 0.0 }), ..closed }, "subsurface"),
|
||||
(OpticalMaterial { absorption_per_mm: [101.0, 0.0, 0.0], ..closed }, "absorption"),
|
||||
(OpticalMaterial { thickness_mm: 10001.0, ..thin }, "thickness"),
|
||||
] {
|
||||
let before = serde_json::to_value(value).unwrap();
|
||||
assert!(value.to_cpu(0, "Target").unwrap_err().to_string().contains(reason));
|
||||
assert_eq!(serde_json::to_value(value).unwrap(), before);
|
||||
}
|
||||
let mut optics = OpticalBindings::default();
|
||||
for feature in 1..=9 {
|
||||
optics
|
||||
.set(
|
||||
SavedOptics {
|
||||
target: RenderBodyTarget { definition: format!("Glass{feature}"), feature, component: 0, occurrence_path: vec![] },
|
||||
optics: closed,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
optics.validate().unwrap();
|
||||
assert!(optics.validate_for_cpu().is_err());
|
||||
let target = RenderBodyTarget { definition: "Glass".into(), feature: 1, component: 0, occurrence_path: vec![] };
|
||||
let mut setup = crate::render_settings::RenderSetup {
|
||||
job: Some(crate::render_job::RenderJob::CpuRaytrace { transparent_background: false, options: Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.optics.set(SavedOptics { target, optics: closed }, false).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<crate::render_settings::RenderSetup>(wire).unwrap(), setup);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_general_glass_contract_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn saved_general_glass_requires_explicit_path_and_preserves_unsupported_choices_and_old_wire() {
|
||||
let optics = OpticalMaterial { ior: 1.51, absorption_per_mm: [0.1, 0.2, 0.3], boundary: OpticalBoundary::ClosedMesh, ..Default::default() };
|
||||
assert_eq!(optics.to_cpu(0, "Glass").unwrap().boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
let general = optics.to_cpu_for_path(0, "Glass", true).unwrap();
|
||||
assert_eq!(general.boundary, solidcraft_render::job::DielectricBoundary::ClosedMesh);
|
||||
assert_eq!((general.ior, general.absorption_per_mm), (optics.ior, optics.absorption_per_mm));
|
||||
let scattering = OpticalMaterial { subsurface: Some(Subsurface { scattering_per_mm: 0.1, anisotropy: 0.3 }), ..optics };
|
||||
let before = serde_json::to_value(scattering).unwrap();
|
||||
assert!(scattering.to_cpu(0, "Glass").is_err(), "the legacy consumer remains explicit");
|
||||
assert_eq!(scattering.to_cpu_for_path(0, "Glass", true).unwrap().subsurface, scattering.subsurface);
|
||||
assert_eq!(serde_json::to_value(scattering).unwrap(), before);
|
||||
let encoded = serde_json::to_value(&general).unwrap();
|
||||
assert!(encoded.get("shadow_transmission").is_none());
|
||||
assert_eq!(serde_json::from_value::<solidcraft_render::job::DielectricOptions>(encoded).unwrap(), general);
|
||||
let thin = OpticalMaterial { boundary: OpticalBoundary::ThinSheet, shadow_transmission: true, ..optics };
|
||||
assert!(thin.to_cpu(0, "Glass").is_err());
|
||||
assert!(thin.to_cpu_for_path(0, "Glass", true).unwrap().shadow_transmission);
|
||||
for value in [
|
||||
OpticalMaterial { shadow_transmission: true, ..optics },
|
||||
OpticalMaterial {
|
||||
boundary: OpticalBoundary::ThinSheet,
|
||||
subsurface: Some(Subsurface { scattering_per_mm: 0.1, anisotropy: 0.0 }),
|
||||
..optics
|
||||
},
|
||||
] {
|
||||
let before = value;
|
||||
assert!(value.to_cpu_for_path(0, "Glass", true).is_err());
|
||||
assert_eq!(value, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ pub struct RenderSetup {
|
||||
pub surfaces: crate::render_surface::SurfaceBindings,
|
||||
#[serde(skip_serializing_if = "crate::render_optics::OpticalBindings::is_empty")]
|
||||
pub optics: crate::render_optics::OpticalBindings,
|
||||
#[serde(skip_serializing_if = "crate::render_coat::CoatBindings::is_empty")]
|
||||
pub coats: crate::render_coat::CoatBindings,
|
||||
#[serde(skip_serializing_if = "solidcraft_render::stage::Stage::is_empty")]
|
||||
pub stage: solidcraft_render::stage::Stage,
|
||||
}
|
||||
@@ -80,7 +78,6 @@ impl Default for RenderSetup {
|
||||
background: [236, 240, 245],
|
||||
job: None,
|
||||
optics: Default::default(),
|
||||
coats: Default::default(),
|
||||
surfaces: Default::default(),
|
||||
stage: Default::default(),
|
||||
}
|
||||
@@ -90,13 +87,9 @@ impl Default for RenderSetup {
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let (limits, cpu) = match &self.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => (
|
||||
options.budget.image_limits(options.progressive.is_some()),
|
||||
Some(CpuSize {
|
||||
progressive: solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
}),
|
||||
),
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
(options.budget.image_limits(options.progressive.is_some()), Some(CpuSize { progressive: options.progressive }))
|
||||
}
|
||||
Some(RenderJob::GpuPath { .. }) => (solidcraft_render::job::GPU_PATH_LIMITS, None),
|
||||
_ => (RenderLimits::default(), None),
|
||||
};
|
||||
@@ -118,30 +111,8 @@ impl RenderSetup {
|
||||
fn validate_with_limits(&self, limits: RenderLimits, cpu: Option<CpuSize>) -> Result<()> {
|
||||
self.surfaces.validate()?;
|
||||
self.optics.validate()?;
|
||||
self.coats.validate()?;
|
||||
if !self.coats.is_empty() && !matches!(self.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved clear coats require an explicit CPU or GPU raytrace job".into()));
|
||||
}
|
||||
if self.coats.iter().any(|coat| self.optics.iter().any(|optics| coat.target == optics.target)) {
|
||||
return Err(DocError::Invalid("clear coat and Glass cannot share a target, including zero-weight authored coats".into()));
|
||||
}
|
||||
match &self.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
self.optics.validate_for_cpu_path(options.path.is_some())?;
|
||||
if options.volume.is_some_and(|v| v.active()) && !self.optics.is_empty() {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU Atmosphere cannot combine with saved Glass or subsurface scattering; settings are kept".into(),
|
||||
));
|
||||
}
|
||||
if options.denoise && self.optics.iter().any(|binding| binding.optics.subsurface.is_some_and(|s| s.active())) {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU subsurface random walks have no surface-denoiser guides; disable denoising explicitly".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(RenderJob::GpuPath { .. }) => {}
|
||||
_ if !self.optics.is_empty() => return Err(DocError::Invalid("saved optics require an explicit CPU or GPU raytrace job".into())),
|
||||
_ => {}
|
||||
if !self.optics.is_empty() && !matches!(self.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved optics require an explicit GPU job".into()));
|
||||
}
|
||||
if self.optics.iter().any(|o| self.surfaces.iter().any(|s| s.target == o.target)) {
|
||||
return Err(DocError::Invalid("saved opaque surface maps and optical boundaries cannot share a target".into()));
|
||||
@@ -150,23 +121,10 @@ impl RenderSetup {
|
||||
if !self.stage.is_empty() && matches!(self.job, Some(RenderJob::Raster { .. })) {
|
||||
return Err(DocError::Invalid("passive stage geometry requires raytracing; raster cannot silently discard it".into()));
|
||||
}
|
||||
if !self.surfaces.is_empty() {
|
||||
match &self.job {
|
||||
Some(RenderJob::GpuPath { .. }) => {}
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if !self.optics.is_empty() && options.path.is_none() {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU surface maps cannot combine with saved Glass; preserve the bindings and select a compatible method".into(),
|
||||
));
|
||||
}
|
||||
options.validate_surface_maps(self.exposure_ev)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(DocError::Invalid(
|
||||
"saved surfaces require an explicit CPU raytrace or GPU path job; unsupported methods cannot discard bindings".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !self.surfaces.is_empty() && !matches!(self.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid(
|
||||
"saved surfaces require an explicit CPU raytrace or GPU path job; unsupported methods cannot discard bindings".into(),
|
||||
));
|
||||
}
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
@@ -212,22 +170,10 @@ impl RenderSetup {
|
||||
));
|
||||
}
|
||||
job.validate(&self.camera, self.exposure_ev)?;
|
||||
if let RenderJob::CpuRaytrace { options, .. } = job {
|
||||
if options.denoise {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
options
|
||||
.budget
|
||||
.validate_image(
|
||||
self.width,
|
||||
self.height,
|
||||
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
if let Some(output) = &options.output {
|
||||
output.validate_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
if let RenderJob::CpuRaytrace { options } = job
|
||||
&& let Some(progressive) = options.progressive
|
||||
{
|
||||
progressive.validate_image_with_budget(self.width, self.height, options.budget).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
|
||||
@@ -299,7 +245,7 @@ impl RenderSettings {
|
||||
}
|
||||
}
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if let Some(hdr) = options.hdr {
|
||||
hdr.validate_resource(&self.resources)?;
|
||||
}
|
||||
@@ -382,12 +328,11 @@ mod tests {
|
||||
RenderSetup {
|
||||
exposure_ev: 1.25,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
transparent_background: false,
|
||||
options: Box::new(CpuJob {
|
||||
options: CpuJob {
|
||||
lights: Some(Vec::new()),
|
||||
hdr: Some(CpuHdrEnvironment { resource: id, rotation_rad: 0.75, intensity: 2.0, samples: 64 }),
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -635,7 +580,7 @@ mod tests {
|
||||
let cpu = |budget, width, height| RenderSetup {
|
||||
width,
|
||||
height,
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(CpuJob { budget, ..Default::default() }) }),
|
||||
job: Some(RenderJob::CpuRaytrace { options: CpuJob { budget, ..Default::default() } }),
|
||||
..Default::default()
|
||||
};
|
||||
let compact = cpu(CpuBudget::Compact, 3840, 2160);
|
||||
@@ -643,7 +588,7 @@ mod tests {
|
||||
assert!(message.contains("3840 × 2160 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
|
||||
assert!(message.contains("the largest image at this aspect is 2730 × 1536"), "{message}");
|
||||
assert!(message.contains("choose the Studio CPU resource profile, or a smaller size"), "{message}");
|
||||
assert!(matches!(&compact.job, Some(RenderJob::CpuRaytrace { options, .. }) if options.budget == CpuBudget::Compact));
|
||||
assert!(matches!(&compact.job, Some(RenderJob::CpuRaytrace { options }) if options.budget == CpuBudget::Compact));
|
||||
let message = compact.validate_for_cpu(CpuBudget::Compact).unwrap_err().to_string();
|
||||
assert!(message.contains("choose the Studio CPU resource profile"), "{message}");
|
||||
|
||||
@@ -702,7 +647,7 @@ mod tests {
|
||||
let saved = serde_json::to_value(&reopened.render).unwrap();
|
||||
assert!(saved["setups"]["Legacy"].get("job").is_none());
|
||||
assert_eq!(saved["setups"]["Legacy"]["environment_rotation"], 0.75);
|
||||
let RenderJob::CpuRaytrace { options, .. } = settings.setups["CPU"].job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
let RenderJob::CpuRaytrace { options } = settings.setups["CPU"].job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
let runtime = options.to_runtime(settings.setups["CPU"].exposure_ev).unwrap();
|
||||
assert_eq!(runtime.exposure_ev, 1.25);
|
||||
assert_eq!(runtime.area_samples, Some(64));
|
||||
@@ -726,11 +671,11 @@ mod tests {
|
||||
for options in [json!({}), json!({"lights":null,"punctual_lights":null,"area_lights":null,"environment":null})] {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace","options":options}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup.job, Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() }));
|
||||
assert_eq!(setup.job, Some(RenderJob::CpuRaytrace { options: CpuJob::default() }));
|
||||
}
|
||||
let empty: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace","options":{"lights":[]}}})).unwrap();
|
||||
empty.validate().unwrap();
|
||||
assert_ne!(empty.job, Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() }));
|
||||
assert_ne!(empty.job, Some(RenderJob::CpuRaytrace { options: CpuJob::default() }));
|
||||
for (lighting, explicit) in [(serde_json::Value::Null, false), (json!({"environment":[0,0,0],"area_lights":[]}), true)] {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"job":{"method":"gpu_path","options":{"lighting":lighting}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
@@ -752,7 +697,7 @@ mod tests {
|
||||
let document = Document { render: Arc::new(settings), ..Default::default() };
|
||||
let reopened = Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
let RenderJob::CpuRaytrace { options, .. } = reopened.render.setups["Film"].job.as_ref().unwrap() else { panic!("method changed") };
|
||||
let RenderJob::CpuRaytrace { options } = reopened.render.setups["Film"].job.as_ref().unwrap() else { panic!("method changed") };
|
||||
let runtime = options.to_runtime(1.5).unwrap();
|
||||
assert_eq!(runtime.progressive.unwrap().seed, u32::MAX);
|
||||
assert_eq!(runtime.exposure_ev, 1.5);
|
||||
@@ -769,104 +714,6 @@ mod tests {
|
||||
assert!(serde_json::from_value::<RenderSettings>(incompatible).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_transparent_intent_roundtrips_offline_and_keeps_opaque_defaults() {
|
||||
use serde_json::json;
|
||||
let opaque: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace"}})).unwrap();
|
||||
assert!(matches!(opaque.job, Some(RenderJob::CpuRaytrace { transparent_background: false, .. })));
|
||||
let setup: RenderSetup =
|
||||
serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"primary_samples":4,"output":{"linear_exr":true}}}}))
|
||||
.unwrap();
|
||||
setup.validate().unwrap();
|
||||
let document = Document {
|
||||
render: Arc::new(RenderSettings { setups: std::collections::BTreeMap::from([("Cutout".into(), setup)]), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(document, Document::from_json(&document.to_json()).unwrap());
|
||||
for output in [json!({"backdrop":{}}), json!({"finishing":{"bloom":{"strength":1}}}), json!({"finishing":{"photographic":{"grain":0.03}}})] {
|
||||
let settings = json!({"setups":{"Cutout":{"job":{"method":"cpu_raytrace","transparent_background":true,"options":{"output":output}}}}});
|
||||
let error = serde_json::from_value::<RenderSettings>(settings.clone()).unwrap_err();
|
||||
assert!(error.to_string().contains("opaque"), "valid opaque-only effects remain authored and refused");
|
||||
let mut saved: serde_json::Value = serde_json::from_str(&Document::default().to_json()).unwrap();
|
||||
saved["render"] = settings;
|
||||
let error = Document::from_json(&saved.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("opaque"), "the full document loader must enforce the same saved output contract");
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<RenderSetup>(json!({"job":{"method":"cpu_raytrace","options":{"transparent_background":true}}})).is_err(),
|
||||
"one alpha authority beside the numerical job"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_physical_lens_roundtrips_offline_and_keeps_disabled_legacy_intent() {
|
||||
use serde_json::json;
|
||||
let camera = Camera { fov: 0.7, distance: 20.0, ..Default::default() };
|
||||
let saved = json!({"setups":{"Focus":{"width":64,"height":32,"camera":camera,
|
||||
"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":8,"seed":81},
|
||||
"lens":{"aperture_radius_mm":0.5,"focus_distance_mm":20.0}}}}}});
|
||||
let settings: RenderSettings = serde_json::from_value(saved.clone()).unwrap();
|
||||
let document = Document { render: Arc::new(settings), ..Default::default() };
|
||||
assert_eq!(document, Document::from_json(&document.to_json()).unwrap());
|
||||
let RenderJob::CpuRaytrace { options, .. } = document.render.setups["Focus"].job.as_ref().unwrap() else { panic!("CPU method changed") };
|
||||
assert_eq!(options.to_runtime(1.5).unwrap().lens, options.lens);
|
||||
let mut fixed = saved.clone();
|
||||
fixed["setups"]["Focus"]["job"]["options"].as_object_mut().unwrap().remove("progressive");
|
||||
assert!(serde_json::from_value::<RenderSettings>(fixed.clone()).is_err());
|
||||
fixed["setups"]["Focus"]["job"]["options"]["lens"]["aperture_radius_mm"] = json!(0);
|
||||
fixed["setups"]["Focus"]["camera"]["fov"] = json!(0);
|
||||
let zero: RenderSettings = serde_json::from_value(fixed).unwrap();
|
||||
assert_eq!(serde_json::to_value(&zero).unwrap()["setups"]["Focus"]["job"]["options"]["lens"]["aperture_radius_mm"].as_f64(), Some(0.0));
|
||||
for lens in [
|
||||
json!({"aperture_radius_mm":-1,"focus_distance_mm":20}),
|
||||
json!({"aperture_radius_mm":0.5,"focus_distance_mm":0}),
|
||||
json!({"aperture_radius_mm":0.5,"focus_distance_mm":20,"f_stop":2}),
|
||||
] {
|
||||
let mut invalid = saved.clone();
|
||||
invalid["setups"]["Focus"]["job"]["options"]["lens"] = lens;
|
||||
assert!(serde_json::from_value::<RenderSettings>(invalid).is_err());
|
||||
}
|
||||
let mut ortho = saved;
|
||||
ortho["setups"]["Focus"]["camera"]["fov"] = json!(0);
|
||||
assert!(serde_json::from_value::<RenderSettings>(ortho).is_err());
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("lens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_output_presentation_roundtrips_offline_without_another_exposure_authority() {
|
||||
use serde_json::json;
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("output").is_none());
|
||||
let saved = json!({"setups":{"CPU output":{"width":64,"height":32,"exposure_ev":1.5,
|
||||
"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":7,"seed":81},
|
||||
"output":{"tone_map":"aces_fitted","finishing":{"rgb_gain":[1.2,0.8,1.1],"photographic":{"grain":0.03,"seed":42},
|
||||
"bloom":{"strength":0.2,"threshold":0.5,"radius":2}},"backdrop":{"mode":"radial"},"linear_exr":true}}}}}});
|
||||
let settings: RenderSettings = serde_json::from_value(saved).unwrap();
|
||||
let setup = &settings.setups["CPU output"];
|
||||
let RenderJob::CpuRaytrace { options, .. } = setup.job.as_ref().unwrap() else { panic!("CPU job expected") };
|
||||
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
|
||||
assert_eq!(runtime.exposure_ev, 1.5);
|
||||
assert_eq!(runtime.output, options.output);
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert!(encoded["setups"]["CPU output"]["job"]["options"]["output"].get("exposure_ev").is_none());
|
||||
assert_eq!(settings, serde_json::from_value::<RenderSettings>(encoded.clone()).unwrap());
|
||||
for bad in [json!({"exposure_ev":1}), json!({"samples":4}), json!({"linear_exr":"yes"}), json!({"finishing":{"rgb_gain":[-1,1,1]}})] {
|
||||
let mut invalid = encoded.clone();
|
||||
invalid["setups"]["CPU output"]["job"]["options"]["output"] = bad;
|
||||
assert!(serde_json::from_value::<RenderSettings>(invalid).is_err());
|
||||
}
|
||||
let wide = json!({"width":16384,"height":1,"job":{"method":"cpu_raytrace","options":{"budget":"workstation"}}});
|
||||
serde_json::from_value::<RenderSetup>(wide.clone()).unwrap().validate().unwrap();
|
||||
for output in [json!({"tone_map":"aces_fitted"}), json!({"linear_exr":true})] {
|
||||
let mut invalid = wide.clone();
|
||||
invalid["job"]["options"]["output"] = output;
|
||||
assert!(
|
||||
serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err(),
|
||||
"active output must respect its existing shared envelope"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_jobs_reject_unbounded_invalid_and_nonportable_inputs() {
|
||||
use serde_json::json;
|
||||
@@ -970,7 +817,6 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod cpu_budget_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
|
||||
#[test]
|
||||
@@ -991,12 +837,11 @@ mod cpu_budget_tests {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
transparent_background: false,
|
||||
options: Box::new(CpuJob {
|
||||
options: CpuJob {
|
||||
budget: CpuBudget::Studio,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 82 }),
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1004,7 +849,7 @@ mod cpu_budget_tests {
|
||||
let text = serde_json::to_string(&setup).unwrap();
|
||||
let reopened: RenderSetup = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(setup, reopened);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &mut setup.job else {
|
||||
panic!("CPU fixture");
|
||||
};
|
||||
options.budget = CpuBudget::Compact;
|
||||
@@ -1016,372 +861,4 @@ mod cpu_budget_tests {
|
||||
let legacy_large = RenderSetup { width: 3840, height: 2160, ..Default::default() };
|
||||
assert!(legacy_large.validate().is_err(), "large intent must name an explicit CPU job profile");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_path_is_explicit_bounded_offline_intent_and_roundtrips_without_changing_legacy_jobs() {
|
||||
let old = CpuJob::default();
|
||||
assert!(old.path.is_none());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
|
||||
for depth in [1, 8, 16] {
|
||||
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut document.render).setups.insert("GI".into(), setup);
|
||||
let bytes = serde_json::to_vec(&document).unwrap();
|
||||
let reopened: crate::Document = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
}
|
||||
for raw in [
|
||||
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":8}}),
|
||||
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
|
||||
] {
|
||||
let options: CpuJob = serde_json::from_value(raw).unwrap();
|
||||
assert!(options.to_runtime(0.0).is_err());
|
||||
}
|
||||
assert!(serde_json::from_value::<CpuJob>(json!({"path":{"max_depth":8,"unknown":true}})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
|
||||
use solidcraft_render::job::CpuBudget;
|
||||
let mut setup = RenderSetup {
|
||||
width: 16,
|
||||
height: 8,
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(CpuJob { denoise: true, ..Default::default() }) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(wire["job"]["options"]["denoise"], true);
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
assert!(options.to_runtime(1.5).unwrap().denoise);
|
||||
options.denoise = false;
|
||||
assert!(serde_json::to_value(&setup).unwrap()["job"]["options"].get("denoise").is_none());
|
||||
let default: CpuJob = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
let explicit: CpuJob = serde_json::from_value(serde_json::json!({"denoise":false})).unwrap();
|
||||
assert_eq!(default, explicit);
|
||||
assert!(!default.to_runtime(0.0).unwrap().denoise);
|
||||
assert!(serde_json::from_value::<CpuJob>(serde_json::json!({"denoise":"true"})).is_err());
|
||||
setup.width = 8193;
|
||||
setup.height = 1;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.budget = CpuBudget::Workstation;
|
||||
setup.validate().unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.denoise = true;
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn composed_cpu_path_preserves_saved_optics_alpha_filter_lens_and_master_intent() {
|
||||
use crate::render_optics::{OpticalMaterial, SavedOptics};
|
||||
use crate::render_surface::RenderBodyTarget;
|
||||
use solidcraft_render::job::{CpuPathOptions, CpuProgressiveOptions, OutputOptions, ThinLens};
|
||||
let mut setup = RenderSetup {
|
||||
camera: Camera { fov: 0.6, distance: 123.0, ..Default::default() },
|
||||
width: 8,
|
||||
height: 8,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
transparent_background: true,
|
||||
options: Box::new(CpuJob {
|
||||
denoise: true,
|
||||
path: Some(CpuPathOptions { max_depth: 3 }),
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 81 }),
|
||||
lens: Some(ThinLens { aperture_radius_mm: 1.0, focus_distance_mm: 123.0 }),
|
||||
output: Some(Box::new(OutputOptions { linear_exr: true, ..Default::default() })),
|
||||
..Default::default()
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
setup
|
||||
.optics
|
||||
.set(
|
||||
SavedOptics {
|
||||
target: RenderBodyTarget { definition: "Glass".into(), feature: 1, component: 0, occurrence_path: Vec::new() },
|
||||
optics: OpticalMaterial::default(),
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let before = setup.clone();
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup, before, "validation must not lower or rewrite authored optics");
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU fixture") };
|
||||
options.path = None;
|
||||
setup.validate().unwrap();
|
||||
assert!(matches!(&setup.job, Some(RenderJob::CpuRaytrace { transparent_background:true, options })
|
||||
if options.denoise && options.output.as_ref().unwrap().linear_exr && options.lens.is_some()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn gpu_hard_directional_intent_roundtrips_and_hostile_lists_refuse_at_document_load() {
|
||||
use serde_json::json;
|
||||
let light = json!({"direction":[0.0,0.0,2.0],"color":[0.25,0.5,1.0],"intensity":0.0});
|
||||
let settings = json!({"setups":{"Lights":{"job":{"method":"gpu_path","options":{"lighting":{"directional_lights":vec![light.clone();8]}}}}}});
|
||||
let saved: RenderSettings = serde_json::from_value(settings.clone()).unwrap();
|
||||
let doc = crate::Document { render: std::sync::Arc::new(saved.clone()), ..Default::default() };
|
||||
assert_eq!(crate::Document::from_json(&doc.to_json()).unwrap(), doc);
|
||||
let encoded = serde_json::to_value(&saved).unwrap();
|
||||
assert_eq!(encoded["setups"]["Lights"]["job"]["options"]["lighting"]["directional_lights"], json!(vec![light.clone(); 8]));
|
||||
for list in [
|
||||
json!(vec![light.clone(); 9]),
|
||||
json!([{"direction":[0,0,0],"color":[1,1,1],"intensity":1}]),
|
||||
json!([{"direction":[0,0,1],"color":[1,1,1],"intensity":65}]),
|
||||
] {
|
||||
let mut bad = settings.clone();
|
||||
bad["setups"]["Lights"]["job"]["options"]["lighting"]["directional_lights"] = list;
|
||||
assert!(serde_json::from_value::<RenderSettings>(bad).is_err());
|
||||
}
|
||||
let mut over_combined = settings;
|
||||
over_combined["setups"]["Lights"]["job"]["options"]["lighting"]["sun"] =
|
||||
json!({"direction":[0,0,1],"angular_radius_rad":0.02,"radiance":[1,1,1]});
|
||||
assert!(serde_json::from_value::<RenderSettings>(over_combined.clone()).is_err());
|
||||
let mut document = serde_json::to_value(crate::Document::default()).unwrap();
|
||||
document["render"] = over_combined;
|
||||
assert!(crate::Document::from_json(&serde_json::to_string(&document).unwrap()).is_err());
|
||||
let old: RenderSettings = serde_json::from_value(json!({"setups":{"Old":{"job":{"method":"gpu_path","options":{}}}}})).unwrap();
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &old.setups["Old"].job else { panic!("gpu job") };
|
||||
assert!(options.lighting.is_none());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn saved_cpu_aging_is_optional_bounded_and_preserves_stable_targets_without_runtime_ordinals() {
|
||||
use crate::render_aging::SavedAgingStack;
|
||||
use crate::render_surface::RenderBodyTarget;
|
||||
use solidcraft_render::job::aging::{AgingKind, AgingLayer};
|
||||
let old = CpuJob::default();
|
||||
assert!(serde_json::to_value(&old).unwrap().get("aging").is_none());
|
||||
let mut layers = SavedAgingStack::default();
|
||||
layers.push(AgingLayer::preset(AgingKind::Dust)).unwrap();
|
||||
let target = RenderBodyTarget { definition: "Aged panel".into(), feature: 19, component: 0, occurrence_path: Vec::new() };
|
||||
layers.get_mut(0).unwrap().target = Some(target.clone());
|
||||
let job = CpuJob { aging: layers.clone(), progressive: Some(Default::default()), denoise: true, ..Default::default() };
|
||||
job.validate(0.5).unwrap();
|
||||
assert!(job.to_runtime(0.5).unwrap_err().to_string().contains("stable occurrence"));
|
||||
let setup = RenderSetup {
|
||||
width: 8,
|
||||
height: 8,
|
||||
exposure_ev: 0.5,
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: true, options: Box::new(job) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(wire["job"]["options"]["aging"][0]["target"]["definition"], "Aged panel");
|
||||
assert!(wire["job"]["options"]["aging"][0]["target"].get("body_index").is_none());
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire.clone()).unwrap(), setup);
|
||||
let mut legacy = wire.clone();
|
||||
legacy["job"]["options"]["aging"][0]["target"] = serde_json::json!({"body_index":0,"body_name":"Aged panel"});
|
||||
assert!(serde_json::from_value::<RenderSetup>(legacy).unwrap_err().to_string().contains("explicit rebinding"));
|
||||
let mut nine = wire;
|
||||
nine["job"]["options"]["aging"] = serde_json::json!(vec![serde_json::json!({}); 9]);
|
||||
assert!(serde_json::from_value::<RenderSetup>(nine).is_err());
|
||||
layers.get_mut(0).unwrap().target = None;
|
||||
let runtime = CpuJob { aging: layers, ..Default::default() }.to_runtime(0.5).unwrap();
|
||||
assert!(runtime.aging.unwrap().active());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_adaptive_saved_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
#[test]
|
||||
fn saved_cpu_adaptive_intent_roundtrips_offline_and_pays_pilots_inside_the_existing_profile() {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"width":128,"height":64,
|
||||
"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"progressive":{"samples_per_pixel":48,"seed":81},"adaptive":{"pilot_samples":16,"min_samples":16},
|
||||
"denoise":true,"path":{"max_depth":3},"output":{"linear_exr":true}}}}))
|
||||
.unwrap();
|
||||
setup.validate().unwrap();
|
||||
let value = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(value.clone()).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = setup.job.as_ref() else { panic!("CPU job expected") };
|
||||
let runtime = options.to_runtime(0.0).unwrap();
|
||||
assert_eq!(runtime.adaptive.as_deref().copied(), options.adaptive);
|
||||
assert_eq!(runtime.preview_target().unwrap(), 64);
|
||||
assert_eq!(runtime.camera_sampling().unwrap(), Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 81 }));
|
||||
let mut too_large = setup.clone();
|
||||
too_large.width = 1024;
|
||||
too_large.height = 1024;
|
||||
assert!(too_large.validate().is_err(), "64 total passes exceed Compact's existing camera-sample bound");
|
||||
let mut cap = value.clone();
|
||||
cap["job"]["options"]["progressive"]["samples_per_pixel"] = json!(64);
|
||||
assert!(
|
||||
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
|
||||
"16 pilots cannot be hidden outside Compact's 64-sample cap"
|
||||
);
|
||||
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
|
||||
let mut runtime = runtime.clone();
|
||||
runtime.budget = budget;
|
||||
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
|
||||
assert_eq!(runtime.preview_target().unwrap(), maximum);
|
||||
runtime.progressive.as_mut().unwrap().samples_per_pixel += 1;
|
||||
assert!(runtime.validate().is_err(), "{budget:?} must include the pilot stage in its unchanged cap");
|
||||
}
|
||||
let mut fixed = value.clone();
|
||||
fixed["job"]["options"].as_object_mut().unwrap().remove("progressive");
|
||||
assert!(serde_json::from_value::<RenderSetup>(fixed).unwrap().validate().is_err());
|
||||
let mut hostile = value;
|
||||
hostile["job"]["options"]["adaptive"]["certified_convergence"] = json!(true);
|
||||
assert!(serde_json::from_value::<RenderSetup>(hostile).is_err());
|
||||
let old = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(old.get("adaptive").is_none(), "default-absent saved jobs keep their prior schema bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_coat_tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
Document,
|
||||
render_coat::{CoatMaterial, SavedCoat},
|
||||
render_surface::RenderBodyTarget,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn target(feature: u64) -> RenderBodyTarget {
|
||||
RenderBodyTarget { definition: format!("Coated{feature}"), feature, component: 0, occurrence_path: vec![] }
|
||||
}
|
||||
fn document(gpu: bool) -> Document {
|
||||
let mut doc = Document::default();
|
||||
let job = if gpu {
|
||||
RenderJob::GpuPath { transparent_background: false, options: Default::default() }
|
||||
} else {
|
||||
RenderJob::CpuRaytrace { transparent_background: false, options: Default::default() }
|
||||
};
|
||||
Arc::make_mut(&mut doc.render).setups.insert("Coated".into(), RenderSetup { width: 8, height: 8, job: Some(job), ..Default::default() });
|
||||
doc
|
||||
}
|
||||
#[test]
|
||||
fn saved_coat_document_roundtrip_preserves_every_number_and_old_omission_for_both_methods() {
|
||||
for gpu in [false, true] {
|
||||
let mut doc = document(gpu);
|
||||
let old = doc.to_json();
|
||||
let old_value: Value = serde_json::from_str(&old).unwrap();
|
||||
assert!(old_value["render"]["setups"]["Coated"].get("coats").is_none());
|
||||
assert_eq!(Document::from_json(&old).unwrap().to_json(), old);
|
||||
let coat = CoatMaterial { weight: -0.0, roughness: 0.22, ior: 1.74 };
|
||||
Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap().coats.set(SavedCoat { target: target(1), coat }, false).unwrap();
|
||||
let encoded = doc.to_json();
|
||||
let loaded = Document::from_json(&encoded).unwrap();
|
||||
assert_eq!(loaded.render, doc.render);
|
||||
let saved = loaded.render.setups["Coated"].coats.iter().next().unwrap();
|
||||
assert_eq!(saved.coat, coat);
|
||||
assert_eq!(saved.coat.weight.to_bits(), (-0.0_f64).to_bits());
|
||||
let wire = serde_json::to_value(saved).unwrap();
|
||||
assert!(wire.get("body_index").is_none() && wire.get("body_name").is_none());
|
||||
assert!(wire["target"].get("feature").is_some());
|
||||
let setup = Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap();
|
||||
let changed = SavedCoat { target: target(1), coat: CoatMaterial { weight: 0.0, ..coat } };
|
||||
assert!(setup.coats.set(changed.clone(), false).is_err(), "authored signed zero is not silently replaced");
|
||||
setup.coats.set(changed, true).unwrap();
|
||||
assert_eq!(setup.coats.iter().next().unwrap().coat.weight.to_bits(), 0.0_f64.to_bits());
|
||||
assert!(setup.coats.remove(&target(1)));
|
||||
assert_eq!(doc.to_json(), old, "removing the only coat restores exact legacy document bytes");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn malformed_saved_coats_refuse_through_the_full_document_loader() {
|
||||
let mut doc = document(false);
|
||||
Arc::make_mut(&mut doc.render)
|
||||
.setups
|
||||
.get_mut("Coated")
|
||||
.unwrap()
|
||||
.coats
|
||||
.set(SavedCoat { target: target(1), coat: Default::default() }, false)
|
||||
.unwrap();
|
||||
let base: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
for (key, value) in
|
||||
[("weight", json!(-0.01)), ("weight", json!(1.01)), ("roughness", json!(0.02)), ("ior", json!(3.01)), ("unknown", json!(true))]
|
||||
{
|
||||
let mut bad = base.clone();
|
||||
bad["render"]["setups"]["Coated"]["coats"][0]["coat"][key] = value;
|
||||
assert!(Document::from_json(&bad.to_string()).is_err(), "invalid coat {key}");
|
||||
}
|
||||
let mut duplicate = base.clone();
|
||||
let row = duplicate["render"]["setups"]["Coated"]["coats"][0].clone();
|
||||
duplicate["render"]["setups"]["Coated"]["coats"] = json!([row, row]);
|
||||
assert!(Document::from_json(&duplicate.to_string()).is_err());
|
||||
for key in ["body_index", "runtime_path"] {
|
||||
let mut bad = base.clone();
|
||||
bad["render"]["setups"]["Coated"]["coats"][0][key] = json!(0);
|
||||
assert!(Document::from_json(&bad.to_string()).is_err());
|
||||
}
|
||||
let mut too_many = base;
|
||||
too_many["render"]["setups"]["Coated"]["coats"] =
|
||||
json!((1..=65).map(|feature| SavedCoat { target: target(feature), coat: Default::default() }).collect::<Vec<_>>());
|
||||
assert!(Document::from_json(&too_many.to_string()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn saved_coat_uses_existing_sixty_four_target_bound_and_rejects_glass_even_at_zero_weight() {
|
||||
use crate::render_optics::{OpticalMaterial, SavedOptics};
|
||||
for gpu in [false, true] {
|
||||
let mut doc = document(gpu);
|
||||
let setup = Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap();
|
||||
for feature in 1..=64 {
|
||||
setup.coats.set(SavedCoat { target: target(feature), coat: CoatMaterial { weight: 0.0, ..Default::default() } }, false).unwrap();
|
||||
}
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup.coats.len(), 64);
|
||||
let before = setup.coats.clone();
|
||||
assert!(setup.coats.set(SavedCoat { target: target(65), coat: Default::default() }, false).is_err());
|
||||
assert_eq!(setup.coats, before);
|
||||
setup.optics.set(SavedOptics { target: target(1), optics: OpticalMaterial::default() }, false).unwrap();
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("cannot share"));
|
||||
setup.optics.remove(&target(1));
|
||||
setup.job = Some(RenderJob::Raster { transparent_background: false });
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("raytrace"));
|
||||
assert_eq!(setup.coats, before, "unsupported method retains all authored rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_celestial_saved_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn saved_cpu_finite_sun_and_sky_roundtrip_exactly_and_require_explicit_path() {
|
||||
let value = json!({"width":16,"height":12,"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":32,"seed":81},"adaptive":{"pilot_samples":16,"min_samples":16},
|
||||
"sun":{"direction":[0.2,-0.3,0.9],"angular_radius_rad":0.02,"radiance":[301.25,202.5,103.75]},
|
||||
"sky":{"kind":"sky","gradient":{"zenith":[0.25,0.5,1.0],"horizon":[0.75,0.5,0.25],"ground":[0.125,0.25,0.5]},"rotation_rad":0.731,"intensity":75},
|
||||
"denoise":true,"output":{"linear_exr":true},"lights":[]}}});
|
||||
let setup: RenderSetup = serde_json::from_value(value.clone()).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire.clone()).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &setup.job else { panic!("CPU expected") };
|
||||
let runtime = options.to_runtime(0.0).unwrap();
|
||||
assert_eq!(runtime.sun.as_deref().copied(), options.sun);
|
||||
assert_eq!(runtime.sky.as_deref().unwrap().intensity, 75.0);
|
||||
assert_eq!(runtime.sky.as_deref().unwrap().rotation_rad, 0.731);
|
||||
for (field, replacement) in [
|
||||
("path", serde_json::Value::Null),
|
||||
("sun", json!({"direction":[0,0,1],"angular_radius_rad":0,"radiance":[1,1,1]})),
|
||||
("environment", json!({"color":[1,1,1],"intensity":1})),
|
||||
] {
|
||||
let mut invalid = wire.clone();
|
||||
invalid["job"]["options"][field] = replacement;
|
||||
assert!(serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err(), "{field} must not be silently replaced");
|
||||
}
|
||||
for (field, value) in [("intensity", json!(100.001)), ("rotation_rad", json!(1e6 + 1.0))] {
|
||||
let mut invalid = wire.clone();
|
||||
invalid["job"]["options"]["sky"][field] = value;
|
||||
assert!(serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err());
|
||||
}
|
||||
let defaults = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,66 +315,4 @@ mod tests {
|
||||
target.occurrence_path = vec![2, 2];
|
||||
assert!(target.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn saved_cpu_surface_maps_roundtrip_for_opaque_direct_and_path_and_preserve_unsupported_intent() {
|
||||
use crate::render_settings::{CpuJob, RenderJob, RenderSetup};
|
||||
let mut options = CpuJob::default();
|
||||
let mut setup = RenderSetup {
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.surfaces.set(fixture(), false).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut direct_document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut direct_document.render).setups.insert("Maps".into(), setup.clone());
|
||||
assert_eq!(crate::Document::from_json(&direct_document.to_json()).unwrap(), direct_document);
|
||||
options.progressive = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 9 });
|
||||
options.shadows = true;
|
||||
options.path = Some(solidcraft_render::job::CpuPathOptions { max_depth: 3 });
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) });
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut document.render).setups.insert("Maps".into(), setup.clone());
|
||||
assert_eq!(crate::Document::from_json(&document.to_json()).unwrap(), document);
|
||||
options.path = None;
|
||||
let saved_options_wire = serde_json::to_value(&options).unwrap();
|
||||
let mut runtime = options.to_runtime(0.0).unwrap();
|
||||
runtime.reflections = Some(solidcraft_render::job::ReflectionOptions {
|
||||
bodies: vec![solidcraft_render::job::ReflectionBody { body: "Panel".into(), mode: solidcraft_render::job::ReflectionMode::Mirror }],
|
||||
samples: 1,
|
||||
max_bounces: 2,
|
||||
});
|
||||
runtime.validate().unwrap();
|
||||
let runtime_wire = serde_json::to_value(&runtime).unwrap();
|
||||
let error = solidcraft_render::job::textures::validate_cpu_options(&runtime).unwrap_err();
|
||||
assert!(error.message.contains("selected reflections"));
|
||||
assert_eq!(serde_json::to_value(&runtime).unwrap(), runtime_wire, "map refusal preserves the exact runtime selected override");
|
||||
assert_eq!(serde_json::to_value(&options).unwrap(), saved_options_wire, "runtime overrides do not rewrite saved CPU fields");
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) });
|
||||
setup.validate().unwrap();
|
||||
let mut unknown = serde_json::to_value(&setup).unwrap();
|
||||
unknown["job"]["options"]["reflections"] = serde_json::to_value(&runtime.reflections).unwrap();
|
||||
let error = serde_json::from_value::<RenderSetup>(unknown).unwrap_err();
|
||||
assert!(error.to_string().contains("reflections"), "saved CPU intent must not silently discard runtime-only selected overrides");
|
||||
// Prove the saved field is supported in fixed mode before checking
|
||||
// its incompatibility with still-authored progressive sampling.
|
||||
let mut fixed = options.clone();
|
||||
fixed.progressive = None;
|
||||
fixed.primary_samples = 4;
|
||||
fixed.validate_surface_maps(0.0).unwrap();
|
||||
let surfaces = setup.surfaces.clone();
|
||||
options.primary_samples = 4;
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options) });
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU progressive requires"));
|
||||
assert_eq!(serde_json::to_value(&setup).unwrap(), wire);
|
||||
assert_eq!(setup.surfaces, surfaces);
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
for job in [None, Some(RenderJob::Raster { transparent_background: false })] {
|
||||
setup.job = job;
|
||||
assert!(setup.validate().is_err());
|
||||
assert_eq!(setup.surfaces, surfaces);
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-374
@@ -123,212 +123,7 @@ fn planar_region(region: &Region2) -> Result<Region2> {
|
||||
region_complexity(std::slice::from_ref(region))?;
|
||||
// Free-form loops stay exact: the planar booleans follow them with arcs and hand a loop
|
||||
// they leave whole its exact segments back.
|
||||
Ok(Region2 { outer: without_hairs(region.outer.ccw()), holes: region.holes.iter().map(|h| without_hairs(h.ccw().reversed())).collect() })
|
||||
}
|
||||
|
||||
/// Edges shorter than this (mm) are no edges to the planar booleans: SMLib refuses a face whose
|
||||
/// loop has one ("status 1001", measured up to 1e-5 mm; 4e-5 mm passes). They come from an
|
||||
/// outline with a corner a hair beside a bend zone's edge, split there. Below 0.1 µm: a ledge
|
||||
/// 0.1 µm tall beside a flange's setback stays a sliver of the base.
|
||||
const HAIR: f64 = 5e-5;
|
||||
|
||||
/// `region` with each corner that lies within [`HAIR`] of a bend zone's straight edge
|
||||
/// (`seams`: a point and a unit direction each), but not on it, moved onto it: the straight edges
|
||||
/// meeting there move that end, an arc meeting there moves whole with its centre (and the edge
|
||||
/// beyond it then moves that end too). A cut-out whose edge stops a hair short of a bend line, or
|
||||
/// a hair past it, or whose rounded corner ends there, otherwise leaves a sliver of a face that
|
||||
/// thin along the line, whose edges the sew cannot tell apart ("ambiguous edge contact"); the
|
||||
/// cut moves by under 0.05 µm.
|
||||
fn onto_seams(region: Region2, seams: &[(Vec2, Vec2)]) -> Region2 {
|
||||
if seams.is_empty() {
|
||||
return region;
|
||||
}
|
||||
let off = |p: Vec2, (o, d): (Vec2, Vec2)| (p - o).cross(d);
|
||||
let snap_loop = |mut lp: Loop2| -> Loop2 {
|
||||
let n = lp.segs.len();
|
||||
if n < 2 {
|
||||
return lp;
|
||||
}
|
||||
for j in 0..n {
|
||||
let prev = (j + n - 1) % n;
|
||||
let (Some(p), Some(q)) = (lp.segs.get(j).and_then(seg_start), lp.segs.get(prev).and_then(seg_end)) else { continue };
|
||||
if p.dist(q) > LINEAR_TOL {
|
||||
continue;
|
||||
}
|
||||
let Some(&(o, d)) = seams.iter().find(|&&s| off(p, s) != 0.0 && off(p, s).abs() < HAIR) else { continue };
|
||||
let delta = o + d * (p - o).dot(d) - p;
|
||||
if let Some(segs) = shifted(&lp.segs, j, delta) {
|
||||
lp.segs = segs;
|
||||
}
|
||||
}
|
||||
lp
|
||||
};
|
||||
Region2 { outer: snap_loop(region.outer), holes: region.holes.into_iter().map(snap_loop).collect() }
|
||||
}
|
||||
|
||||
/// `segs` (a closed loop) with the corner where segment `j` starts moved by `delta`: a line
|
||||
/// meeting there moves that end, an arc moves whole and passes the move on to the corner at its
|
||||
/// other end. None when a curve that cannot move so meets the move, or the move goes round the
|
||||
/// whole loop.
|
||||
fn shifted(segs: &[Seg2], j: usize, delta: Vec2) -> Option<Vec<Seg2>> {
|
||||
let n = segs.len();
|
||||
let mut out = segs.to_vec();
|
||||
let mut moved = 0;
|
||||
// forward from j (moving starts), then backward from the segment before j (moving ends)
|
||||
for forward in [true, false] {
|
||||
let mut k = if forward { j } else { (j + n - 1) % n };
|
||||
loop {
|
||||
moved += 1;
|
||||
if moved > n {
|
||||
return None;
|
||||
}
|
||||
match out.get(k).copied()? {
|
||||
Seg2::Line { a, b } => {
|
||||
out[k] = if forward { Seg2::Line { a: a + delta, b } } else { Seg2::Line { a, b: b + delta } };
|
||||
break;
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
out[k] = Seg2::Arc { center: center + delta, radius, start, sweep };
|
||||
k = if forward { (k + 1) % n } else { (k + n - 1) % n };
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn seg_start(s: &Seg2) -> Option<Vec2> {
|
||||
match *s {
|
||||
Seg2::Line { a, .. } => Some(a),
|
||||
Seg2::Arc { center, radius, start, .. } => Some(center + Vec2::new(start.cos(), start.sin()) * radius),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seg_end(s: &Seg2) -> Option<Vec2> {
|
||||
match *s {
|
||||
Seg2::Line { b, .. } => Some(b),
|
||||
Seg2::Arc { center, radius, start, sweep } => Some(center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cut-out's outline encloses some area: a sliver there and back, or points on a
|
||||
/// line, cuts nothing, and SMLib's face builder refuses it (status 1001).
|
||||
fn encloses_area(r: &Region2) -> bool {
|
||||
r.outer.signed_area().abs() > 1e-9
|
||||
}
|
||||
|
||||
/// [`kernel::extrude_planar_regions`] of `regions` with their loops [`welded`] first.
|
||||
fn extrude_flat(plane: &Plane, regions: &[Region2], z0: f64, z1: f64) -> kernel::Result<Body> {
|
||||
kernel::extrude_planar_regions(plane, welded_regions(regions).as_ref(), z0, z1)
|
||||
}
|
||||
|
||||
/// `regions` with their loops [`welded`], borrowed when every loop is closed already.
|
||||
fn welded_regions(regions: &[Region2]) -> std::borrow::Cow<'_, [Region2]> {
|
||||
let open = |lp: &Loop2| lp.segs.iter().zip(lp.segs.iter().cycle().skip(1)).any(|(p, q)| p.end().dist(q.start()) > NOISE);
|
||||
if !regions.iter().any(|r| open(&r.outer) || r.holes.iter().any(open)) {
|
||||
return std::borrow::Cow::Borrowed(regions);
|
||||
}
|
||||
std::borrow::Cow::Owned(
|
||||
regions.iter().map(|r| Region2 { outer: welded(r.outer.clone()), holes: r.holes.iter().cloned().map(welded).collect() }).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Gaps up to this (mm) are rounding, not gaps: left as they are, so a loop and its mirror image
|
||||
/// keep their exact mirror coordinates.
|
||||
const NOISE: f64 = 1e-9;
|
||||
|
||||
/// `lp` with each gap between [`NOISE`] and [`HAIR`] between a segment's end and the next one's start closed:
|
||||
/// the next segment starts where the last ends, or the last ends where the next starts when the
|
||||
/// next is an arc (whose ends do not move without its centre). The planar booleans close a loop
|
||||
/// only within 1 nm; a free-form outline the flat pattern was made of (a torn fold's) came out
|
||||
/// of them with its pieces 14 nm apart, which a following boolean refused.
|
||||
fn welded(mut lp: Loop2) -> Loop2 {
|
||||
let n = lp.segs.len();
|
||||
let moved = |s: Seg2, start: bool, to: Vec2| -> Option<Seg2> {
|
||||
Some(match s {
|
||||
Seg2::Line { a, b } => {
|
||||
if start {
|
||||
Seg2::Line { a: to, b }
|
||||
} else {
|
||||
Seg2::Line { a, b: to }
|
||||
}
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||||
if start {
|
||||
Seg2::Cubic { p0: to, p1, p2, p3 }
|
||||
} else {
|
||||
Seg2::Cubic { p0, p1, p2, p3: to }
|
||||
}
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
if start {
|
||||
Seg2::Conic { a: to, apex, b, w }
|
||||
} else {
|
||||
Seg2::Conic { a, apex, b: to, w }
|
||||
}
|
||||
}
|
||||
Seg2::Bezier { n: deg, mut p } => {
|
||||
let k = if start { 0 } else { usize::from(deg) };
|
||||
*p.get_mut(k)? = to;
|
||||
Seg2::Bezier { n: deg, p }
|
||||
}
|
||||
Seg2::Arc { .. } => return None,
|
||||
})
|
||||
};
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let (Some(&p), Some(&q)) = (lp.segs.get(i), lp.segs.get(j)) else { continue };
|
||||
let gap = p.end().dist(q.start());
|
||||
if gap <= NOISE || gap >= HAIR {
|
||||
continue;
|
||||
}
|
||||
if let Some(q2) = moved(q, true, p.end()).filter(|s| s.length() >= HAIR) {
|
||||
lp.segs[j] = q2;
|
||||
} else if let Some(p2) = moved(p, false, q.start()).filter(|s| s.length() >= HAIR) {
|
||||
lp.segs[i] = p2;
|
||||
}
|
||||
}
|
||||
lp
|
||||
}
|
||||
|
||||
/// `lp` without its hair edges (shorter than [`HAIR`]): the line the hair runs on from takes it,
|
||||
/// so the corners at the hair's other end stay where they are (an outline split at a bend zone's
|
||||
/// edge keeps its corner on that edge, which the zone shares). A hair at a corner goes to the
|
||||
/// line after it. A hair between two curves stays.
|
||||
fn without_hairs(lp: Loop2) -> Loop2 {
|
||||
let n = lp.segs.len();
|
||||
if n <= 3 || !lp.segs.iter().any(|s| s.length() < HAIR) {
|
||||
return lp;
|
||||
}
|
||||
let along = |line: &Seg2, hair: &Seg2| match (line, (hair.end() - hair.start()).normalized()) {
|
||||
(Seg2::Line { a, b }, Some(d)) => (*b - *a).normalized().is_some_and(|u| u.cross(d).abs() < 1e-3 && u.dot(d) > 0.0),
|
||||
_ => false,
|
||||
};
|
||||
let mut segs = lp.segs;
|
||||
let mut i = 0;
|
||||
while i < segs.len() && segs.len() > 3 {
|
||||
let Some(hair) = segs.get(i).copied().filter(|s| s.length() < HAIR) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let m = segs.len();
|
||||
let (prev, next) = ((i + m - 1) % m, (i + 1) % m);
|
||||
let prev_takes = segs.get(prev).is_some_and(|p| along(p, &hair)) && !segs.get(next).is_some_and(|q| along(q, &hair));
|
||||
match (prev_takes, segs.get(prev).copied(), segs.get(next).copied()) {
|
||||
(true, Some(Seg2::Line { a, .. }), _) => segs[prev] = Seg2::Line { a, b: hair.end() },
|
||||
(_, _, Some(Seg2::Line { b, .. })) => segs[next] = Seg2::Line { a: hair.start(), b },
|
||||
(_, Some(Seg2::Line { a, .. }), _) => segs[prev] = Seg2::Line { a, b: hair.end() },
|
||||
_ => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
segs.remove(i);
|
||||
}
|
||||
Loop2 { segs }
|
||||
Ok(Region2 { outer: region.outer.ccw(), holes: region.holes.iter().map(|h| h.ccw().reversed()).collect() })
|
||||
}
|
||||
|
||||
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
|
||||
@@ -403,8 +198,7 @@ pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp,
|
||||
let (be, bp) = region_complexity(b)?;
|
||||
let edges = ae.saturating_add(be);
|
||||
work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?;
|
||||
let (a, b) = (welded_regions(a), welded_regions(b));
|
||||
let regions = kernel::planar_regions_boolean(a.as_ref(), b.as_ref(), op, 1e-7)?;
|
||||
let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?;
|
||||
region_complexity(®ions)?;
|
||||
Ok(regions)
|
||||
}
|
||||
@@ -1617,23 +1411,6 @@ impl SheetBody {
|
||||
(-f.ext.0 - f.slope.0 * f.leg, f.len + f.ext.1 + f.slope.1 * f.leg)
|
||||
}
|
||||
|
||||
/// The straight edges of the bend zones that run along their bends, as a point and a unit
|
||||
/// direction each: the lines where the folded solid's faces change from a panel to a bend.
|
||||
fn seams(&self) -> Vec<(Vec2, Vec2)> {
|
||||
let mut out = Vec::new();
|
||||
for f in &self.flanges {
|
||||
let Some(d) = f.d.normalized() else { continue };
|
||||
for e in &self.zone(f).outer.segs {
|
||||
if let Seg2::Line { a, b } = *e
|
||||
&& (b - a).normalized().is_some_and(|u| u.cross(d).abs() < 1e-9)
|
||||
{
|
||||
out.push((a, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Flat region of a flange's bend zone: square across the bend at both ends, but at a torn
|
||||
/// end its far edge ends where the panel's does.
|
||||
pub fn zone(&self, f: &SheetFlange) -> Region2 {
|
||||
@@ -1943,9 +1720,8 @@ impl SheetBody {
|
||||
// their fitted arcs there (the flat pattern keeps them exact).
|
||||
let mut cuts = Vec::new();
|
||||
let mut cut_points = 0usize;
|
||||
let seams = self.seams();
|
||||
for hole in self.holes.iter().filter(|h| encloses_area(h)) {
|
||||
let region = fitted_region(&onto_seams(planar_region(hole)?, &seams))?;
|
||||
for hole in &self.holes {
|
||||
let region = fitted_region(&planar_region(hole)?)?;
|
||||
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
|
||||
if cut_points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
@@ -2193,7 +1969,7 @@ impl SheetBody {
|
||||
let mut local = self.clone();
|
||||
local.frame = Plane::XY;
|
||||
local.flat = true;
|
||||
let body = extrude_flat(&Plane::XY, ®ions, 0.0, self.t)?;
|
||||
let body = kernel::extrude_planar_regions(&Plane::XY, ®ions, 0.0, self.t)?;
|
||||
let mut work = FlatWork::new();
|
||||
let body = local.machine_pockets(body, &mut work)?;
|
||||
let body = local.machine_bores(body, &mut work)?;
|
||||
@@ -2283,7 +2059,7 @@ impl SheetBody {
|
||||
work.charge(100_000)?;
|
||||
// The bore's own panel, flat: the bore never reaches a bend or another panel, in
|
||||
// the folded part or in its flat pattern.
|
||||
let slab = extrude_flat(&Plane::XY, &cells, 0.0, self.t)?;
|
||||
let slab = kernel::extrude_planar_regions(&Plane::XY, &cells, 0.0, self.t)?;
|
||||
let drill = kernel::cylinder(bore.base, bore.axis, bore.radius, bore.height)?;
|
||||
let Some(inside) = kernel::boolean(&drill, &slab, kernel::BoolOp::Intersect)? else { continue };
|
||||
// Cut with the drill clipped to the panel, its ends flush with the panel's rims.
|
||||
@@ -2317,7 +2093,7 @@ impl SheetBody {
|
||||
outer: Loop2 { segs: cell.outer.clone() },
|
||||
holes: cell.holes.iter().map(|h| Loop2 { segs: h.clone() }).collect(),
|
||||
})?;
|
||||
extrude_flat(&self.frame, &[region], 0.0, self.t)?
|
||||
kernel::extrude_planar_regions(&self.frame, &[region], 0.0, self.t)?
|
||||
} else {
|
||||
kernel::sew_touching(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)?
|
||||
};
|
||||
@@ -2419,7 +2195,7 @@ impl SheetBody {
|
||||
let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?;
|
||||
// BRep replay is costlier than planar clipping; share the command budget.
|
||||
work.charge(100_000)?;
|
||||
let tool = extrude_flat(&plane, &clipped, pocket.z0, pocket.z1)?;
|
||||
let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?;
|
||||
body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)?
|
||||
.ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?;
|
||||
}
|
||||
@@ -2612,7 +2388,7 @@ impl SheetBody {
|
||||
let regions = material_union(regions, &mut work)?;
|
||||
let mut cuts = Vec::new();
|
||||
let mut cut_points = 0usize;
|
||||
for hole in self.holes.iter().filter(|h| encloses_area(h)) {
|
||||
for hole in &self.holes {
|
||||
let region = planar_region(hole)?;
|
||||
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
|
||||
if cut_points > MAX_FLAT_POINTS {
|
||||
@@ -6994,147 +6770,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A notch cut from the flat whose edge along the bend lies a hair (0.01 µm to 10 µm) before
|
||||
/// or past a bend zone's edge, on either side of the zone: it refolds into one valid solid,
|
||||
/// as on the line and clearly away from it. The sliver it left between the cut and the
|
||||
/// zone failed to sew ("ambiguous edge contact").
|
||||
#[test]
|
||||
fn a_notch_edge_a_hair_beside_a_bend_zone_edge_refolds() {
|
||||
let rule = RuleValues { bend_radius: 2.0, ..alu() };
|
||||
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 40.0), Vec2::new(0.0, 40.0)];
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
|
||||
s.add_flange(Vec3::new(50.0, 40.0, 2.0), 30.0, std::f64::consts::FRAC_PI_2, 2.0, "inside", false).unwrap();
|
||||
let f = s.flanges[0].clone();
|
||||
let ba = s.allowance(&f);
|
||||
let v0 = vol(&s.solid().unwrap());
|
||||
for (side, line) in [("panel", 0.0), ("flange", ba)] {
|
||||
for off in [0.0, 1e-8, -1e-8, 1.6e-7, -1.6e-7, 1e-5, -1e-5, 0.01, -0.01] {
|
||||
let what = format!("notch on the {side} side, {off} mm beside the zone edge");
|
||||
// The notch runs 20 along the bend from its start, from the zone edge outwards.
|
||||
let edge = line + off;
|
||||
let out = if side == "panel" { -10.0 } else { ba + 40.0 };
|
||||
let (a, b) = (f.p - f.d * 5.0, f.p + f.d * 20.0);
|
||||
let notch = Loop2::polygon(&[a + f.n * edge, b + f.n * edge, b + f.n * out, a + f.n * out]).ccw();
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(Region2 { outer: notch, holes: vec![] });
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty(), "{what}: {:?}", body.validity());
|
||||
assert!(vol(&body) < v0 - 1.0, "{what}: {} vs {v0}", vol(&body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A loop whose pieces are 14 nm apart (as the planar booleans hand back a torn fold's
|
||||
/// free-form outline) is closed before it is extruded: each segment starts where the one
|
||||
/// before ends, and the plate comes out with the loop's area.
|
||||
#[test]
|
||||
fn a_loop_with_gaps_under_a_hair_is_welded_before_it_is_extruded() {
|
||||
let g = 1.4e-5;
|
||||
let lp = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) },
|
||||
Seg2::Line { a: Vec2::new(10.0 + g, g), b: Vec2::new(10.0, 10.0) },
|
||||
Seg2::Arc { center: Vec2::new(5.0, 10.0), radius: 5.0, start: 0.0, sweep: std::f64::consts::PI },
|
||||
Seg2::Line { a: Vec2::new(-g, 10.0), b: Vec2::new(0.0, g) },
|
||||
],
|
||||
};
|
||||
let closed = welded(lp.clone());
|
||||
for (p, q) in closed.segs.iter().zip(closed.segs.iter().cycle().skip(1)) {
|
||||
assert_eq!(p.end().dist(q.start()), 0.0, "{p:?} then {q:?}");
|
||||
}
|
||||
let body = extrude_flat(&Plane::XY, &[Region2 { outer: lp, holes: Vec::new() }], 0.0, 2.0).unwrap_or_else(|e| panic!("{e}"));
|
||||
let area = 100.0 + std::f64::consts::PI * 12.5;
|
||||
let volume = body.volume_at(1e-6).unwrap_or(f64::NAN);
|
||||
assert!((volume - 2.0 * area).abs() < 1e-3, "{volume} vs {}", 2.0 * area);
|
||||
}
|
||||
|
||||
/// A rectangle whose outline has an extra vertex on its edge a hair (to 0.01 µm) from where a
|
||||
/// fold's bend zone ends, folded 90° along a line: the flat pattern and the folded solid build,
|
||||
/// as without the vertex. Split there, the panel's loop kept an edge of the hair's length,
|
||||
/// which SMLib's face builder refuses ("SMLib failed (status 1001)").
|
||||
#[test]
|
||||
fn an_outline_vertex_a_hair_from_a_bend_zone_edge_unfolds() {
|
||||
let rule = RuleValues { thickness: 1.5, k_factor: 0.5, bend_radius: 2.0, ..alu() };
|
||||
let line = 30.0;
|
||||
let ba = std::f64::consts::FRAC_PI_2 * (rule.bend_radius + rule.k_factor * rule.thickness);
|
||||
for off in [0.0, 1e-8, 1e-6, -1e-6, 1e-5, -1e-5] {
|
||||
let what = format!("vertex {off} mm from the zone edge");
|
||||
let x = line + ba / 2.0 + off;
|
||||
let outline = [Vec2::new(x, 0.0), Vec2::new(80.0, 0.0), Vec2::new(80.0, 120.0), Vec2::new(0.0, 120.0), Vec2::new(0.0, 0.0)];
|
||||
let mut s = base_flange("Body1", "R", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: Vec::new() });
|
||||
let (a, b) = (Vec3::new(line, -200.0, s.t), Vec3::new(line, 200.0, s.t));
|
||||
let mut warning = None;
|
||||
s.add_fold(a, b, std::f64::consts::FRAC_PI_2, rule.bend_radius, "centerline", false, None, &mut warning)
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let flat = s.flat_regions().unwrap_or_else(|e| panic!("{what}: flat: {e}"));
|
||||
assert_eq!(flat.len(), 1, "{what}");
|
||||
let body = s.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty(), "{what}: {:?}", body.validity());
|
||||
}
|
||||
}
|
||||
|
||||
/// As above, with arcs at the zone edge: a notch whose far corner along the edge is rounded,
|
||||
/// and a straight notch with a rounded relief over its edge whose arc ends on the zone edge,
|
||||
/// tangent to it (a corner relief drawn to end on the bend line, which ends 0.03 µm beside it).
|
||||
#[test]
|
||||
fn rounded_cut_corners_a_hair_beside_a_bend_zone_edge_refold() {
|
||||
let rule = RuleValues { bend_radius: 2.0, ..alu() };
|
||||
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 40.0), Vec2::new(0.0, 40.0)];
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
|
||||
s.add_flange(Vec3::new(50.0, 40.0, 2.0), 30.0, std::f64::consts::FRAC_PI_2, 2.0, "inside", false).unwrap();
|
||||
let f = s.flanges[0].clone();
|
||||
let ba = s.allowance(&f);
|
||||
let v0 = vol(&s.solid().unwrap());
|
||||
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
|
||||
let arc = |c: Vec2, a: Vec2, b: Vec2| {
|
||||
let (sa, sb) = ((a - c).y.atan2((a - c).x), (b - c).y.atan2((b - c).x));
|
||||
let mut sweep = sb - sa;
|
||||
while sweep > std::f64::consts::PI {
|
||||
sweep -= std::f64::consts::TAU;
|
||||
}
|
||||
while sweep < -std::f64::consts::PI {
|
||||
sweep += std::f64::consts::TAU;
|
||||
}
|
||||
Seg2::Arc { center: c, radius: a.dist(c), start: sa, sweep }
|
||||
};
|
||||
for (side, zone) in [("panel", 0.0), ("flange", ba)] {
|
||||
for off in [3e-5, -3e-5, 1e-6, -1e-6, 0.0] {
|
||||
let edge = zone + off;
|
||||
let out = if side == "panel" { -10.0 } else { ba + 40.0 };
|
||||
let sg = (out - edge).signum();
|
||||
let p = |t: f64, n: f64| f.p + f.d * t + f.n * n;
|
||||
let r = 3.0;
|
||||
let rounded = Loop2 {
|
||||
segs: vec![
|
||||
line(p(-5.0, out), p(-5.0, edge)),
|
||||
line(p(-5.0, edge), p(20.0 - r, edge)),
|
||||
arc(p(20.0 - r, edge + sg * r), p(20.0 - r, edge), p(20.0, edge + sg * r)),
|
||||
line(p(20.0, edge + sg * r), p(20.0, out)),
|
||||
line(p(20.0, out), p(-5.0, out)),
|
||||
],
|
||||
};
|
||||
let notch = Loop2::polygon(&[p(-5.0, edge), p(20.0, edge), p(20.0, out), p(-5.0, out)]);
|
||||
let relief = Loop2 {
|
||||
segs: vec![
|
||||
line(p(0.0, edge), p(0.0, edge + sg * 3.0)),
|
||||
line(p(0.0, edge + sg * 3.0), p(-5.0, edge + sg * 3.0)),
|
||||
line(p(-5.0, edge + sg * 3.0), p(-5.0, edge - sg * 2.0)),
|
||||
line(p(-5.0, edge - sg * 2.0), p(-2.0, edge - sg * 2.0)),
|
||||
arc(p(0.0, edge - sg * 2.0), p(-2.0, edge - sg * 2.0), p(0.0, edge)),
|
||||
],
|
||||
};
|
||||
for (shape, holes) in [("rounded notch", vec![rounded]), ("notch and relief", vec![notch, relief])] {
|
||||
let what = format!("{shape} on the {side} side, {off} mm beside the zone edge");
|
||||
let mut cut = s.clone();
|
||||
cut.holes.extend(holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: vec![] }));
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty(), "{what}: {:?}", body.validity());
|
||||
assert!(vol(&body) < v0 - 1.0, "{what}: {} vs {v0}", vol(&body));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel).
|
||||
fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<f64> {
|
||||
let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
|
||||
|
||||
@@ -664,26 +664,6 @@ fn a_folder_naming_missing_or_repeated_features_still_opens() {
|
||||
assert_eq!(opened.timeline_groups.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["Repeats", "Stale"], "both folders are kept");
|
||||
}
|
||||
|
||||
/// Timeline folders nested 10 000 deep open at once: each folder's chain of parents is walked
|
||||
/// once, not again for every folder inside it (a 588 KB design took over three minutes). A
|
||||
/// cycle is still refused.
|
||||
#[test]
|
||||
fn deeply_nested_timeline_folders_open_and_a_cycle_does_not() {
|
||||
let mut doc = Document::new("nested folders");
|
||||
let n = 10_000u64;
|
||||
doc.timeline_groups = (0..n)
|
||||
.map(|i| TimelineGroup { id: 100 + i, name: "f".into(), parent: (i + 1 < n).then_some(101 + i), features: vec![], joints: vec![] })
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
let opened = Document::from_json(&doc.to_json()).expect("the design opens");
|
||||
assert_eq!(opened.timeline_groups.len(), n as usize);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
doc.timeline_groups =
|
||||
(0..3).map(|i| TimelineGroup { id: 100 + i, name: "f".into(), parent: Some(100 + (i + 1) % 3), features: vec![], joints: vec![] }).collect();
|
||||
let refused = Document::from_json(&doc.to_json()).unwrap_err().to_string();
|
||||
assert!(refused.contains("cyclic timeline folders"), "{refused}");
|
||||
}
|
||||
|
||||
/// A marker after the last feature leaves every feature active, as no marker does, so a feature
|
||||
/// added then is active too, not added rolled back (#7741: editing the last feature from the
|
||||
/// History runs its command with the marker there).
|
||||
|
||||
@@ -449,96 +449,22 @@ fn group_tree(doc: &solidcraft_doc::Document, roots: &[u64]) -> (std::collection
|
||||
}
|
||||
|
||||
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
|
||||
sort_folder_features(&doc.features, &mut doc.timeline_groups);
|
||||
}
|
||||
|
||||
fn sort_folder_features(features: &[Feature], folders: &mut [solidcraft_doc::TimelineGroup]) {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
for group in folders {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
for group in &mut doc.timeline_groups {
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
|
||||
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
|
||||
Err(not_contiguous(&doc.timeline_groups, id, cmd))
|
||||
}
|
||||
|
||||
fn not_contiguous(folders: &[solidcraft_doc::TimelineGroup], id: u64, cmd: &str) -> EngineError {
|
||||
let name = folders.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
|
||||
bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features"))
|
||||
}
|
||||
|
||||
/// The design's folders once `folder` is added, taking its features and joints out of any other
|
||||
/// folder and becoming the parent of the folders `groups` (whose features and joints it then holds
|
||||
/// through them), and the first folder that would no longer be one run of features, if any.
|
||||
fn folders_with(
|
||||
doc: &solidcraft_doc::Document,
|
||||
mut folder: solidcraft_doc::TimelineGroup,
|
||||
groups: &[u64],
|
||||
) -> (Vec<solidcraft_doc::TimelineGroup>, Option<u64>) {
|
||||
let nested = group_feature_ids(doc, groups);
|
||||
folder.features.retain(|feature| !nested.contains(feature));
|
||||
let nested_joints = group_joint_ids(doc, groups);
|
||||
folder.joints.retain(|joint| !nested_joints.contains(joint));
|
||||
let mut next = doc.timeline_groups.clone();
|
||||
for group in &mut next {
|
||||
group.joints.retain(|joint| !folder.joints.contains(joint));
|
||||
group.features.retain(|feature| !folder.features.contains(feature));
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = Some(folder.id);
|
||||
}
|
||||
}
|
||||
next.push(folder);
|
||||
sort_folder_features(&doc.features, &mut next);
|
||||
let broken = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &next);
|
||||
(next, broken)
|
||||
}
|
||||
|
||||
/// A new folder named `name` holding `features` and `joints`, inside the folder `parent` (`None`:
|
||||
/// at the history's root), as `timeline.group` makes it. Returns its id.
|
||||
pub(crate) fn new_folder(s: &mut Session, name: &str, features: Vec<u64>, joints: Vec<u64>, parent: Option<u64>, cmd: &str) -> Result<u64> {
|
||||
// Folder names are 1…128 bytes, as `timeline.rename_group` takes them.
|
||||
let mut name = name.trim().to_string();
|
||||
while name.len() > 128 {
|
||||
name.pop();
|
||||
}
|
||||
let name = if name.is_empty() { format!("Folder{}", s.doc.timeline_groups.len() + 1) } else { name };
|
||||
let id = s.doc_mut().allocate_id()?;
|
||||
let (folders, broken) = folders_with(&s.doc, solidcraft_doc::TimelineGroup { id, name, parent, features, joints }, &[]);
|
||||
if let Some(broken) = broken {
|
||||
return Err(not_contiguous(&folders, broken, cmd));
|
||||
}
|
||||
s.doc_mut().timeline_groups = folders;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The id a folder that is only being tried out goes by.
|
||||
const TRIAL_FOLDER: u64 = u64::MAX;
|
||||
|
||||
impl Session {
|
||||
/// Why `timeline.group` would refuse a new folder holding `features` inside the folder
|
||||
/// `parent` (`None` at the history's root), in words for a menu's tooltip; `None` when it
|
||||
/// would make it.
|
||||
pub fn group_refusal(&self, features: &[u64], parent: Option<u64>) -> Option<String> {
|
||||
if features.is_empty() {
|
||||
return Some("No features are selected".into());
|
||||
}
|
||||
let trial = solidcraft_doc::TimelineGroup { id: TRIAL_FOLDER, name: String::new(), parent, features: features.to_vec(), joints: Vec::new() };
|
||||
let (folders, broken) = folders_with(&self.doc, trial, &[]);
|
||||
let broken = broken?;
|
||||
if broken == TRIAL_FOLDER {
|
||||
return Some("The features aren't next to each other in the history".into());
|
||||
}
|
||||
let name = folders.iter().find(|folder| folder.id == broken).map_or("", |folder| folder.name.as_str());
|
||||
Some(format!("Folder {name} would no longer hold one run of features"))
|
||||
}
|
||||
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
|
||||
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.group";
|
||||
let features = group_features(s, p, false, cmd)?;
|
||||
let joints = group_joints(s, p, cmd)?;
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let mut joints = group_joints(s, p, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) if name.len() <= 128 => name.to_string(),
|
||||
@@ -555,12 +481,22 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
|
||||
}
|
||||
let folder = solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features, joints };
|
||||
let (folders, broken) = folders_with(&s.doc, folder, &groups);
|
||||
if let Some(broken) = broken {
|
||||
return Err(not_contiguous(&folders, broken, cmd));
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let nested_joints = group_joint_ids(&s.doc, &groups);
|
||||
joints.retain(|joint| !nested_joints.contains(joint));
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.joints.retain(|joint| !joints.contains(joint));
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = Some(id);
|
||||
}
|
||||
}
|
||||
s.doc_mut().timeline_groups = folders;
|
||||
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features, joints });
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); units?: for an STL or OBJ file (see file.insert_mesh); folder?: bool for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); units?: for an STL or OBJ file (see file.insert_mesh); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -139,9 +139,6 @@ struct StepOptions {
|
||||
/// (Y becomes Z), as the app's "Stand it up" offer does. `z` (the default) keeps the file's axes.
|
||||
y_up: bool,
|
||||
names: solidcraft_io::StepNames,
|
||||
/// `folder: true`: an import that brings more than one item gets a `Group #N` history folder
|
||||
/// (`import_folder`). `false` (the default) makes none, as before the option.
|
||||
folder: bool,
|
||||
}
|
||||
|
||||
fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
@@ -160,17 +157,7 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
.and_then(solidcraft_io::StepNames::parse)
|
||||
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
|
||||
};
|
||||
let folder = match p.get("folder") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(folder)) => *folder,
|
||||
Some(_) => {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(StepOptions { y_up, names, folder })
|
||||
Ok(StepOptions { y_up, names })
|
||||
}
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
@@ -213,7 +200,6 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptio
|
||||
// assembly's parts and instances turn together.
|
||||
let turned = if options.y_up { turn_up(s, &placed)? } else { None };
|
||||
let created = created_bodies(s, id);
|
||||
let folder = if options.folder { import_folder(s, id, &placed)? } else { None };
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings, "system": sf.import.system});
|
||||
if let Some((up, exporter)) = exporter_up(&sf.import) {
|
||||
@@ -224,9 +210,6 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptio
|
||||
if let Some(turned) = turned {
|
||||
out["stand_up"] = json!(turned);
|
||||
}
|
||||
if let Some(folder) = folder {
|
||||
out["group"] = json!(folder);
|
||||
}
|
||||
if let Some(mark) = profiled {
|
||||
let report = crate::schema::diagnostics::ProfileReport::new(&mark.report(), 20);
|
||||
out["profile"] = serde_json::to_value(report).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
@@ -234,26 +217,6 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptio
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An import that brings more than one item into the history (its Import feature and the rigid
|
||||
/// joints holding its parts together) goes into a history folder of its own, named `Group #N`,
|
||||
/// holding the feature and those joints, so it stands apart from the features before and after
|
||||
/// it and the History shows it as one chip. The folder sits in the folder the feature was put
|
||||
/// in, if any. Returns the folder.
|
||||
fn import_folder(s: &mut Session, feature: u64, placed: &import::Placed) -> Result<Option<u64>> {
|
||||
if placed.joints.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let parent = s.doc.timeline_groups.iter().find(|folder| folder.features.contains(&feature)).map(|folder| folder.id);
|
||||
let name = next_group_name(&s.doc);
|
||||
super::edit::new_folder(s, &name, vec![feature], placed.joints.clone(), parent, "file.insert_step").map(Some)
|
||||
}
|
||||
|
||||
/// The first `Group #N` no history folder is named yet.
|
||||
fn next_group_name(doc: &Document) -> String {
|
||||
let taken = |name: &str| doc.timeline_groups.iter().any(|folder| folder.name == name);
|
||||
(1..=doc.timeline_groups.len() + 1).map(|n| format!("Group #{n}")).find(|name| !taken(name)).unwrap_or_else(|| "Group".into())
|
||||
}
|
||||
|
||||
/// The bodies an import feature built, by name.
|
||||
fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
|
||||
s.model.state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.name.clone()).collect()
|
||||
|
||||
@@ -95,8 +95,6 @@ mod parameters;
|
||||
mod plastic;
|
||||
mod print_face;
|
||||
mod render;
|
||||
pub(crate) use render::{PreparedCapture, prepare_capture_with_metadata, render_cancel_params};
|
||||
pub(crate) use render_ui::batch_request;
|
||||
mod render_ui;
|
||||
#[cfg(test)]
|
||||
mod script_tests;
|
||||
@@ -118,7 +116,6 @@ pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_construction;
|
||||
mod sketch_create;
|
||||
mod sketch_divide;
|
||||
mod sketch_ends;
|
||||
mod sketch_equation;
|
||||
mod sketch_freeform;
|
||||
@@ -316,7 +313,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_create::COMMANDS.iter());
|
||||
v.extend(sketch_construction::COMMANDS.iter());
|
||||
v.extend(sketch_divide::COMMANDS.iter());
|
||||
v.extend(sketch_equation::COMMANDS.iter());
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_import::COMMANDS.iter());
|
||||
|
||||
@@ -198,31 +198,6 @@ fn deleting_a_used_parameter_lists_its_users() {
|
||||
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Base" && e[1] == dim.as_str()), "{g}");
|
||||
}
|
||||
|
||||
/// #12293: a parameter that only a sketch point's coordinate reads lists that sketch in the
|
||||
/// parameter list's `used_by`, as `parameters.graph` and `parameters.users` already show it.
|
||||
#[test]
|
||||
fn a_parameter_read_by_a_sketch_point_lists_the_sketch_as_its_user() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "a", "expression": "20 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "h", "expression": "5 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], ["a", 0], ["a", 10], [0, 10]], "closed": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "h", "name": "E"}));
|
||||
let g = run(&mut s, "parameters.graph", json!({}));
|
||||
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:S" && e[1] == "a"), "{g}");
|
||||
let list = run(&mut s, "parameters.list", json!({"filter": "user"}));
|
||||
assert_eq!(row(&list, "a")["used_by"], json!(["Feature:S"]), "{list}");
|
||||
// `h` is read through the extrude's named input, not by the extrude itself.
|
||||
let h = row(&list, "h")["used_by"].clone();
|
||||
assert_eq!(h.as_array().map(Vec::len), Some(1), "{list}");
|
||||
assert!(h[0].as_str().is_some_and(|n| n.starts_with('d')), "{list}");
|
||||
// The sketch still reads `a` after `a` changes.
|
||||
run(&mut s, "parameters.change", json!({"name": "a", "expression": "30 mm"}));
|
||||
let list = run(&mut s, "parameters.list", json!({"filter": "user"}));
|
||||
assert_eq!(row(&list, "a")["used_by"], json!(["Feature:S"]), "{list}");
|
||||
}
|
||||
|
||||
/// #4420: sheet metal and plastic rules and the plane of a canvas hold expressions too. Renaming a
|
||||
/// parameter they use renames it there, and they count as its users (so it can't be deleted).
|
||||
#[test]
|
||||
@@ -1096,38 +1071,6 @@ fn a_refused_definition_changes_nothing() {
|
||||
assert!(r.get("defined").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// Several holes made one command at a time, each with its depth typed as `ScrewDepth = 5`: the
|
||||
/// first defines `ScrewDepth`, the later ones name it instead of being refused because it
|
||||
/// already exists. Typed again with another value, it is still refused and changes nothing.
|
||||
#[test]
|
||||
fn the_same_definition_typed_again_uses_the_parameter() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 12}));
|
||||
let r = run(&mut s, "solid.hole", json!({"position": [15, 20, 12], "diameter": 5, "depth": "ScrewDepth = 5"}));
|
||||
assert_eq!(r["definitions"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let r = run(&mut s, "solid.hole", json!({"position": [45, 20, 12], "diameter": 5, "depth": "ScrewDepth = 5"}));
|
||||
assert!(r.get("definitions").is_none_or(|d| d.as_array().is_some_and(Vec::is_empty)), "nothing new is defined: {r}");
|
||||
assert_eq!(s.doc.params.iter().filter(|p| p.name == "ScrewDepth").count(), 1);
|
||||
assert_eq!(expr_of(&mut s, "ScrewDepth"), "5");
|
||||
let depths: Vec<&str> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Hole { depth, .. } => depth.as_ref().map(|d| d.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(depths, ["ScrewDepth", "ScrewDepth"]);
|
||||
run(&mut s, "parameters.change", json!({"changes": {"ScrewDepth": "8"}}));
|
||||
// Another value for the name is still a parameter that already exists.
|
||||
let undo = s.undo.len();
|
||||
let e = err(&mut s, "solid.hole", json!({"position": [30, 30, 12], "diameter": 5, "depth": "ScrewDepth = 6"}));
|
||||
assert!(e.contains("there is already a parameter `ScrewDepth`"), "{e}");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(expr_of(&mut s, "ScrewDepth"), "8");
|
||||
}
|
||||
|
||||
/// Changing a parameter to `name = expression` names it (#4071): the dimension's or feature
|
||||
/// input's own parameter is renamed, references follow, and it takes the expression; no other
|
||||
/// parameter is made. One undo takes both back.
|
||||
|
||||
+14
-120
@@ -752,29 +752,18 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
|
||||
// Each hole's occurrence is found before the part exists, so a pick off every body fails the
|
||||
// insert before anything is made.
|
||||
let mut holes: Vec<(Vec3, u64)> = Vec::new();
|
||||
let mut seats: Vec<(u64, HoleWall)> = Vec::new();
|
||||
let mut holes = Vec::new();
|
||||
let mut seats: Vec<(u64, Vec3)> = Vec::new();
|
||||
for a in hole_picks(p, cmd)? {
|
||||
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true });
|
||||
let host = super::joints::occurrence_at(s, wall.at).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
|
||||
// A rim is drawn as more than one edge, and one click on it picks them all; a hole can
|
||||
// also be picked at both its ends (#12191). The picks of one hole seat one part, at an
|
||||
// open end: seated at a blind hole's floor it would stand on its head in the material.
|
||||
match seats.iter().position(|(h, w)| *h == host && w.same_hole(&wall)) {
|
||||
Some(i) => {
|
||||
if !seats.get(i).is_some_and(|(_, w)| w.open)
|
||||
&& wall.open
|
||||
&& let (Some(seat), Some(hole)) = (seats.get_mut(i), holes.get_mut(i))
|
||||
{
|
||||
*hole = (wall.at, host);
|
||||
seat.1 = wall;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
holes.push((wall.at, host));
|
||||
seats.push((host, wall));
|
||||
}
|
||||
let (a, seat) = hole_wall(s, a).unwrap_or((a, a));
|
||||
let host = super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
|
||||
// A rim is drawn as more than one edge, and one click on it picks them all: the picks
|
||||
// of one rim seat one part.
|
||||
if seats.iter().any(|(h, q)| *h == host && (*q - seat).len() < 1e-3) {
|
||||
continue;
|
||||
}
|
||||
seats.push((host, seat));
|
||||
holes.push((a, host));
|
||||
}
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh));
|
||||
let active = s.active_component;
|
||||
@@ -861,43 +850,9 @@ fn hole_picks(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pick on a hole's rim seats a part.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct HoleWall {
|
||||
/// A point on the hole's cylindrical wall, a little in from the picked rim.
|
||||
at: Vec3,
|
||||
/// The point on the hole's axis level with `at` (the same for every pick on one rim).
|
||||
seat: Vec3,
|
||||
/// The hole's axis, when the pick is on a hole.
|
||||
axis: Option<Vec3>,
|
||||
/// The two ends of the picked cylindrical wall, on the axis.
|
||||
ends: Option<[Vec3; 2]>,
|
||||
/// The picked rim opens onto air, not onto the floor of a blind hole.
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl HoleWall {
|
||||
/// The two picks seat on one hole: at one point, or on one axis line with walls that meet.
|
||||
/// Two blind holes drilled towards each other from opposite faces share an axis, but solid
|
||||
/// material between their walls keeps them two holes.
|
||||
fn same_hole(&self, other: &HoleWall) -> bool {
|
||||
if (self.seat - other.seat).len() < 1e-3 {
|
||||
return true;
|
||||
}
|
||||
match (self.axis, other.axis, self.ends, other.ends) {
|
||||
(Some(a), Some(b), Some(e), Some(f)) if a.cross(b).len() < 1e-6 && (other.seat - self.seat).cross(a).len() < 1e-3 => {
|
||||
let along = |p: Vec3| (p - self.seat).dot(a);
|
||||
let ([e0, e1], [f0, f1]) = (e.map(along), f.map(along));
|
||||
e0.min(e1) <= f0.max(f1) + 1e-3 && f0.min(f1) <= e0.max(e1) + 1e-3
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall of the hole at `a`, a little in from the picked rim, and whether that rim is an open
|
||||
/// end of the hole: just past it along the axis is not the body's material.
|
||||
fn hole_wall(s: &Session, a: Vec3) -> Option<HoleWall> {
|
||||
/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim, and the
|
||||
/// point on the hole's axis level with it (the same for every pick on one rim).
|
||||
fn hole_wall(s: &Session, a: Vec3) -> Option<(Vec3, Vec3)> {
|
||||
// Picks are in world coordinates, including rims on already placed standard parts.
|
||||
let st = s.world_state().ok()?;
|
||||
let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0];
|
||||
@@ -910,13 +865,7 @@ fn hole_wall(s: &Session, a: Vec3) -> Option<HoleWall> {
|
||||
let along = v.dot(c.axis);
|
||||
let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end;
|
||||
if on_wall {
|
||||
let axis: Vec3 = c.axis * 1.0;
|
||||
// The end of the wall nearer the pick, and a point on the axis just past it.
|
||||
let (end, out) = if along - c.start < c.end - along { (c.start, -1.0) } else { (c.end, 1.0) };
|
||||
let past = c.axis_point + axis * (end + out * (c.end - c.start).min(1.0) * 0.25);
|
||||
let open = !matches!(b.body.classify_point(past), Ok(solidcraft_doc::kernel::PointClass::Inside));
|
||||
let ends = [c.axis_point + axis * c.start, c.axis_point + axis * c.end];
|
||||
return Some(HoleWall { at: q, seat: c.axis_point + axis * along, axis: Some(axis), ends: Some(ends), open });
|
||||
return Some((q, c.axis_point + c.axis * along));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1333,61 +1282,6 @@ mod tests {
|
||||
assert_eq!((s.doc.components.len(), s.doc.occurrences.len()), (comps, occs));
|
||||
}
|
||||
|
||||
/// #12191: a hole picked at both ends (a blind hole's rim and its floor edge, a through
|
||||
/// hole's top and bottom rims) gets one fastener, head at the open end, not a second one
|
||||
/// turned over.
|
||||
#[test]
|
||||
fn a_hole_picked_at_both_ends_gets_one_fastener_at_its_open_end() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
|
||||
let blind = [(8.0, 8.0), (32.0, 8.0), (32.0, 22.0)];
|
||||
for (x, y) in blind {
|
||||
run(&mut s, "solid.hole", json!({"position": [x, y, 20], "diameter": 3.4, "depth": 10}));
|
||||
}
|
||||
let through = (8.0, 22.0);
|
||||
run(&mut s, "solid.hole", json!({"position": [through.0, through.1, 20], "diameter": 3.4}));
|
||||
// The first blind hole is picked at its floor edge first, then at its rim; the second at
|
||||
// its rim, then its floor; the third at its rim only; the through hole at both rims.
|
||||
let rim = |(x, y): (f64, f64), z: f64| json!([x + 1.7, y, z]);
|
||||
let at = vec![
|
||||
rim(blind[0], 10.0),
|
||||
rim(blind[0], 20.0),
|
||||
rim(blind[1], 20.0),
|
||||
rim(blind[1], 10.0),
|
||||
rim(blind[2], 20.0),
|
||||
rim(through, 20.0),
|
||||
rim(through, 0.0),
|
||||
];
|
||||
let r = run(&mut s, "parts.fastener", json!({"family": "socket_head_cap_screw", "size": "M3", "length": 8, "at": at}));
|
||||
let comp = r["component"].as_u64().unwrap_or(0);
|
||||
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(4), "one screw per hole: {r}");
|
||||
let st = s.world_state().unwrap();
|
||||
let mut seated: Vec<(f64, f64)> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) {
|
||||
let (lo, hi) = (b.mesh().bounds().min, b.mesh().bounds().max);
|
||||
// Head on the top face (3 mm tall), shank 8 mm down the hole.
|
||||
assert!((hi.z - 23.0).abs() < 1e-6 && (lo.z - 12.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
seated.push(((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0));
|
||||
}
|
||||
assert_eq!(seated.len(), 4, "{seated:?}");
|
||||
for (x, y) in blind.into_iter().chain([through]) {
|
||||
assert!(seated.iter().any(|c| (c.0 - x).abs() < 1e-6 && (c.1 - y).abs() < 1e-6), "no screw at ({x}, {y}): {seated:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12191: two blind holes drilled towards each other from opposite faces share an axis, but
|
||||
/// the material between them keeps them two holes: each rim picked gets its own fastener.
|
||||
#[test]
|
||||
fn coaxial_blind_holes_from_opposite_faces_get_a_fastener_each() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 3.4, "depth": 6}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 0], "direction": [0, 0, 1], "diameter": 3.4, "depth": 6}));
|
||||
let at = json!([[21.7, 15, 20], [21.7, 15, 0]]);
|
||||
let r = run(&mut s, "parts.fastener", json!({"family": "socket_head_cap_screw", "size": "M3", "length": 6, "at": at}));
|
||||
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "one screw per hole: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sketch_of_a_standard_part_is_fully_defined() {
|
||||
for (family, size, resized) in [
|
||||
|
||||
@@ -4,17 +4,13 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::Mat;
|
||||
use solidcraft_doc::point_cloud::{
|
||||
ColorBy, Colors, Complex, Filter, Keep, MAX_COORD, MAX_FILTERS, MAX_NAMES, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale,
|
||||
ColorBy, Colors, Filter, Keep, MAX_COORD, MAX_FILTERS, MAX_NAMES, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale,
|
||||
};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::schema::point_cloud::{PointCloudReloadParams, PointCloudReloadResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const RELOAD_CONTRACT: TypedContract<PointCloudReloadParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("pointcloud.insert", "Insert Point Cloud", insert)
|
||||
.at("SOLID", "INSERT")
|
||||
@@ -23,17 +19,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("pointcloud.edit", "Edit Point Cloud", edit)
|
||||
.icon("edit")
|
||||
.params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height or a property's name (the file's colours, the default; one colour; a ramp by height, blue low to red high; or by any property of the file, a ramp over its values or a palette for whole numbers); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); color_range?: [low, high] the ramp runs between (design Z in mm by height), or null for the automatic range (by height all but the lowest and highest 2 % of the points; by a property the values it takes over what the filters keep); height_range?: the same as color_range; units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance?; filters?: [{property, range: [low, high]} or {property, values: [whole numbers]} for a property with named values], or null (only what every filter keeps is shown) → cloud"),
|
||||
CommandSpec::new("pointcloud.reload", "Reload Point Cloud", crate::checked!("pointcloud.reload", reload))
|
||||
.icon("import")
|
||||
.contract(&RELOAD_CONTRACT)
|
||||
.output(output_schema_for::<PointCloudReloadResult>)
|
||||
.params("cloud: id or name (its file is read again from where it was inserted; name, placement, units, colouring and the filters on properties it still has are kept; read under the limit it was inserted with, so a file that grew is thinned only past that limit) → cloud, points, segments, triangles, warnings"),
|
||||
CommandSpec::new("pointcloud.delete", "Delete Point Cloud", delete).icon("delete").params("cloud: id or name → deleted"),
|
||||
CommandSpec::new("pointcloud.list", "Point Clouds", list)
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ clouds: [{cloud, name, file, source (the file's path, read again by pointcloud.reload), points, segments, triangles, source_points, units, visible, point_size, color_by (file, single, height or a property's name), color, colored, properties: [{name, integer, span, names}], filters, color_range (the ramp's now, exact over what the filters keep), height_range (the ramp's own range or the automatic one by height), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"),
|
||||
.params("→ clouds: [{cloud, name, file, points, segments, triangles, source_points, units, visible, point_size, color_by (file, single, height or a property's name), color, colored, properties: [{name, integer, span, names}], filters, color_range (the ramp's now, exact over what the filters keep), height_range (the ramp's own range or the automatic one by height), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"),
|
||||
];
|
||||
|
||||
fn units_of(p: &Value, cmd: &str) -> Result<Option<(&'static str, f64)>> {
|
||||
@@ -132,18 +123,13 @@ fn check_properties(c: &PointCloud, cmd: &str) -> Result<()> {
|
||||
return Err(bad(cmd, format!("the point cloud has no property `{n}` (it has {})", names.join(", "))));
|
||||
}
|
||||
}
|
||||
if let Some(f) = c.filters.iter().find(|f| picks_from_no_names(&c.complex, f)) {
|
||||
// A set of values filters a label: a property with named whole numbers.
|
||||
if let Some(f) = c.filters.iter().find(|f| matches!(f.keep, Keep::Values { .. }) && c.complex.names(&f.property).is_none()) {
|
||||
return Err(bad(cmd, format!("`{}` has no named values: filter it with a range", f.property)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A set of values filters a label: a property with named whole numbers. Is this one a set of
|
||||
/// values on a property with no names?
|
||||
fn picks_from_no_names(complex: &Complex, f: &Filter) -> bool {
|
||||
matches!(f.keep, Keep::Values { .. }) && complex.names(&f.property).is_none()
|
||||
}
|
||||
|
||||
/// `height_range`: absent (`None`), null for the automatic one (`Some(None)`), or [low, high].
|
||||
fn height_range(p: &Value, cmd: &str) -> Result<Option<Option<[f64; 2]>>> {
|
||||
let refuse = || bad(cmd, "`height_range` (or `color_range`) must be [low, high], lowest first, or null");
|
||||
@@ -246,8 +232,6 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
complex: read.complex,
|
||||
color_property: None,
|
||||
filters: filters(p, cmd)?.unwrap_or_default(),
|
||||
source: std::path::absolute(path).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| path.to_string()),
|
||||
max_points: Some(max_points as u64),
|
||||
};
|
||||
set_colors(&mut cloud, colors.0, colors.1, colors.2);
|
||||
check_properties(&cloud, cmd)?;
|
||||
@@ -276,7 +260,7 @@ fn find(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let key = match p.get("cloud") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`cloud` must be an id or name")),
|
||||
_ => return Err(bad(cmd, "`cloud` must be an id or a name")),
|
||||
};
|
||||
s.doc.point_clouds.iter().position(|c| c.id.to_string() == key || c.name == key).ok_or_else(|| bad(cmd, format!("no point cloud `{key}`")))
|
||||
}
|
||||
@@ -334,56 +318,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"cloud": id}))
|
||||
}
|
||||
|
||||
/// The most points a reload keeps: the limit the cloud was read under. A read is a function of
|
||||
/// the file and that limit, so an unchanged file reads as the very same points, and a file that
|
||||
/// grew is thinned only past the limit. The counts cannot say this: a cloud has fewer points than
|
||||
/// its file when some could not be read, too, thinned or not.
|
||||
fn reload_limit(c: &PointCloud) -> usize {
|
||||
c.max_points.map_or(solidcraft_io::DEFAULT_MAX_POINTS, |n| usize::try_from(n).unwrap_or(MAX_POINTS))
|
||||
}
|
||||
|
||||
/// Read the cloud's file again where it was read from, keeping its name, placement, units and
|
||||
/// how it is shown.
|
||||
/// The path comes from the design, which may be someone else's, so it is a design path: one on
|
||||
/// another computer is refused from its text until the user allows that server.
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "pointcloud.reload";
|
||||
let i = find(s, p, cmd)?;
|
||||
let mut c = s.doc.point_clouds.get(i).cloned().ok_or_else(|| bad(cmd, "point cloud"))?;
|
||||
if c.source.is_empty() {
|
||||
return Err(bad(cmd, format!("point cloud `{}` does not remember its file", c.name)));
|
||||
}
|
||||
let path = c.source.clone();
|
||||
let scale = unit_scale(&c.units).unwrap_or(1.0);
|
||||
let max_points = reload_limit(&c);
|
||||
let bytes = solidcraft_io::vfs::read_design_path(&path, s.path.as_deref(), solidcraft_io::MAX_POINT_CLOUD_BYTES)
|
||||
.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let read = solidcraft_io::read_point_cloud(&bytes, &path, scale, max_points).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
c.origin = read.origin;
|
||||
c.points = Points::new(read.points.into());
|
||||
c.colors = Colors(read.colors.into());
|
||||
c.source_points = read.source_points;
|
||||
c.complex = read.complex;
|
||||
// What the file no longer has is no longer coloured or filtered by.
|
||||
let names: Vec<String> = c.property_names().map(str::to_string).collect();
|
||||
c.filters.retain(|f| names.contains(&f.property) && !picks_from_no_names(&c.complex, f));
|
||||
if c.color_by == ColorBy::Property && !c.color_property.as_ref().is_some_and(|n| names.contains(n)) {
|
||||
(c.color_by, c.color_property) = (ColorBy::File, None);
|
||||
}
|
||||
c.check()?;
|
||||
let out = PointCloudReloadResult {
|
||||
cloud: c.id,
|
||||
points: c.len(),
|
||||
segments: c.complex.segment_count(),
|
||||
triangles: c.complex.triangle_count(),
|
||||
warnings: read.warnings,
|
||||
};
|
||||
let out = serde_json::to_value(out).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let slot = s.doc_mut().point_clouds.get_mut(i).ok_or_else(|| bad(cmd, "point cloud"))?;
|
||||
*slot = c;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = find(s, p, "pointcloud.delete")?;
|
||||
let c = s.doc_mut().point_clouds.remove(i);
|
||||
@@ -414,7 +348,6 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
.collect::<Vec<_>>(),
|
||||
"filters": c.filters,
|
||||
"color_range": solidcraft_doc::point_cloud_distribution::color_range(c, &solidcraft_doc::point_cloud_distribution::Geometry::of(c), &c.filters),
|
||||
"source": c.source,
|
||||
"color": c.color,
|
||||
"colored": !c.colors.is_empty(),
|
||||
"height_range": c.height_range_now(),
|
||||
@@ -531,174 +464,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A PLY in text with a `layer` property (or none) on each point.
|
||||
fn layered(path: &std::path::Path, points: usize, layer: bool) {
|
||||
let mut b = format!("ply\nformat ascii 1.0\nelement vertex {points}\nproperty float x\nproperty float y\nproperty float z\n");
|
||||
if layer {
|
||||
b.push_str("property int layer\n");
|
||||
}
|
||||
b.push_str("end_header\n");
|
||||
for i in 0..points {
|
||||
b.push_str(&if layer { format!("{i} 0 0 {i}\n") } else { format!("{i} 0 0\n") });
|
||||
}
|
||||
std::fs::write(path, b).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_reads_the_file_again_and_keeps_how_the_cloud_is_shown() {
|
||||
let d = dir("reload");
|
||||
let path = d.join("scan.ply");
|
||||
layered(&path, 3, true);
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("pointcloud.insert", &json!({"path": path, "units": "m"})).unwrap()["cloud"].as_u64().unwrap();
|
||||
s.execute(
|
||||
"pointcloud.edit",
|
||||
&json!({"cloud": id, "name": "Scan", "point_size": 4, "matrix": [1, 0, 0, 100, 0, 1, 0, 0, 0, 0, 1, 0], "color_by": "layer", "filters": [{"property": "layer", "range": [0, 2]}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let listed = s.execute("pointcloud.list", &json!({})).unwrap();
|
||||
assert_eq!(listed["clouds"][0]["source"], json!(std::path::absolute(&path).unwrap()));
|
||||
|
||||
layered(&path, 5, true);
|
||||
let r = s.execute("pointcloud.reload", &json!({"cloud": "Scan"})).unwrap();
|
||||
assert_eq!((r["cloud"].as_u64(), r["points"].as_u64()), (Some(id), Some(5)));
|
||||
let c = &s.doc.point_clouds[0];
|
||||
assert_eq!((c.name.as_str(), c.units.as_str(), c.point_size, c.color_by), ("Scan", "m", 4.0, ColorBy::Property));
|
||||
assert_eq!(c.color_property.as_deref(), Some("layer"));
|
||||
assert_eq!(c.filters.len(), 1);
|
||||
// In metres and moved, as before: the last point is at 4 m, plus 100 mm.
|
||||
assert_eq!(c.world_point(4).map(|p| p.x), Some(4100.0));
|
||||
// One undo step takes it back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].len(), 3);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].len(), 5);
|
||||
|
||||
// What the file no longer has is no longer coloured or filtered by.
|
||||
layered(&path, 2, false);
|
||||
s.execute("pointcloud.reload", &json!({"cloud": id})).unwrap();
|
||||
let c = &s.doc.point_clouds[0];
|
||||
assert_eq!((c.len(), c.color_by, c.color_property.as_deref(), c.filters.len()), (2, ColorBy::File, None, 0));
|
||||
|
||||
// A file that is gone is refused, and the cloud is as it was, with no undo step.
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let (before, undo) = (s.doc.point_clouds.clone(), s.undo.len());
|
||||
assert!(s.execute("pointcloud.reload", &json!({"cloud": id})).is_err());
|
||||
assert_eq!((&s.doc.point_clouds, s.undo.len()), (&before, undo));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A set of values filters a label, so it lasts as long as the file names that property's
|
||||
/// values: left behind, it would make every later `pointcloud.edit` of the cloud refused.
|
||||
#[test]
|
||||
fn a_reload_drops_a_value_filter_when_the_file_stops_naming_the_values() {
|
||||
let d = dir("reload-names");
|
||||
let path = d.join("scan.ply");
|
||||
let write = |comment: &str| {
|
||||
let head = "ply\nformat ascii 1.0\n";
|
||||
let body = "element vertex 2\nproperty float x\nproperty float y\nproperty float z\nproperty int layer\nend_header\n0 0 0 0\n1 0 0 1\n";
|
||||
std::fs::write(&path, format!("{head}{comment}{body}")).unwrap();
|
||||
};
|
||||
write("comment layer: 0 first, 1 second\n");
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("pointcloud.insert", &json!({"path": path})).unwrap()["cloud"].as_u64().unwrap();
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "filters": [{"property": "layer", "values": [1]}]})).unwrap();
|
||||
|
||||
// Still named: the filter stays.
|
||||
s.execute("pointcloud.reload", &json!({"cloud": id})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].filters.len(), 1);
|
||||
|
||||
// The same property with no names: the filter goes, and the cloud can still be edited.
|
||||
write("");
|
||||
s.execute("pointcloud.reload", &json!({"cloud": id})).unwrap();
|
||||
assert!(s.doc.point_clouds[0].filters.is_empty());
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "point_size": 4})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A design may come from anyone, and on Windows only asking about `\\\\host\\share\\x` connects
|
||||
/// to that computer with the user's sign-in: the path is refused from its text, with no file
|
||||
/// call, until the server is allowed.
|
||||
#[test]
|
||||
fn a_reload_reaches_no_network_location_until_its_server_is_allowed() {
|
||||
use solidcraft_io::vfs::{allowing_hosts, file_calls};
|
||||
let d = dir("reload-network");
|
||||
let path = d.join("scan.ply");
|
||||
layered(&path, 3, false);
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("pointcloud.insert", &json!({"path": path})).unwrap()["cloud"].as_u64().unwrap();
|
||||
for (crafted, says) in [(r"\\evil.example\share\scan.ply", "evil.example"), (r"\\.\pipe\scan.ply", "device")] {
|
||||
s.doc_mut().point_clouds[0].source = crafted.into();
|
||||
let (before, undo, calls) = (s.doc.point_clouds.clone(), s.undo.len(), file_calls());
|
||||
let e = s.execute("pointcloud.reload", &json!({"cloud": id})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{e}");
|
||||
assert_eq!(file_calls(), calls, "{crafted}: nothing was asked of the disk or the network");
|
||||
assert_eq!((&s.doc.point_clouds, s.undo.len()), (&before, undo));
|
||||
}
|
||||
// Allowed, the read goes ahead (and fails here only because no such server answers).
|
||||
s.doc_mut().point_clouds[0].source = r"\\evil.example\share\scan.ply".into();
|
||||
let calls = file_calls();
|
||||
let allowed = allowing_hosts(&["evil.example".to_string()], || s.execute("pointcloud.reload", &json!({"cloud": id})));
|
||||
assert!(allowed.is_err());
|
||||
assert!(file_calls() > calls, "an allowed server is read");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_reads_a_grown_file_whole_and_keeps_a_thinned_cloud_thinned() {
|
||||
let d = dir("reload-limit");
|
||||
let (whole, thin) = (d.join("whole.ply"), d.join("thin.ply"));
|
||||
layered(&whole, 4, false);
|
||||
layered(&thin, 100, false);
|
||||
let mut s = Session::default();
|
||||
s.execute("pointcloud.insert", &json!({"path": whole, "name": "Whole"})).unwrap();
|
||||
// One point in ten is the fewest strides that fit in 11: ten points, under the limit.
|
||||
s.execute("pointcloud.insert", &json!({"path": thin, "name": "Thin", "max_points": 11})).unwrap();
|
||||
let thinned = s.doc.point_clouds[1].clone();
|
||||
assert_eq!((thinned.len(), thinned.source_points), (10, 100));
|
||||
|
||||
layered(&whole, 100, false);
|
||||
let r = s.execute("pointcloud.reload", &json!({"cloud": "Whole"})).unwrap();
|
||||
assert_eq!(r["points"].as_u64(), Some(100), "a file that grew is not cut to the count it had");
|
||||
|
||||
// The same file reads as the very same points.
|
||||
s.execute("pointcloud.reload", &json!({"cloud": "Thin"})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[1].points, thinned.points);
|
||||
// A grown one, thinned under the same limit.
|
||||
layered(&thin, 500, false);
|
||||
let r = s.execute("pointcloud.reload", &json!({"cloud": "Thin"})).unwrap();
|
||||
assert_eq!(r["points"].as_u64(), Some(11));
|
||||
assert_eq!(s.doc.point_clouds[1].source_points, 500);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_takes_no_unreadable_point_for_thinning() {
|
||||
let d = dir("reload-unreadable");
|
||||
// `count` points in a line, with the one at `far` out of range, so it cannot be read.
|
||||
let line = |count: usize, far: usize| -> Vec<[f32; 3]> { (0..count).map(|i| [if i == far { 1e30 } else { i as f32 }, 0.0, 0.0]).collect() };
|
||||
let whole = ply(&d, "whole.ply", &line(4, 1), false);
|
||||
let thin = ply(&d, "thin.ply", &line(100, 10), false);
|
||||
let mut s = Session::default();
|
||||
s.execute("pointcloud.insert", &json!({"path": whole, "name": "Whole"})).unwrap();
|
||||
s.execute("pointcloud.insert", &json!({"path": thin, "name": "Thin", "max_points": 10})).unwrap();
|
||||
let (kept_whole, kept_thin) = (s.doc.point_clouds[0].clone(), s.doc.point_clouds[1].clone());
|
||||
assert_eq!((kept_whole.len(), kept_whole.source_points), (3, 4));
|
||||
assert_eq!((kept_thin.len(), kept_thin.source_points), (9, 100));
|
||||
|
||||
// The same files read as the very same points.
|
||||
s.execute("pointcloud.reload", &json!({"cloud": "Whole"})).unwrap();
|
||||
s.execute("pointcloud.reload", &json!({"cloud": "Thin"})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].points, kept_whole.points);
|
||||
assert_eq!(s.doc.point_clouds[1].points, kept_thin.points);
|
||||
|
||||
// A file that grew comes in whole, not cut to the count it had.
|
||||
ply(&d, "whole.ply", &line(100, 1), false);
|
||||
let r = s.execute("pointcloud.reload", &json!({"cloud": "Whole"})).unwrap();
|
||||
assert_eq!(r["points"].as_u64(), Some(99));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_point_cloud_params_never_panic() {
|
||||
let d = dir("hostile");
|
||||
@@ -746,18 +511,6 @@ mod tests {
|
||||
assert!(s.execute("pointcloud.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("pointcloud.delete", &json!({"cloud": "nope"})).is_err());
|
||||
for p in [json!({}), json!({"cloud": 999}), json!({"cloud": [1]})] {
|
||||
assert!(s.execute("pointcloud.reload", &p).is_err(), "{p}");
|
||||
}
|
||||
// A cloud from a design saved before it remembered its file, or naming a file not a scan.
|
||||
let source = s.doc.point_clouds[0].source.clone();
|
||||
for hostile in ["", bad.to_str().unwrap(), d.to_str().unwrap()] {
|
||||
s.doc_mut().point_clouds[0].source = hostile.into();
|
||||
let before = s.doc.point_clouds.clone();
|
||||
assert!(s.execute("pointcloud.reload", &json!({"cloud": id})).is_err(), "{hostile}");
|
||||
assert_eq!(s.doc.point_clouds, before);
|
||||
}
|
||||
s.doc_mut().point_clouds[0].source = source;
|
||||
// Scaled out of range: refused, and the cloud is as it was.
|
||||
let far = ply(&d, "far.ply", &[[2e6, 0.0, 0.0], [2e6, 1.0, 0.0]], false);
|
||||
let far = s.execute("pointcloud.insert", &json!({"path": far})).unwrap()["cloud"].as_u64().unwrap();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ pub(super) struct Override {
|
||||
ior: f64,
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > solidcraft_doc::render_coat::MAX_COAT_BINDINGS {
|
||||
if overrides.len() > 64 {
|
||||
return Err(bad(command, "at most 64 runtime clear-coat overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
@@ -31,33 +31,28 @@ pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &
|
||||
return Err(bad(command, "clear coats require bounded names and distinct captured body ordinals"));
|
||||
}
|
||||
let coat = ClearCoat::new(input.weight, input.roughness, input.ior).map_err(|e| bad(command, e.to_string()))?;
|
||||
apply_one(scene, input.body_index, &input.body_name, coat, command)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn apply_one(scene: &mut ProductScene, body_index: usize, body_name: &str, coat: ClearCoat, command: &str) -> Result<()> {
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(body_index)
|
||||
.filter(|m| m.name == body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
|
||||
// A zero-weight override is a true no-op, including legacy appearances.
|
||||
if coat.weight() == 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
|
||||
return Err(bad(command, "clear coat and transmission cannot share a body"));
|
||||
}
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
let base =
|
||||
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(base.with_clear_coat(coat))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(input.body_index)
|
||||
.filter(|m| m.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
|
||||
// A zero-weight override is a true no-op, including legacy appearances.
|
||||
if coat.weight() == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
|
||||
return Err(bad(command, "clear coat and transmission cannot share a body"));
|
||||
}
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
let base =
|
||||
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(base.with_clear_coat(coat))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -90,149 +85,3 @@ mod tests {
|
||||
assert!(apply(&mut original.clone(), &duplicate, "render.start").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stable_tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::render_coat::{CoatBindings, CoatMaterial, SavedCoat};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn stable_coats_save_reopen_replace_and_undo_offline_for_cpu_and_gpu_without_changing_sources() {
|
||||
for method in ["cpu_raytrace", "gpu_path"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":3,"body_name":"Housing"})).unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Housing"],"material":"Steel"})).unwrap();
|
||||
s.execute("render.settings.set", &json!({"name":"Coat","set":{"width":8,"height":8,"job":{"method":method}}})).unwrap();
|
||||
let physical = s.execute("component.properties", &json!({})).unwrap();
|
||||
let model = s.model.state();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let evaluations = s.evaluations;
|
||||
let coat = json!({"weight":0.63,"roughness":0.61,"ior":1.7});
|
||||
s.execute("render.coat.bind", &json!({"name":"Coat","body_index":0,"body_name":"Housing","coat":coat,"expected_revision":s.revision}))
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(physical, s.execute("component.properties", &json!({})).unwrap());
|
||||
let saved = s.doc.clone();
|
||||
let loaded = solidcraft_doc::Document::from_json(&saved.to_json()).unwrap();
|
||||
assert_eq!(loaded.render, saved.render);
|
||||
let original = crate::product_scene::extract(&s).unwrap();
|
||||
let mut scene = original.clone();
|
||||
let faces_capacity = scene.meshes[0].faces.capacity();
|
||||
let cpu = solidcraft_render::job::RaytraceOptions::default();
|
||||
super::super::coat_bindings::apply(
|
||||
&s,
|
||||
&mut scene,
|
||||
&saved.render.setups["Coat"].coats,
|
||||
(method == "cpu_raytrace").then_some(&cpu),
|
||||
"check",
|
||||
)
|
||||
.unwrap();
|
||||
let actual = scene.meshes[0].surface.unwrap().clear_coat().unwrap();
|
||||
assert_eq!((actual.weight(), actual.roughness(), actual.ior()), (0.63, 0.61, 1.7));
|
||||
assert!(Arc::ptr_eq(&original.meshes[0].mesh, &scene.meshes[0].mesh));
|
||||
assert_eq!(original.meshes[0].faces.len(), scene.meshes[0].faces.len());
|
||||
assert_eq!(faces_capacity, scene.meshes[0].faces.capacity());
|
||||
assert_eq!(s.doc, saved, "captured coat never changes the source appearance");
|
||||
for face in &scene.meshes[0].faces {
|
||||
assert_eq!(face.surface.unwrap().clear_coat(), Some(actual));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
let replacement = json!({"name":"Coat","body_index":0,"body_name":"Housing","coat":{"weight":0.4,"roughness":0.3,"ior":1.8},"expected_revision":s.revision});
|
||||
assert!(s.execute("render.coat.bind", &replacement).is_err());
|
||||
assert_eq!(s.doc, saved);
|
||||
let mut explicit = replacement;
|
||||
explicit["replace"] = json!(true);
|
||||
s.execute("render.coat.bind", &explicit).unwrap();
|
||||
assert_eq!(s.doc.render.setups["Coat"].coats.iter().next().unwrap().coat.ior, 1.8);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
let target = saved.render.setups["Coat"].coats.iter().next().unwrap().target.clone();
|
||||
s.execute("render.coat.unbind", &json!({"name":"Coat","target":target,"expected_revision":s.revision})).unwrap();
|
||||
assert!(s.doc.render.setups["Coat"].coats.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stable_coat_source_replacement_hidden_and_glass_guards_never_guess_or_mutate_a_binding() {
|
||||
for method in ["cpu_raytrace", "gpu_path"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
s.execute(
|
||||
"render.coat.bind",
|
||||
&json!({"name":"Coat","set":{"width":4,"height":4,"job":{"method":method}},
|
||||
"body_index":0,"body_name":"Housing","coat":{"weight":0},"expected_revision":s.revision}),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = s.doc.render.setups["Coat"].coats.clone();
|
||||
let target = saved.iter().next().unwrap().target.clone();
|
||||
let original = crate::product_scene::extract(&s).unwrap();
|
||||
let mut zero = original.clone();
|
||||
super::super::coat_bindings::apply(&s, &mut zero, &saved, None, "check").unwrap();
|
||||
assert_eq!(zero.meshes[0].surface, original.meshes[0].surface);
|
||||
assert!(Arc::ptr_eq(&zero.meshes[0].mesh, &original.meshes[0].mesh));
|
||||
let before = s.doc.clone();
|
||||
assert!(
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"Coat","body_index":0,"body_name":"Housing","optics":{},"expected_revision":s.revision})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(s.doc, before, "zero weight is authored opaque intent, not permission to silently drop it on Glass");
|
||||
let mut glass = original;
|
||||
glass.meshes[0].transmission = Some(solidcraft_render::transmission::Transmission::new(Vec3::ZERO, None).unwrap());
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut glass, &saved, None, "check").unwrap_err().to_string().contains("opaque"));
|
||||
s.execute("browser.visibility", &json!({"items":["Housing"],"visible":false})).unwrap();
|
||||
let mut hidden = crate::product_scene::extract(&s).unwrap();
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut hidden, &saved, None, "check").unwrap_err().to_string().contains("hidden"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("timeline.delete", &json!({"feature":target.feature.to_string()})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
let mut replaced = crate::product_scene::extract(&s).unwrap();
|
||||
assert_eq!((replaced.meshes.len(), replaced.meshes[0].name.as_str()), (1, "Housing"));
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut replaced, &saved, None, "check").unwrap_err().to_string().contains("changed"));
|
||||
assert_eq!(s.doc.render.setups["Coat"].coats, saved);
|
||||
// Empty means the original capture path, even when no old target survives.
|
||||
super::super::coat_bindings::apply(&s, &mut replaced, &CoatBindings::default(), None, "check").unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stable_cpu_coats_refuse_selected_transport_but_leave_authored_values_and_scene_sources_intact() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
let target = super::super::surface_bindings::targets(&s, "check").unwrap().into_keys().next().unwrap();
|
||||
let mut bindings = CoatBindings::default();
|
||||
bindings.set(SavedCoat { target, coat: CoatMaterial::default() }, false).unwrap();
|
||||
let scene = crate::product_scene::extract(&s).unwrap();
|
||||
let options = solidcraft_render::job::RaytraceOptions {
|
||||
reflections: Some(solidcraft_render::job::ReflectionOptions {
|
||||
samples: 1,
|
||||
max_bounces: 1,
|
||||
bodies: vec![solidcraft_render::job::ReflectionBody {
|
||||
body: "Housing".into(),
|
||||
mode: solidcraft_render::job::ReflectionMode::RoughSpecular,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let before = s.doc.clone();
|
||||
let mut captured = scene.clone();
|
||||
let error = super::super::coat_bindings::apply(&s, &mut captured, &bindings, Some(&options), "check").unwrap_err().to_string();
|
||||
assert!(error.contains("selected reflection"));
|
||||
assert_eq!(captured.meshes[0].surface, scene.meshes[0].surface);
|
||||
assert_eq!(bindings.iter().next().unwrap().coat, CoatMaterial::default());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Stable opaque clear-coat bindings shared by CPU and GPU scene capture.
|
||||
//! Lowering changes only already-sized captured surface fields, never render options.
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_coat::{CoatBindings, SavedCoat},
|
||||
render_settings::RenderJob,
|
||||
render_surface::RenderBodyTarget,
|
||||
};
|
||||
use solidcraft_render::{ProductScene, job::RaytraceOptions};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
/// No bindings is the original zero-allocation capture path. Even zero-weight
|
||||
/// authored bindings require a current opaque target; they cannot mask Glass.
|
||||
pub(super) fn apply(s: &Session, scene: &mut ProductScene, bindings: &CoatBindings, cpu: Option<&RaytraceOptions>, command: &str) -> Result<()> {
|
||||
if bindings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
// The shared resolver indexes authored source identity. This additional
|
||||
// captured-name index is bounded by the 64 requested targets, not scene size.
|
||||
let mut visible = BTreeMap::new();
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved clear-coat target changed; explicit rebinding required"))?;
|
||||
if visible.insert(name.as_str(), None).is_some() {
|
||||
return Err(bad(command, "ambiguous stable clear-coat world target"));
|
||||
}
|
||||
}
|
||||
for (index, body) in scene.meshes.iter().enumerate() {
|
||||
if let Some(slot) = visible.get_mut(body.name.as_str())
|
||||
&& slot.replace(index).is_some()
|
||||
{
|
||||
return Err(bad(command, "ambiguous captured clear-coat target"));
|
||||
}
|
||||
}
|
||||
let mut resolved = Vec::new();
|
||||
resolved.try_reserve_exact(bindings.len()).map_err(|_| bad(command, "could not reserve clear-coat capture metadata"))?;
|
||||
if resolved.capacity() != bindings.len() {
|
||||
return Err(bad(command, "clear-coat capture capacity exceeds admission"));
|
||||
}
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved clear-coat target changed; explicit rebinding required"))?;
|
||||
let index = visible.get(name.as_str()).copied().flatten().ok_or_else(|| bad(command, "saved clear-coat target is hidden or absent"))?;
|
||||
let body = scene.meshes.get(index).ok_or_else(|| bad(command, "clear-coat target disappeared"))?;
|
||||
if body.transmission.is_some() || body.faces.iter().any(|face| face.transmission.is_some()) {
|
||||
return Err(bad(command, "saved clear coat requires an opaque target, including zero-weight authored coats"));
|
||||
}
|
||||
if cpu.is_some_and(|options| {
|
||||
options.dielectrics.iter().any(|entry| entry.body_index == index)
|
||||
|| options.reflections.as_ref().is_some_and(|overrides| overrides.bodies.iter().any(|entry| entry.body == *name))
|
||||
|| options.anisotropic_faces.iter().flatten().any(|entry| entry.body_index == index)
|
||||
}) {
|
||||
return Err(bad(command, "CPU clear-coat targets cannot share Glass, selected reflection or anisotropy overrides"));
|
||||
}
|
||||
resolved.push((index, name.as_str(), binding.coat.to_runtime().map_err(|e| bad(command, e.to_string()))?));
|
||||
}
|
||||
drop(visible);
|
||||
for (index, name, coat) in resolved {
|
||||
super::clear_coat::apply_one(scene, index, name, coat, command)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.coat.bind";
|
||||
let input: crate::schema::render::CoatBindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coat = input.coat.material();
|
||||
coat.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "clear-coat binding review is stale"));
|
||||
}
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|old| old.coats != setup.coats) {
|
||||
return Err(bad(command, "setup patch cannot replace saved clear coats; use explicit bind/unbind"));
|
||||
}
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "saved clear coats require an explicit CPU or GPU raytrace setup"));
|
||||
}
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
let name = &scene
|
||||
.meshes
|
||||
.get(input.body_index)
|
||||
.filter(|body| body.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat target ordinal and exact name do not match"))?
|
||||
.name;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
let mut candidates = identities.iter().filter(|(_, world)| *world == name);
|
||||
let target =
|
||||
candidates.next().map(|(target, _)| target.clone()).ok_or_else(|| bad(command, "clear-coat target has no stable source identity"))?;
|
||||
if candidates.next().is_some() {
|
||||
return Err(bad(command, "ambiguous clear-coat target identity"));
|
||||
}
|
||||
setup.coats.set(SavedCoat { target, coat }, input.replace).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
// Verify the actual opaque source even for inactive authored values. Runtime
|
||||
// options are not invented while saving an offline stable numerical setup.
|
||||
apply(s, &mut scene, &setup.coats, None, command)?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"name":input.name,"setup":setup,"geometry_and_engineering_unchanged":true}))
|
||||
}
|
||||
|
||||
pub(super) fn unbind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.coat.unbind";
|
||||
let input: crate::schema::render::CoatUnbindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let target: RenderBodyTarget = serde_json::from_value(input.target).map_err(|e| bad(command, e.to_string()))?;
|
||||
target.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "clear-coat removal review is stale"));
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let setup = settings.setups.get_mut(&input.name).ok_or_else(|| bad(command, "unknown clear-coat setup"))?;
|
||||
let removed = setup.coats.remove(&target);
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"name":input.name,"removed":removed}))
|
||||
}
|
||||
@@ -13,9 +13,9 @@ fn empty(p: &Value, command: &str) -> Result<()> {
|
||||
}
|
||||
pub(super) fn aging(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
empty(p, "render.aging.describe")?;
|
||||
Ok(json!({"maximum_layers":8,"storage":"CPU/GPU saved job.options.aging","order":"bottom to top; later layers modify earlier surface results",
|
||||
Ok(json!({"maximum_layers":8,"storage":"GPU saved job.options.aging","order":"bottom to top; later layers modify earlier surface results",
|
||||
"presets":AgingKind::ALL.map(AgingLayer::preset),"target":"all eligible opaque bodies or stable definition/feature/component/occurrence identity; capture indices are resolved only for the runtime job; passive stages and glass are excluded",
|
||||
"coordinate_space":"world millimetres, explicit origin; 4096-cell periodic noise with directional Scratch bands; not object-following motion",
|
||||
"coordinate_space":"world millimetres, explicit origin and 4096-cell periodic field; not object-following motion",
|
||||
"preserves":["engineering geometry","mass and physical material cards","pose","source textures","visibility"],
|
||||
"limitations":["authored optical weathering, not measured corrosion or age prediction","no geometric grooves or edge-curvature detection","no aging on glass or participating-medium boundaries","stack changes transport and requires a new render"],
|
||||
"airborne":"use the separately validated finite homogeneous GPU medium; it is not deposited surface dust or a physical lunar atmosphere"}))
|
||||
|
||||
@@ -29,11 +29,9 @@ pub(super) fn set(s: &mut crate::Session, params: &serde_json::Value) -> Result<
|
||||
.transpose()?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
let (angle, intensity, maximum) = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if let Some(hdr) = &mut options.hdr {
|
||||
(Some(&mut hdr.rotation_rad), &mut hdr.intensity, 64.0)
|
||||
} else if let Some(SkyEnvironment::Sky { rotation_rad, intensity, .. }) = &mut options.sky {
|
||||
(Some(rotation_rad), intensity, 100.0)
|
||||
} else if let Some(environment) = &mut options.environment {
|
||||
(None, &mut environment.intensity, 64.0)
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ use solidcraft_render::{
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
aging: solidcraft_doc::render_aging::SavedAgingStack,
|
||||
studio_weights: Option<[f64; 3]>,
|
||||
progressive: bool,
|
||||
denoise: bool,
|
||||
@@ -48,7 +47,6 @@ impl Default for EnvironmentRequest {
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct ProgressiveStart {
|
||||
caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
studio_weights: Option<[f64; 3]>,
|
||||
aging: solidcraft_doc::render_aging::SavedAgingStack,
|
||||
#[serde(default)]
|
||||
@@ -76,10 +74,14 @@ struct ProgressiveStart {
|
||||
denoise: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Prepared {
|
||||
pub(crate) replace: bool,
|
||||
pub(crate) submission: Option<solidcraft_render::job::PreparedSubmission>,
|
||||
pub(crate) reply: Value,
|
||||
struct Prepared {
|
||||
replace: bool,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: ImageRequest,
|
||||
reply: Value,
|
||||
executor: solidcraft_render::ExecutorId,
|
||||
options: solidcraft_render::job::JobOptions,
|
||||
environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
|
||||
}
|
||||
|
||||
fn lower_led(input: &mut Start, command: &str) -> Result<()> {
|
||||
@@ -177,13 +179,12 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
let mut hdr = None;
|
||||
let mut lowered = match job {
|
||||
RenderJob::Raster { transparent_background } => json!({"transparent_background":transparent_background}),
|
||||
RenderJob::CpuRaytrace { mut options, transparent_background } => {
|
||||
RenderJob::CpuRaytrace { options } => {
|
||||
hdr = options.hdr.map(SavedHdr::Cpu);
|
||||
let aging = std::mem::take(&mut options.aging);
|
||||
let raytrace = options.to_runtime(settings.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
|
||||
// The legacy CPU path reads EV from RaytraceOptions. There is one saved authority.
|
||||
settings.exposure_ev = 0.0;
|
||||
json!({"integrator":"raytrace","raytrace":raytrace,"aging":aging,"transparent_background":transparent_background})
|
||||
json!({"integrator":"raytrace","raytrace":raytrace})
|
||||
}
|
||||
RenderJob::GpuPath { transparent_background, options } => {
|
||||
hdr = options.hdr.map(SavedHdr::Gpu);
|
||||
@@ -194,7 +195,7 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
"lens":options.lens,"lighting":options.lighting,"tone_map":options.tone_map,
|
||||
"denoise":options.denoise,"environment":options.environment,
|
||||
"finishing":options.finishing,"backdrop":options.backdrop,"linear_exr":options.linear_exr,
|
||||
"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -208,26 +209,14 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
Ok(Some(Normalized { params: lowered, hdr, surfaces, optics }))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(s: &Session, params: &Value, command: &str, cancel: &solidcraft_render::job::CancelToken) -> Result<Prepared> {
|
||||
prepare_with_metadata(s, params, command, cancel, None)
|
||||
}
|
||||
pub(crate) fn prepare_with_metadata(
|
||||
s: &Session,
|
||||
params: &Value,
|
||||
command: &str,
|
||||
cancel: &solidcraft_render::job::CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
cancel.check().map_err(job_error)?;
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
let normalized = normalize_saved_job(s, params, command)?;
|
||||
let saved_hdr = normalized.as_ref().and_then(|normalized| normalized.hdr.as_ref());
|
||||
let params = normalized.as_ref().map_or(params, |normalized| &normalized.params);
|
||||
if params.get("progressive").and_then(Value::as_bool) == Some(true) {
|
||||
let hdr = match saved_hdr {
|
||||
Some(SavedHdr::Gpu(hdr)) => Some(*hdr),
|
||||
Some(SavedHdr::Cpu(_)) => {
|
||||
return Err(bad(command, "saved CPU HDR cannot enter a GPU request"));
|
||||
}
|
||||
Some(SavedHdr::Cpu(_)) => return Err(bad(command, "saved CPU HDR cannot enter a GPU request")),
|
||||
None => None,
|
||||
};
|
||||
return prepare_progressive(
|
||||
@@ -238,8 +227,6 @@ pub(crate) fn prepare_with_metadata(
|
||||
hdr,
|
||||
normalized.as_ref().map(|n| &n.surfaces),
|
||||
normalized.as_ref().map(|n| &n.optics),
|
||||
cancel,
|
||||
retained,
|
||||
);
|
||||
}
|
||||
let gpu_raytrace =
|
||||
@@ -255,42 +242,12 @@ pub(crate) fn prepare_with_metadata(
|
||||
));
|
||||
}
|
||||
let mut input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coats = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.coats));
|
||||
// A checked file HDR uses the runtime request lane, but its optical targets
|
||||
// remain stable authored identities. Extract them before validating job:null
|
||||
// settings, just as for stable coats; never discard or turn them into ordinals
|
||||
// in the UI. Exact visible identities are resolved from the captured scene.
|
||||
let inline_optics = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.optics));
|
||||
let normalized_optics = normalized.as_ref().map(|n| &n.optics).filter(|bindings| !bindings.is_empty());
|
||||
if normalized_optics.is_some() && !inline_optics.is_empty() {
|
||||
return Err(bad(command, "choose normalized or inline stable optics, not both"));
|
||||
}
|
||||
let saved_optics = normalized_optics.or((!inline_optics.is_empty()).then_some(&inline_optics));
|
||||
if let Some(bindings) = saved_optics {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable optics require CPU or GPU ray tracing"));
|
||||
}
|
||||
let options = input.raytrace.get_or_insert_default();
|
||||
bindings.validate_for_cpu_path(options.path.is_some()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if options.volume.is_some_and(|volume| volume.active()) {
|
||||
return Err(bad(command, "CPU Atmosphere cannot combine with saved Glass or subsurface scattering; settings are kept"));
|
||||
}
|
||||
if options.denoise && bindings.iter().any(|binding| binding.optics.subsurface.is_some_and(|scatter| scatter.active())) {
|
||||
return Err(bad(command, "CPU subsurface random walks have no surface-denoiser guides; disable denoising explicitly"));
|
||||
}
|
||||
if !options.dielectrics.is_empty() {
|
||||
return Err(bad(command, "saved optics cannot combine with runtime dielectric overrides"));
|
||||
}
|
||||
}
|
||||
if !coats.is_empty() && input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable clear coats require CPU or GPU ray tracing"));
|
||||
}
|
||||
validate_studio_weights(input.studio_weights, command)?;
|
||||
if input.integrator == Integrator::Raytrace && input.studio_weights.is_some() {
|
||||
return Err(bad(command, "studio_weights apply to raster or the implicit GPU studio; use explicit CPU light intensities"));
|
||||
}
|
||||
if input.progressive {
|
||||
return prepare_progressive(s, params, command, false, None, None, None, cancel, retained);
|
||||
return prepare_progressive(s, params, command, false, None, None, None);
|
||||
}
|
||||
if input.denoise {
|
||||
return Err(bad(command, "denoise requires GPU progressive rendering"));
|
||||
@@ -309,36 +266,14 @@ pub(crate) fn prepare_with_metadata(
|
||||
}
|
||||
host.validate_integrator(input.integrator).map_err(job_error)?;
|
||||
lower_led(&mut input, command)?;
|
||||
if !input.aging.is_empty() {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable CPU aging requires the CPU raytrace integrator"));
|
||||
}
|
||||
input.aging.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
input.raytrace.get_or_insert_with(RaytraceOptions::default);
|
||||
}
|
||||
let saved_surfaces = normalized.as_ref().map(|n| &n.surfaces).filter(|bindings| !bindings.is_empty());
|
||||
if saved_surfaces.is_some() {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "saved opaque surfaces require CPU or GPU ray tracing"));
|
||||
}
|
||||
solidcraft_render::job::textures::validate_cpu_options(input.raytrace.as_ref().unwrap_or(&RaytraceOptions::default())).map_err(job_error)?;
|
||||
if normalized.as_ref().is_some_and(|n| !n.optics.is_empty()) && input.raytrace.as_ref().is_none_or(|options| options.path.is_none()) {
|
||||
return Err(bad(command, "CPU maps with Glass require Indirect lighting and separate targets"));
|
||||
}
|
||||
}
|
||||
if input.integrator == Integrator::Raster && input.raytrace.is_some() {
|
||||
return Err(bad(command, "raytrace options require integrator: raytrace"));
|
||||
}
|
||||
if input.integrator == Integrator::Raytrace && input.transparent_background {
|
||||
return Err(bad(command, "raytrace supports opaque backgrounds only"));
|
||||
}
|
||||
if let Some(options) = &input.raytrace {
|
||||
options.retained_bytes().map_err(job_error)?;
|
||||
if !input.aging.is_empty() {
|
||||
if options.aging.as_ref().is_some_and(|stack| !stack.is_empty()) {
|
||||
return Err(bad(command, "choose stable authored aging or captured runtime aging, not both"));
|
||||
}
|
||||
if input.aging.iter().any(|layer| layer.active()) && options.anisotropic_faces.iter().any(Option::is_some) {
|
||||
return Err(bad(command, "CPU aging cannot replace independently authored anisotropic roughness axes"));
|
||||
}
|
||||
}
|
||||
options.budget.validate_execution().map_err(job_error)?;
|
||||
// Check the complete image envelope before any source file is read.
|
||||
let dimensions = if let Some(name) = &input.setup {
|
||||
@@ -350,29 +285,7 @@ pub(crate) fn prepare_with_metadata(
|
||||
let defaults = RenderSetup::default();
|
||||
(defaults.width, defaults.height)
|
||||
};
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
if let Some(lens) = options.lens {
|
||||
let camera = input
|
||||
.setup
|
||||
.as_ref()
|
||||
.and_then(|name| s.doc.render.setups.get(name))
|
||||
.map(|setup| setup.camera)
|
||||
.or_else(|| input.settings.as_ref().map(|setup| setup.camera))
|
||||
.or(s.camera)
|
||||
.unwrap_or_default();
|
||||
lens.validate_camera(&camera).map_err(job_error)?;
|
||||
}
|
||||
if let Some(output) = &options.output {
|
||||
output.validate_image(dimensions.0, dimensions.1).map_err(job_error)?;
|
||||
output.presentation(options.exposure_ev, [0; 3], input.transparent_background).validate().map_err(job_error)?;
|
||||
}
|
||||
}
|
||||
// Refuse conflicting sources before VFS/resource access. Sky is a direct
|
||||
// radiance consumer and never a substitute for unread HDR bytes.
|
||||
if (saved_hdr.is_some() || input.environment.is_some())
|
||||
&& input.raytrace.as_ref().is_some_and(|options| options.sky.is_some() || options.environment.is_some())
|
||||
{
|
||||
return Err(bad(command, "choose either CPU HDR input, gradient Sky or constant environment"));
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.progressive).map_err(job_error)?;
|
||||
}
|
||||
let environment = if let Some(saved_hdr) = saved_hdr {
|
||||
let SavedHdr::Cpu(hdr) = saved_hdr else {
|
||||
@@ -423,23 +336,15 @@ pub(crate) fn prepare_with_metadata(
|
||||
None
|
||||
};
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
if let Some(bindings) = saved_optics {
|
||||
let options = input.raytrace.as_mut().ok_or_else(|| bad(command, "saved CPU optics require a CPU raytrace request"))?;
|
||||
if !options.dielectrics.is_empty() {
|
||||
return Err(bad(command, "saved optics cannot combine with runtime dielectric overrides"));
|
||||
let textures = if let Some(bindings) = normalized.as_ref().map(|saved| &saved.surfaces).filter(|bindings| !bindings.is_empty()) {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "saved surface maps require a raytrace executor"));
|
||||
}
|
||||
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
|
||||
options.validate().map_err(job_error)?;
|
||||
}
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
|
||||
if !input.aging.is_empty() {
|
||||
let options = input.raytrace.as_mut().ok_or_else(|| bad(command, "CPU aging requires a CPU raytrace request"))?;
|
||||
if options.aging.as_ref().is_some_and(|stack| !stack.is_empty()) {
|
||||
return Err(bad(command, "choose stable authored aging or captured runtime aging, not both"));
|
||||
}
|
||||
options.aging = Some(Box::new(super::effects::resolve_aging(s, &scene, &input.aging, command)?));
|
||||
options.validate().map_err(job_error)?;
|
||||
}
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if environment.is_some() {
|
||||
input.raytrace.as_ref().unwrap_or(&RaytraceOptions::default()).validate_environment_reflections(&scene).map_err(job_error)?;
|
||||
}
|
||||
@@ -464,13 +369,7 @@ pub(crate) fn prepare_with_metadata(
|
||||
if input.integrator == Integrator::Raytrace {
|
||||
settings.validate_for_cpu(budget).map_err(|e| bad(command, e.to_string()))?;
|
||||
budget.validate_execution().map_err(job_error)?;
|
||||
budget
|
||||
.validate_image(
|
||||
settings.width,
|
||||
settings.height,
|
||||
input.raytrace.as_ref().map(|o| o.camera_sampling()).transpose().map_err(job_error)?.flatten(),
|
||||
)
|
||||
.map_err(job_error)?;
|
||||
budget.validate_image(settings.width, settings.height, input.raytrace.as_ref().and_then(|o| o.progressive)).map_err(job_error)?;
|
||||
} else {
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
@@ -517,12 +416,6 @@ pub(crate) fn prepare_with_metadata(
|
||||
if resolved.executor != expected_executor {
|
||||
return Err(bad(command, "resolved executor is not installed as an image job host"));
|
||||
}
|
||||
let textures = if let Some(bindings) = saved_surfaces {
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request = ImageRequest {
|
||||
studio_weights: validate_studio_weights(input.studio_weights, command)?,
|
||||
environment: None,
|
||||
@@ -535,18 +428,12 @@ pub(crate) fn prepare_with_metadata(
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
|
||||
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let submission =
|
||||
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
|
||||
let retained_environment =
|
||||
if environment_info.is_some() { std::mem::size_of::<solidcraft_render::lighting::environment_input::EnvironmentInfo>() as u64 } else { 0 };
|
||||
let retained_request_bytes = retained_lights
|
||||
.checked_add(retained_environment)
|
||||
.and_then(|bytes| bytes.checked_add(request.retained_resource_metadata_bytes()))
|
||||
.ok_or_else(|| bad(command, "request metadata overflow"))?;
|
||||
let retained_request_bytes = retained_lights.checked_add(retained_environment).ok_or_else(|| bad(command, "request metadata overflow"))?;
|
||||
|
||||
admission.estimated_total_bytes = admission
|
||||
.estimated_total_bytes
|
||||
@@ -559,38 +446,10 @@ pub(crate) fn prepare_with_metadata(
|
||||
let transparency = match input.integrator {
|
||||
Integrator::Raster => "sorted_alpha_approximation",
|
||||
Integrator::Raytrace if refraction => "physical_transmission",
|
||||
Integrator::Raytrace if input.transparent_background => "coverage_alpha",
|
||||
Integrator::Raytrace => "opaque_only",
|
||||
};
|
||||
let path = request.raytrace.as_ref().is_some_and(|options| options.path.is_some());
|
||||
let stage_transport = if path && refraction {
|
||||
"CPU finite-depth diffuse/specular and dielectric path lighting"
|
||||
} else if path {
|
||||
"CPU finite-depth opaque diffuse/specular path lighting"
|
||||
} else {
|
||||
"CPU direct/specular only; no diffuse interreflection"
|
||||
};
|
||||
let dielectric_boundaries = match (refraction, path) {
|
||||
(true, true) => vec!["thin_sheet", "closed_box", "closed_mesh"],
|
||||
(true, false) => vec!["thin_sheet", "closed_box"],
|
||||
(false, _) => Vec::new(),
|
||||
};
|
||||
let mut reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":stage_transport, "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"textures":request.textures.as_ref().map(|maps|maps.summary()),"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":dielectric_boundaries});
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
|
||||
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
|
||||
"bypass":"glass, sharp CPU mirror overrides, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"biased spatial filter; first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
|
||||
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
|
||||
"checkpoints":"every complete published pass uses the same filter"
|
||||
});
|
||||
}
|
||||
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
|
||||
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
|
||||
Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
|
||||
}
|
||||
|
||||
fn prepare_progressive(
|
||||
@@ -601,8 +460,6 @@ fn prepare_progressive(
|
||||
saved_hdr: Option<GpuHdrEnvironment>,
|
||||
saved_surfaces: Option<&solidcraft_doc::render_surface::SurfaceBindings>,
|
||||
saved_optics: Option<&solidcraft_doc::render_optics::OpticalBindings>,
|
||||
cancel: &solidcraft_render::job::CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
let cpu_raytrace = params
|
||||
.get("raytrace")
|
||||
@@ -617,19 +474,6 @@ fn prepare_progressive(
|
||||
return Err(bad(command, "progressive rendering does not accept CPU raytrace, environment or LED options"));
|
||||
}
|
||||
let mut input: ProgressiveStart = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coats = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.coats));
|
||||
let inline_optics = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.optics));
|
||||
let normalized_optics = saved_optics.filter(|bindings| !bindings.is_empty());
|
||||
if normalized_optics.is_some() && !inline_optics.is_empty() {
|
||||
return Err(bad(command, "choose normalized or inline stable optics, not both"));
|
||||
}
|
||||
let saved_optics = normalized_optics.or((!inline_optics.is_empty()).then_some(&inline_optics));
|
||||
if let Some(bindings) = saved_optics {
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if !input.raytrace.dielectrics.is_empty() || !input.raytrace.clear_coats.is_empty() {
|
||||
return Err(bad(command, "saved optical bindings cannot be mixed with transient optical overrides"));
|
||||
}
|
||||
}
|
||||
validate_studio_weights(input.studio_weights, command)?;
|
||||
if input.studio_weights.is_some() && (input.lighting.is_some() || input.environment.is_some() || saved_hdr.is_some()) {
|
||||
return Err(bad(command, "studio_weights require the implicit GPU studio without environment lighting"));
|
||||
@@ -668,11 +512,7 @@ fn prepare_progressive(
|
||||
}
|
||||
input.raytrace.dielectrics = super::optical_bindings::resolve(s, &scene, bindings, command)?;
|
||||
}
|
||||
if !coats.is_empty() && !input.raytrace.clear_coats.is_empty() {
|
||||
return Err(bad(command, "stable clear coats cannot combine with runtime clear-coat overrides"));
|
||||
}
|
||||
super::transmission::apply(&mut scene, &input.raytrace.dielectrics, command)?;
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, None, command)?;
|
||||
super::clear_coat::apply(&mut scene, &input.raytrace.clear_coats, command)?;
|
||||
let settings = if let Some(name) = &input.setup {
|
||||
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
|
||||
@@ -716,7 +556,6 @@ fn prepare_progressive(
|
||||
options.aging = super::effects::resolve_aging(s, &scene, &input.aging, command)?;
|
||||
options.nested_media = input.nested_media;
|
||||
options.volume = input.volume;
|
||||
options.caustics = input.caustics;
|
||||
options.adaptive = input.adaptive;
|
||||
options.transmission_samples = input.transmission_samples.unwrap_or(1);
|
||||
options.validate().map_err(job_error)?;
|
||||
@@ -796,22 +635,15 @@ fn prepare_progressive(
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
|
||||
};
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
|
||||
let mut admission = host.admit(&scene, &request, options).map_err(job_error)?;
|
||||
admission.estimated_total_bytes = admission
|
||||
.estimated_total_bytes
|
||||
.checked_add(request.retained_resource_metadata_bytes())
|
||||
.and_then(|bytes| bytes.checked_add(retained_options_bytes))
|
||||
.filter(|bytes| *bytes <= host.job_limit_bytes())
|
||||
.ok_or_else(|| bad(command, "GPU work and retained metadata exceed the per-job limit"))?;
|
||||
let reply = json!({"stage_panels":settings.stage.len(),"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.transparent_background {"coverage_alpha"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"retained_options_bytes":retained_options_bytes,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let reply = json!({"stage_panels":settings.stage.len(),"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
|
||||
let mut reply = reply;
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if options.denoise {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
|
||||
@@ -821,7 +653,7 @@ fn prepare_progressive(
|
||||
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
|
||||
});
|
||||
}
|
||||
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
|
||||
Ok(Prepared { replace: input.replace, scene, request, reply, executor: resolved.executor, options, environment: None })
|
||||
}
|
||||
|
||||
/// The setup's controls the raster image executor doesn't apply: those not at their neutral value.
|
||||
@@ -840,18 +672,24 @@ fn ignored_controls(settings: &RenderSetup) -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
prepare(s, params, "render.preflight", &solidcraft_render::job::CancelToken::default()).map(|p| p.reply)
|
||||
prepare(s, params, "render.preflight").map(|p| p.reply)
|
||||
}
|
||||
|
||||
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let p = prepare(s, params, "render.start", &solidcraft_render::job::CancelToken::default())?;
|
||||
let p = prepare(s, params, "render.start")?;
|
||||
let status = s
|
||||
.render_jobs
|
||||
.start_prepared(
|
||||
.start_with_environment(
|
||||
s.revision,
|
||||
p.submission.ok_or_else(|| bad("render.start", "missing prepared submission"))?,
|
||||
p.scene,
|
||||
p.request,
|
||||
p.replace,
|
||||
params.get("setup").and_then(Value::as_str).map(str::to_owned),
|
||||
crate::render_jobs::Execution {
|
||||
executor: p.executor,
|
||||
options: p.options,
|
||||
environment: p.environment,
|
||||
shot: params.get("setup").and_then(Value::as_str).map(str::to_owned),
|
||||
},
|
||||
)
|
||||
.map_err(job_error)?;
|
||||
let mut status = json!(status);
|
||||
@@ -926,18 +764,9 @@ pub(super) fn copy(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
Ok(json!({"frame":frame.info}))
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?;
|
||||
Ok(input.job)
|
||||
}
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let requested = cancel_params(params)?;
|
||||
if requested.is_none() && s.cancel_native_preparation() {
|
||||
return Ok(json!({"preparing":true,"cancel_requested":true,"queue":s.render_queue_state()}));
|
||||
}
|
||||
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
|
||||
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?;
|
||||
let job = input.job.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
|
||||
let status = s.render_jobs.status(job).map_err(job_error)?;
|
||||
s.cancel_render_batch(job);
|
||||
if status.progress.phase.terminal() {
|
||||
|
||||
@@ -9,17 +9,17 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_resources::{CpuHdrEnvironment, GpuHdrEnvironment, HdrId, HdrResource},
|
||||
render_settings::{CpuJob, RenderJob, RenderSetup},
|
||||
render_settings::RenderJob,
|
||||
};
|
||||
use solidcraft_render::{
|
||||
job::{AreaLight, AreaLights, RectangleLightOptions, StudioLighting, Sun},
|
||||
job::{AreaLight, AreaLights, StudioLighting, Sun},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{
|
||||
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds, StableRenderTarget,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds,
|
||||
};
|
||||
use crate::{Result, Session, params::bad};
|
||||
|
||||
@@ -98,7 +98,7 @@ fn projection(kind: &str, value: &Value) -> Value {
|
||||
"native_scenes" => json!({"status":"native_composition","executor":"gpu-path","variants":["review_then_apply"],
|
||||
"preserves":["camera","materials","geometry","engineering_properties"],"requires_subject_and_explicit_replacement_consent":true}),
|
||||
"surfaces" => {
|
||||
json!({"status":"implemented_pending_batch_validation","variants":if cfg!(feature="bundled-render-library") {vec!["uniform_opaque","gpu_planar_maps","cpu_saved_planar_maps"]} else {vec!["uniform_opaque"]},
|
||||
json!({"status":"implemented_pending_batch_validation","variants":if cfg!(feature="bundled-render-library") {vec!["uniform_opaque","gpu_planar_maps"]} else {vec!["uniform_opaque"]},
|
||||
"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],"maps_portability":"version_and_digest_pinned_builtin_setup_bindings"})
|
||||
}
|
||||
"selene_appearances" => json!({"status":"implemented_pending_batch_validation","variants":["uniform_opaque"],
|
||||
@@ -227,12 +227,6 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
let query = input.query.to_lowercase();
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let mut stable = BTreeMap::new();
|
||||
for (target, name) in super::surface_bindings::targets(s, command)? {
|
||||
if stable.insert(name, target).is_some() {
|
||||
return Err(bad(command, "ambiguous stable occurrence name"));
|
||||
}
|
||||
}
|
||||
let count = scene.meshes.iter().filter(|body| body.name.to_lowercase().contains(&query)).count();
|
||||
let items: Vec<_> = scene
|
||||
.meshes
|
||||
@@ -244,7 +238,6 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
.map(|(ordinal, body)| RenderTarget {
|
||||
ordinal,
|
||||
name: body.name.clone(),
|
||||
stable_target: stable.get(&body.name).map(StableRenderTarget::from),
|
||||
color: body.color,
|
||||
opacity: body.opacity,
|
||||
pbr: body.surface.map(|v| Pbr { metallic: v.metallic(), roughness: v.roughness(), dielectric_ior: v.dielectric_ior() }),
|
||||
@@ -442,7 +435,7 @@ struct EnvironmentApply {
|
||||
|
||||
fn has_environment(setup: &solidcraft_doc::render_settings::RenderSetup) -> bool {
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => options.hdr.is_some() || options.environment.is_some() || options.sky.is_some(),
|
||||
Some(RenderJob::CpuRaytrace { options }) => options.hdr.is_some() || options.environment.is_some(),
|
||||
Some(RenderJob::GpuPath { options, .. }) => options.hdr.is_some() || options.environment.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -459,13 +452,10 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
|
||||
let rotation = hdr_rotation(input.rotation_rad, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if !input.replace_environment && (options.hdr.is_some() || options.environment.is_some() || options.sky.is_some()) {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if !input.replace_environment && (options.hdr.is_some() || options.environment.is_some()) {
|
||||
return Err(bad(command, "existing environment requires replace_environment: true"));
|
||||
}
|
||||
if metadata["solar_disk"] == true && options.sun.is_some() {
|
||||
return Err(bad(command, "the selected HDR already owns the sun; remove the analytic sun explicitly"));
|
||||
}
|
||||
CpuHdrEnvironment::check_controls(rotation, input.intensity, input.samples).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
@@ -484,9 +474,8 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
|
||||
let digest = resource.id();
|
||||
settings.resources.insert(resource).map_err(|e| bad(command, e.to_string()))?;
|
||||
match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
options.environment = None;
|
||||
options.sky = None;
|
||||
options.hdr = Some(CpuHdrEnvironment { resource: digest, rotation_rad: rotation, intensity: input.intensity, samples: input.samples });
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
@@ -567,9 +556,7 @@ pub(super) fn prepare_surface(s: &mut Session, params: &Value) -> Result<Value>
|
||||
"base_color_srgb":format!("builtin://{}/base_color",input.id),"normal_opengl":format!("builtin://{}/normal_open_gl",input.id),
|
||||
"roughness_linear":format!("builtin://{}/roughness",input.id),"metallic_linear":format!("builtin://{}/metallic",input.id)},
|
||||
"source_maps":manifest["maps"],"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],
|
||||
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in",
|
||||
"compatible_saved_methods":if cfg!(feature="bundled-render-library") {vec!["cpu_raytrace","gpu_path"]} else {Vec::<&str>::new()},
|
||||
"cpu_maps":"saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse"}))
|
||||
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in"}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -633,13 +620,7 @@ pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLig
|
||||
_ => return Err(bad(command, "authored light has no exact runtime adapter")),
|
||||
}
|
||||
}
|
||||
let lighting = StudioLighting {
|
||||
punctual_lights: Default::default(),
|
||||
directional_lights: Default::default(),
|
||||
environment: [0.0; 3],
|
||||
sun,
|
||||
area_lights: AreaLights::new(&areas).map_err(|e| bad(command, e.to_string()))?,
|
||||
};
|
||||
let lighting = StudioLighting { environment: [0.0; 3], sun, area_lights: AreaLights::new(&areas).map_err(|e| bad(command, e.to_string()))? };
|
||||
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok((
|
||||
lighting,
|
||||
@@ -703,106 +684,12 @@ pub(super) fn rig_clearance(scene: &solidcraft_render::ProductScene, lighting: &
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Exact typed bridge for the currently authored achromatic area/Sun rigs.
|
||||
/// This changes lighting authority only, retaining dormant CPU sampling fields.
|
||||
pub(super) fn cpu_studio_lighting(options: &mut CpuJob, lighting: &StudioLighting, command: &str) -> Result<bool> {
|
||||
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if lighting.environment != [0.0; 3] || !lighting.directional_lights.is_empty() || !lighting.punctual_lights.is_empty() {
|
||||
return Err(bad(command, "authored CPU studio projection requires its explicit area/Sun rig and separate environment authority"));
|
||||
}
|
||||
let mut areas = Vec::new();
|
||||
areas.try_reserve_exact(lighting.area_lights.iter().count()).map_err(|_| bad(command, "could not reserve CPU studio lights"))?;
|
||||
for (index, light) in lighting.area_lights.iter().enumerate() {
|
||||
let [r, g, b] = light.radiance;
|
||||
if r.to_bits() != g.to_bits() || r.to_bits() != b.to_bits() {
|
||||
return Err(bad(
|
||||
command,
|
||||
format!("authored rectangle {index} needs a lossless CPU colour/intensity projection; this rig is not supported"),
|
||||
));
|
||||
}
|
||||
if !(0.0..=64.0).contains(&r) {
|
||||
return Err(bad(
|
||||
command,
|
||||
format!(
|
||||
"authored rectangle {index} radiance {r} exceeds CPU area intensity 0..64; this rig is unavailable without changing its authored light"
|
||||
),
|
||||
));
|
||||
}
|
||||
areas.push(RectangleLightOptions {
|
||||
center_mm: light.center,
|
||||
edge_u_mm: light.u.map(|value| value * 2.0),
|
||||
edge_v_mm: light.v.map(|value| value * 2.0),
|
||||
color: [1.0; 3],
|
||||
intensity: r,
|
||||
});
|
||||
}
|
||||
let needs_indirect = lighting.sun.is_some() && options.path.is_none();
|
||||
options.preset = None;
|
||||
options.lights = Some(Vec::new());
|
||||
options.punctual_lights = Some(Vec::new());
|
||||
options.area_lights = Some(areas);
|
||||
options.sun = lighting.sun;
|
||||
options.environment = None;
|
||||
options.sky = None;
|
||||
options.hdr = None;
|
||||
Ok(needs_indirect)
|
||||
}
|
||||
fn adopt_studio_indirect(setup: &mut RenderSetup) {
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job {
|
||||
options.path.get_or_insert_default();
|
||||
options.progressive.get_or_insert_default();
|
||||
options.primary_samples = 1;
|
||||
options.shadows = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StudioPrepare {
|
||||
id: String,
|
||||
variant: String,
|
||||
center_mm: [f64; 3],
|
||||
subject_extent_mm: f64,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default = "empty_patch")]
|
||||
set: Value,
|
||||
#[serde(default)]
|
||||
enable_indirect: bool,
|
||||
}
|
||||
pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
pub(super) fn prepare_studio(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.studio.prepare";
|
||||
let input: StudioPrepare = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let rig = RigInput { id: input.id, variant: input.variant, center_mm: input.center_mm, subject_extent_mm: input.subject_extent_mm };
|
||||
let (lighting, projection) = rig_lighting(&rig, command)?;
|
||||
let stage = rig_stage(&rig, command)?;
|
||||
let mut report =
|
||||
json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false});
|
||||
if let Some(name) = input.name {
|
||||
let mut setup = super::patched_settings(s, &name, &input.set, command)?;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
|
||||
return Err(bad(command, "typed rig context requires a selected CPU raytrace setup"));
|
||||
};
|
||||
let needs_indirect = cpu_studio_lighting(options, &lighting, command)?;
|
||||
// Resolve the complete prospective adoption without publishing it. Apply
|
||||
// still requires the separate explicit action below, at current revision.
|
||||
if needs_indirect {
|
||||
adopt_studio_indirect(&mut setup);
|
||||
}
|
||||
setup.stage = stage;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
report["setup"] = json!(setup);
|
||||
report["executor"] = json!("cpu-raytrace");
|
||||
report["revision"] = json!(s.revision);
|
||||
report["requires_indirect"] = json!(needs_indirect);
|
||||
report["indirect_adoption"] = json!(
|
||||
needs_indirect
|
||||
.then_some(json!({"path_depth":8,"progressive_if_absent":{"samples_per_pixel":16,"seed":0},"primary_samples":1,"shadows":true}))
|
||||
);
|
||||
} else if input.enable_indirect || input.set != json!({}) {
|
||||
return Err(bad(command, "CPU rig context needs name and typed set"));
|
||||
}
|
||||
Ok(report)
|
||||
let input: RigInput = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let (lighting, projection) = rig_lighting(&input, command)?;
|
||||
let stage = rig_stage(&input, command)?;
|
||||
Ok(json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -815,10 +702,6 @@ struct StudioApply {
|
||||
replace_lighting_and_environment: bool,
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
#[serde(default)]
|
||||
enable_indirect: bool,
|
||||
#[serde(default)]
|
||||
expected_revision: Option<u64>,
|
||||
}
|
||||
pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.studio.apply";
|
||||
@@ -826,35 +709,22 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !input.replace_lighting_and_environment {
|
||||
return Err(bad(command, "confirm replacing lighting and environment explicitly to preserve a single energy owner"));
|
||||
}
|
||||
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
|
||||
return Err(bad(command, "scene or saved settings changed after rig review"));
|
||||
}
|
||||
if !input.replace_stage && s.doc.render.setups.get(&input.name).is_some_and(|setup| !setup.stage.is_empty()) {
|
||||
return Err(bad(command, "existing stage requires replace_stage: true"));
|
||||
}
|
||||
let (lighting, projection) = rig_lighting(&input.rig, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &mut setup.job else {
|
||||
return Err(bad(command, "authored area/sun rigs require an explicit GPU job"));
|
||||
};
|
||||
let stage = rig_stage(&input.rig, command)?;
|
||||
if !setup.stage.is_empty() && !input.replace_stage {
|
||||
return Err(bad(command, "existing stage requires replace_stage: true"));
|
||||
}
|
||||
setup.stage = stage;
|
||||
let needs_indirect = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => cpu_studio_lighting(options, &lighting, command)?,
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
options.lighting = Some(lighting);
|
||||
options.environment = None;
|
||||
options.hdr = None;
|
||||
false
|
||||
}
|
||||
_ => return Err(bad(command, "authored rigs require an explicit CPU or GPU raytrace setup")),
|
||||
};
|
||||
if needs_indirect {
|
||||
if !input.enable_indirect {
|
||||
return Err(bad(command, "this CPU Sun rig requires explicit Enable indirect lighting and apply consent"));
|
||||
}
|
||||
adopt_studio_indirect(&mut setup);
|
||||
}
|
||||
options.lighting = Some(lighting);
|
||||
options.environment = None;
|
||||
options.hdr = None;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
super::staging::apply(&mut scene, &setup, command)?;
|
||||
|
||||
@@ -209,7 +209,7 @@ fn builtin_hdr_capture_is_atomic_undoable_deduplicated_and_path_independent() {
|
||||
assert_eq!(reply["source_sha256"], reply["resource"]);
|
||||
assert_eq!(reply["native_rotation_rad"], std::f64::consts::PI);
|
||||
assert_eq!(reply["self_contained"], true);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Library CPU"].job else {
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &s.doc.render.setups["Library CPU"].job else {
|
||||
panic!("CPU intent");
|
||||
};
|
||||
let id = options.hdr.unwrap().resource;
|
||||
@@ -526,10 +526,6 @@ fn saved_surface_rejects_changed_feature_and_hidden_target_and_can_be_removed_of
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"raster"}}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
let bindings = s.doc.render.setups["P"].surfaces.clone();
|
||||
s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"cpu_raytrace","options":{}}}})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["P"].surfaces, bindings, "supported CPU method changes preserve saved bindings, even offline");
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -637,266 +633,3 @@ fn omitted_optional_pack_keeps_discovery_but_refuses_resource_mutation() {
|
||||
assert_eq!(session.doc, before);
|
||||
assert_eq!((session.revision, session.undo.len()), history);
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_saved_surface_binding_is_offline_undoable_portable_and_preserves_geometry_and_unsupported_intent() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Coupon"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let camera = solidcraft_render::Camera::default();
|
||||
let set = json!({"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":9},"output":{"linear_exr":true}}}});
|
||||
let input = json!({"name":"CPU maps","set":set,"surface":{"id":"SURF-001","body_index":0,"body_name":"Coupon"}});
|
||||
assert!(s.render_job_host().is_none());
|
||||
s.execute("render.library.surface.bind", &input).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(s.doc.appearances, before.appearances);
|
||||
let setup = s.doc.render.setups["CPU maps"].clone();
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
assert_eq!(setup.camera, camera);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
let snapshot = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
for options in [
|
||||
json!({"reflections":{"bodies":[{"body":"Coupon","mode":"mirror"}],"samples":1,"max_bounces":2}}),
|
||||
json!({"budget":"studio","progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":2},"shadows":false}),
|
||||
] {
|
||||
let mut hostile = input.clone();
|
||||
hostile["name"] = json!("Unsupported");
|
||||
hostile["set"]["job"]["options"] = options;
|
||||
assert!(s.execute("render.library.surface.bind", &hostile).is_err());
|
||||
assert!(Arc::ptr_eq(&s.doc, &snapshot));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
let mut stale = setup.clone();
|
||||
let mut binding = stale.surfaces.iter().next().unwrap().clone();
|
||||
let solidcraft_doc::render_surface::SurfaceSource::Builtin(source) = &mut binding.source else { panic!("built-in fixture") };
|
||||
source.normal_sha256 = HdrId::of(b"changed");
|
||||
stale.surfaces.set(binding, true).unwrap();
|
||||
assert!(super::super::surface_bindings::resolve(&s, &crate::product_scene::extract(&s).unwrap(), &stale.surfaces, "test").is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU maps"], setup);
|
||||
s.execute("render.library.surface.unbind", &json!({"name":"CPU maps","target":setup.surfaces.iter().next().unwrap().target})).unwrap();
|
||||
assert!(s.doc.render.setups["CPU maps"].surfaces.is_empty());
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_path_maps_bind_beside_saved_glass_and_targeted_aging_without_weakening_target_or_legacy_guards() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Glass", [0, 0, 0]), ("Panel", [20, 0, 0])] {
|
||||
s.execute("solid.box", &json!({"body_name":name,"length":10,"width":10,"height":5,"corner":corner,"operation":"new"})).unwrap();
|
||||
}
|
||||
let targets = s.execute("render.library.targets", &json!({})).unwrap();
|
||||
let glass = targets["items"].as_array().unwrap().iter().find(|target| target["name"] == "Glass").unwrap().clone();
|
||||
let panel = targets["items"].as_array().unwrap().iter().find(|target| target["name"] == "Panel").unwrap().clone();
|
||||
assert_eq!(panel["stable_target"]["definition"], "Panel");
|
||||
assert_ne!(glass["stable_target"], panel["stable_target"]);
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Mixed","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
|
||||
"progressive":{"samples_per_pixel":4,"seed":9},"path":{"max_depth":4},
|
||||
"aging":[{"kind":"dust","target":panel["stable_target"]}]
|
||||
}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"Mixed","body_index":glass["ordinal"],"body_name":"Glass","expected_revision":s.revision,
|
||||
"optics":{"ior":1.5,"boundary":"closed_mesh"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
assert!(s.render_job_host().is_none(), "authoring does not require an image producer");
|
||||
let input = |target: &Value| json!({"name":"Mixed","surface":{"id":"SURF-001","body_index":target["ordinal"],"body_name":target["name"]}});
|
||||
let error = s.execute("render.library.surface.bind", &input(&glass)).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot share a target"), "{error}");
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
let mut legacy = input(&panel);
|
||||
legacy["set"] = json!({"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":4,"seed":9}}}});
|
||||
assert!(s.execute("render.library.surface.bind", &legacy).unwrap_err().to_string().contains("require Indirect lighting"));
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
s.execute("render.library.surface.bind", &input(&panel)).unwrap();
|
||||
assert_eq!(s.undo.len(), history.1 + 1);
|
||||
let saved = s.doc.clone();
|
||||
let setup = &saved.render.setups["Mixed"];
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
assert_eq!(setup.optics, before.render.setups["Mixed"].optics);
|
||||
let solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. } = setup.job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
assert!(options.path.is_some());
|
||||
assert_eq!(options.aging.iter().next().unwrap().target.as_ref().unwrap(), &setup.surfaces.iter().next().unwrap().target);
|
||||
assert_eq!(saved.features, before.features);
|
||||
assert_eq!(saved.materials, before.materials);
|
||||
assert_eq!(saved.appearances, before.appearances);
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&saved.to_json()).unwrap(), *saved);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_authored_rig_domain_is_exact_and_preserves_dormant_sampling_without_implicit_lights() {
|
||||
use solidcraft_doc::render_settings::CpuJob;
|
||||
let mut s = Session::default();
|
||||
let mut accepted = Vec::new();
|
||||
let mut over_domain = Vec::new();
|
||||
for index in 1..=24 {
|
||||
let id = format!("RIG-{index:03}");
|
||||
let rig = json!({"id":id,"variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
if s.execute("render.library.studio.prepare", &rig).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
prepare["set"] = json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"area_samples":64,"seed":731}}});
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
let result = s.execute("render.library.studio.prepare", &prepare);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, s.undo.len());
|
||||
match result {
|
||||
Ok(plan) => {
|
||||
accepted.push(id.clone());
|
||||
let options: CpuJob = serde_json::from_value(plan["setup"]["job"]["options"].clone()).unwrap();
|
||||
assert_eq!(options.lights, Some(vec![]));
|
||||
assert_eq!(options.punctual_lights, Some(vec![]));
|
||||
assert_eq!((options.area_samples, options.seed), (Some(64), Some(731)));
|
||||
assert!(options.environment.is_none() && options.sky.is_none() && options.hdr.is_none());
|
||||
assert_eq!(plan["requires_indirect"], id == "RIG-020");
|
||||
}
|
||||
Err(error) => {
|
||||
assert!(error.to_string().contains("exceeds CPU area intensity 0..64"), "{id}: {error}");
|
||||
over_domain.push(id);
|
||||
let err = s
|
||||
.execute(
|
||||
"render.library.studio.apply",
|
||||
&json!({"rig":rig,"name":"Rig","set":prepare["set"],"replace_lighting_and_environment":true}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("exceeds CPU area intensity 0..64"));
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, s.undo.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, vec!["RIG-007", "RIG-020", "RIG-022"]);
|
||||
assert_eq!(over_domain.len(), 16);
|
||||
let mut options = CpuJob { area_samples: Some(64), seed: Some(731), ..Default::default() };
|
||||
let before = options.clone();
|
||||
let light = AreaLight { center: [0., 0., 10.], u: [1., 0., 0.], v: [0., -1., 0.], radiance: [1., 0.5, 0.25] };
|
||||
let lighting = StudioLighting { area_lights: AreaLights::new(&[light]).unwrap(), ..Default::default() };
|
||||
assert!(cpu_studio_lighting(&mut options, &lighting, "test").unwrap_err().to_string().contains("lossless CPU colour/intensity projection"));
|
||||
assert_eq!(options, before, "no lossy colour decomposition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_rig_adoption_is_explicit_complete_atomic_and_one_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("render.settings.set",&json!({"name":"Rig","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"primary_samples":4,"shadows":false,"area_samples":64,"seed":731}}}})).unwrap();
|
||||
let rig = json!({"id":"RIG-020","variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let plan = s.execute("render.library.studio.prepare", &prepare).unwrap();
|
||||
assert_eq!(plan["requires_indirect"], true);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
let mut apply = json!({"name":"Rig","rig":rig,"replace_lighting_and_environment":true,"expected_revision":s.revision});
|
||||
assert!(s.execute("render.library.studio.apply", &apply).unwrap_err().to_string().contains("Enable indirect lighting"));
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
apply["enable_indirect"] = json!(true);
|
||||
s.execute("render.library.studio.apply", &apply).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Rig"].job else { panic!() };
|
||||
assert_eq!(options.path.unwrap().max_depth, 8);
|
||||
assert_eq!(options.progressive.unwrap().samples_per_pixel, 16);
|
||||
assert_eq!((options.primary_samples, options.shadows, options.area_samples, options.seed), (1, true, Some(64), Some(731)));
|
||||
assert!(options.sun.is_some());
|
||||
assert_eq!(options.area_lights, Some(vec![]));
|
||||
let saved = s.doc.to_json();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("render.library.studio.apply", &apply).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
let mut large = Session::default();
|
||||
let fixed = json!({"width":2048,"height":2048,"job":{"method":"cpu_raytrace","options":{}}});
|
||||
// This is an admitted fixed Compact image. Only explicit adoption of the
|
||||
// complete progressive16 Sun request crosses Compact's1,048,576-pixel bound.
|
||||
// Upstream's shared setup refusal names the admitting Studio profile rather
|
||||
// than repeating the current Compact name, so assert the actual size proof.
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
let adopted = Some(CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
|
||||
assert!(CpuBudget::Compact.validate_image(2048, 2048, None).is_ok());
|
||||
assert_eq!(CpuBudget::Compact.limits().max_progressive_pixels, 1_048_576);
|
||||
assert!(CpuBudget::Compact.validate_image(2048, 2048, adopted).is_err());
|
||||
assert!(CpuBudget::Studio.validate_image(2048, 2048, adopted).is_ok());
|
||||
let authored = super::super::patched_settings(&large, "Rig", &fixed, "render.library.studio.prepare").unwrap();
|
||||
assert_eq!((authored.width, authored.height), (2048, 2048));
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &authored.job else { panic!("typed fixed CPU setup") };
|
||||
assert_eq!(options.budget, CpuBudget::Compact);
|
||||
assert!(options.progressive.is_none() && options.path.is_none());
|
||||
let mut oversized = prepare;
|
||||
oversized["set"] = fixed.clone();
|
||||
let before = large.doc.clone();
|
||||
let state = (large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera);
|
||||
let check_size_error = |error: String| {
|
||||
assert!(error.contains("render output 2048 × 2048 exceeds the image limits"), "{error}");
|
||||
assert!(error.contains("1048576 pixels in all"), "{error}");
|
||||
assert!(error.contains("largest image at this aspect is 1024 × 1024"), "{error}");
|
||||
assert!(error.contains("choose the Studio CPU resource profile, or a smaller size"), "{error}");
|
||||
eprintln!("complete CPU Sun adoption refused without mutation: {error}");
|
||||
};
|
||||
check_size_error(large.execute("render.library.studio.prepare", &oversized).unwrap_err().to_string());
|
||||
assert!(Arc::ptr_eq(&before, &large.doc));
|
||||
assert_eq!((large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera), state);
|
||||
let mut oversized = apply;
|
||||
oversized.as_object_mut().unwrap().remove("expected_revision");
|
||||
oversized["set"] = fixed;
|
||||
check_size_error(large.execute("render.library.studio.apply", &oversized).unwrap_err().to_string());
|
||||
assert!(Arc::ptr_eq(&before, &large.doc));
|
||||
assert_eq!((large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera), state);
|
||||
assert!(large.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_rig_keeps_existing_path_adaptive_seed_and_profile_and_rejects_unknown_context() {
|
||||
let mut s = Session::default();
|
||||
let options = json!({"budget":"studio","progressive":{"samples_per_pixel":64,"seed":947},"adaptive":{"pilot_samples":32,"min_samples":32},"path":{"max_depth":5},"area_samples":4,"seed":731});
|
||||
s.execute("render.settings.set", &json!({"name":"Rig","set":{"width":96,"height":64,"job":{"method":"cpu_raytrace","options":options}}}))
|
||||
.unwrap();
|
||||
let rig = json!({"id":"RIG-020","variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
let plan = s.execute("render.library.studio.prepare", &prepare).unwrap();
|
||||
assert_eq!(plan["requires_indirect"], false);
|
||||
let old = s.doc.render.setups["Rig"].clone();
|
||||
s.execute("render.library.studio.apply", &json!({"name":"Rig","rig":rig,"replace_lighting_and_environment":true})).unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options: before, .. }) = old.job else { panic!() };
|
||||
let Some(RenderJob::CpuRaytrace { options: after, .. }) = &s.doc.render.setups["Rig"].job else { panic!() };
|
||||
assert_eq!(
|
||||
(after.budget, after.progressive, after.adaptive, after.path, after.area_samples, after.seed),
|
||||
(before.budget, before.progressive, before.adaptive, before.path, before.area_samples, before.seed)
|
||||
);
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
prepare["extra"] = json!(true);
|
||||
assert!(s.execute("render.library.studio.prepare", &prepare).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
@@ -39,15 +39,7 @@ fn normalized_twenty_source_preflight_has_one_metadata_authority_and_no_reservat
|
||||
assert_eq!(options.primary_samples, 16);
|
||||
assert_eq!(options.area_lights.as_ref().unwrap().len(), 4);
|
||||
assert_eq!(options.punctual_lights.as_ref().unwrap().len(), 8);
|
||||
assert_eq!(
|
||||
reply["retained_request_bytes"],
|
||||
json!(
|
||||
options.retained_bytes().unwrap()
|
||||
+ serde_json::from_value::<solidcraft_render::job::ImageRequest>(reply["request"].clone())
|
||||
.unwrap()
|
||||
.retained_resource_metadata_bytes()
|
||||
)
|
||||
);
|
||||
assert_eq!(reply["retained_request_bytes"], json!(options.retained_bytes().unwrap()));
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert_eq!(input, unchanged);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
|
||||
@@ -84,63 +76,3 @@ fn invalid_or_unavailable_union_preserves_stale_scene_before_capture_or_replacem
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_finite_sun_common_edit_keeps_raw_radiance_identity_and_undo() {
|
||||
let mut s = Session::default();
|
||||
let sun = json!({"direction":[0.2,-0.3,0.9],"angular_radius_rad":0.02,"radiance":[301.25,202.5,103.75]});
|
||||
s.execute("render.settings.set",&json!({"name":"CPU Sun","set":{"width":16,"height":12,"job":{"method":"cpu_raytrace","options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":3,"seed":81},"sun":sun,"punctual_lights":[],"area_lights":[],"lights":[{"direction":[0,0,1],"color":[1,0.5,0.25],"intensity":2}]}}}})).unwrap();
|
||||
let before = s.doc.render.setups["CPU Sun"].clone();
|
||||
let rows = crate::render_lights::inspect(&before).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].id, "directional:0");
|
||||
assert_eq!(rows[1].id, "directional:1");
|
||||
let mut exact = before.clone();
|
||||
crate::render_lights::write(&mut exact, &rows).unwrap();
|
||||
assert_eq!(exact, before);
|
||||
let undo = s.undo.len();
|
||||
s.execute("render.lights.set", &json!({"name":"CPU Sun","id":"directional:1","set":{"intensity_ev":2.0}})).unwrap();
|
||||
let changed = s.doc.render.setups["CPU Sun"].clone();
|
||||
assert_eq!(serde_json::to_value(&changed).unwrap()["job"]["options"]["sun"], sun);
|
||||
let edited = crate::render_lights::inspect(&changed).unwrap();
|
||||
assert_eq!((edited[1].intensity, edited[1].colour), (4.0, [1.0, 0.5, 0.25]));
|
||||
assert_eq!(edited[1].geometry, rows[1].geometry);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU Sun"], before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU Sun"], changed);
|
||||
let document = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
let mut without_path = serde_json::to_value(&changed).unwrap();
|
||||
without_path["job"]["options"]["path"] = Value::Null;
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"CPU Sun","set":without_path})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
|
||||
// Radius zero retains the pre-existing hard-source representation without Path.
|
||||
s.execute("render.settings.set",&json!({"name":"CPU hard","set":{"job":{"method":"cpu_raytrace","options":{"lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":1}]}}}})).unwrap();
|
||||
let hard = crate::render_lights::inspect(&s.doc.render.setups["CPU hard"]).unwrap();
|
||||
assert!(matches!(hard[0].geometry, crate::schema::render_lights::Geometry::Directional { angular_radius_rad: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_add_remove_returns_actual_canonical_addresses_and_preserves_other_sources() {
|
||||
let mut s = Session::default();
|
||||
s.execute("render.settings.set",&json!({"name":"CPU rig","set":{"width":16,"height":12,"job":{"method":"cpu_raytrace","options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":3,"seed":81},"punctual_lights":[],"area_lights":[],"lights":[{"direction":[0,0,1],"color":[1,0.5,0.25],"intensity":2}]}}}})).unwrap();
|
||||
let before = s.doc.render.setups["CPU rig"].clone();
|
||||
let reply = s.execute("render.lights.add", &json!({"name":"CPU rig","set":{"kind":"directional","softness":0.1}})).unwrap();
|
||||
assert_eq!(reply["id"], "directional:0");
|
||||
let rows = crate::render_lights::inspect(&s.doc.render.setups["CPU rig"]).unwrap();
|
||||
assert_eq!(rows[1].id, "directional:1");
|
||||
assert_eq!(reply["id_remap"][0]["after_id"], "directional:1");
|
||||
let mut filtered = s.doc.render.setups["CPU rig"].clone();
|
||||
let visibility: crate::schema::render_lights::Visibility = serde_json::from_value(json!({"muted":["directional:0"]})).unwrap();
|
||||
crate::render_lights::filter(&mut filtered, &visibility).unwrap();
|
||||
let Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. }) = filtered.job else { panic!("CPU expected") };
|
||||
assert_eq!(options.sun.unwrap().radiance, [0.0; 3]);
|
||||
assert_eq!(options.lights.as_ref().unwrap()[0].intensity, 2.0);
|
||||
s.execute("render.lights.remove", &json!({"name":"CPU rig","id":"directional:0"})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU rig"], before);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light, LightIdRemap};
|
||||
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
|
||||
use crate::schema::render_lights::{ListParams, ListResult};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,33 +35,6 @@ pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn canonical_id(setup: &RenderSetup, lights: &[Light], index: usize) -> Option<String> {
|
||||
lights.get(index).map(|light| {
|
||||
let kind = light.geometry.kind();
|
||||
let finite_directional = matches!(
|
||||
setup.job,
|
||||
Some(solidcraft_doc::render_settings::RenderJob::GpuPath { .. } | solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. })
|
||||
) && kind == "directional";
|
||||
let slot = if finite_directional {
|
||||
if matches!(light.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. }) {
|
||||
0
|
||||
} else {
|
||||
usize::from(lights.iter().any(|l| matches!(l.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. })))
|
||||
+ lights.iter().take(index).filter(|l| matches!(l.geometry, Geometry::Directional { angular_radius_rad: None, .. })).count()
|
||||
}
|
||||
} else {
|
||||
lights
|
||||
.iter()
|
||||
.take(index)
|
||||
.filter(|prior| {
|
||||
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
|
||||
})
|
||||
.count()
|
||||
};
|
||||
format!("{kind}:{slot}")
|
||||
})
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
let command = format!("render.lights.{operation}");
|
||||
if !params.is_object() {
|
||||
@@ -78,7 +51,6 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
None => s.doc.render.setups.get(&input.name).cloned().unwrap_or_default(),
|
||||
};
|
||||
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
|
||||
let original_lights = lights.clone();
|
||||
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
|
||||
let mut selected = None;
|
||||
match operation {
|
||||
@@ -118,15 +90,13 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
if input.set.is_some() {
|
||||
return Err(bad(&command, "duplicate does not accept set"));
|
||||
}
|
||||
let mut light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
|
||||
// A new source, even when every numerical value equals its parent.
|
||||
light.id.clear();
|
||||
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
|
||||
lights.push(light);
|
||||
selected = lights.len().checked_sub(1);
|
||||
} else {
|
||||
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
|
||||
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
|
||||
if matches!(&setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. }) if options.path.is_none())
|
||||
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
|
||||
&& light.geometry.kind() == "directional"
|
||||
&& patch.softness.is_some()
|
||||
{
|
||||
@@ -138,17 +108,19 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(&command, "unknown light operation")),
|
||||
}
|
||||
let id = selected.and_then(|index| canonical_id(&setup, &lights, index));
|
||||
let id_remap = lights
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, light)| {
|
||||
original_lights
|
||||
let id = selected.and_then(|index| {
|
||||
lights.get(index).map(|light| {
|
||||
let kind = light.geometry.kind();
|
||||
let slot = lights
|
||||
.iter()
|
||||
.find(|before| !light.id.is_empty() && before.id == light.id)
|
||||
.and_then(|before| canonical_id(&setup, &lights, index).map(|after_id| LightIdRemap { before: before.clone(), after_id }))
|
||||
.take(index)
|
||||
.filter(|prior| {
|
||||
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
|
||||
})
|
||||
.count();
|
||||
format!("{kind}:{slot}")
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
@@ -156,7 +128,7 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
if *s.doc.render != settings {
|
||||
s.doc_mut().render = std::sync::Arc::new(settings);
|
||||
}
|
||||
serde_json::to_value(EditResult { id, id_remap, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
|
||||
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
|
||||
.map_err(|e| bad(&command, e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
//! Stable, render-only optical assignments. Raw request overrides remain a
|
||||
//! separate mode; saved optics cannot silently disappear into another target.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_bindings::{OpticsBindParams, OpticsBindingSaved, OpticsUnbindParams, OpticsUnbound};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_optics::{OpticalBindings, OpticalMaterial, SavedOptics},
|
||||
render_settings::RenderJob,
|
||||
render_surface::RenderBodyTarget,
|
||||
};
|
||||
use solidcraft_render::ProductScene;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
fn resolve_each<T>(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
command: &str,
|
||||
convert: impl Fn(usize, &str, OpticalMaterial) -> Result<T>,
|
||||
) -> Result<Vec<T>> {
|
||||
pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &OpticalBindings, command: &str) -> Result<Vec<super::transmission::Override>> {
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
let visible: BTreeMap<_, _> = scene.meshes.iter().enumerate().map(|(i, b)| (b.name.as_str(), i)).collect();
|
||||
@@ -26,48 +20,32 @@ fn resolve_each<T>(
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved optical target changed; explicit rebinding required"))?;
|
||||
let index = visible.get(name.as_str()).ok_or_else(|| bad(command, "saved optical target is hidden or absent"))?;
|
||||
result.push(convert(*index, name, binding.optics)?);
|
||||
let mut value = serde_json::to_value(binding.optics).map_err(|e| bad(command, e.to_string()))?;
|
||||
value["body_index"] = json!(index);
|
||||
value["body_name"] = json!(name);
|
||||
result.push(serde_json::from_value(value).map_err(|e| bad(command, e.to_string()))?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &OpticalBindings, command: &str) -> Result<Vec<super::transmission::Override>> {
|
||||
resolve_each(s, scene, bindings, command, |index, name, optics| {
|
||||
let mut value = serde_json::to_value(optics).map_err(|e| bad(command, e.to_string()))?;
|
||||
value["body_index"] = json!(index);
|
||||
value["body_name"] = json!(name);
|
||||
serde_json::from_value(value).map_err(|e| bad(command, e.to_string()))
|
||||
})
|
||||
fn empty() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn resolve_cpu(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
command: &str,
|
||||
) -> Result<Vec<solidcraft_render::job::DielectricOptions>> {
|
||||
resolve_cpu_path(s, scene, bindings, false, command)
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Bind {
|
||||
name: String,
|
||||
#[serde(default = "empty")]
|
||||
set: Value,
|
||||
body_index: usize,
|
||||
body_name: String,
|
||||
optics: OpticalMaterial,
|
||||
expected_revision: u64,
|
||||
#[serde(default)]
|
||||
replace: bool,
|
||||
}
|
||||
pub(super) fn resolve_cpu_path(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
path: bool,
|
||||
command: &str,
|
||||
) -> Result<Vec<solidcraft_render::job::DielectricOptions>> {
|
||||
bindings.validate_for_cpu_path(path).map_err(|e| bad(command, e.to_string()))?;
|
||||
resolve_each(s, scene, bindings, command, |index, name, optics| {
|
||||
optics.to_cpu_for_path(index, name, path).map_err(|e| bad(command, e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) const BIND_CONTRACT: TypedContract<OpticsBindParams> = TypedContract::new();
|
||||
pub(super) const UNBIND_CONTRACT: TypedContract<OpticsUnbindParams> = TypedContract::new();
|
||||
|
||||
pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.optics.bind";
|
||||
let input: OpticsBindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Bind = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
input.optics.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "optical binding review is stale"));
|
||||
@@ -76,8 +54,8 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|old| old.optics != setup.optics) {
|
||||
return Err(bad(command, "setup patch cannot erase or replace saved optics; use explicit bind/unbind"));
|
||||
}
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "portable optics require an explicit CPU or GPU raytrace setup"));
|
||||
if !matches!(setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "portable optics require an explicit GPU setup"));
|
||||
}
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
let name = &scene
|
||||
@@ -94,35 +72,28 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
setup.optics.set(SavedOptics { target, optics: input.optics }, input.replace).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if matches!(setup.job, Some(RenderJob::CpuRaytrace { .. })) {
|
||||
// Offline saving resolves identity and numerical compatibility. The selected
|
||||
// CPU image producer still certifies actual geometry before any transport.
|
||||
let path = matches!(&setup.job,Some(RenderJob::CpuRaytrace{options,..}) if options.path.is_some());
|
||||
resolve_cpu_path(s, &scene, &setup.optics, path, command)?;
|
||||
} else {
|
||||
let resolved = resolve(s, &scene, &setup.optics, command)?;
|
||||
super::transmission::apply(&mut scene, &resolved, command)?;
|
||||
}
|
||||
let resolved = resolve(s, &scene, &setup.optics, command)?;
|
||||
super::transmission::apply(&mut scene, &resolved, command)?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
// Serialized before the document changes, so a setup that can't be written leaves it as it was.
|
||||
let setup = serde_json::to_value(&setup).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
let reply = OpticsBindingSaved {
|
||||
name: input.name,
|
||||
setup,
|
||||
geometry_and_engineering_unchanged: true,
|
||||
certification: "closed topology and nested containment are checked by the selected image producer before transport".into(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
Ok(
|
||||
json!({"name":input.name,"setup":setup,"geometry_and_engineering_unchanged":true,"certification":"closed topology and nested containment are checked by the selected image producer before transport"}),
|
||||
)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Unbind {
|
||||
name: String,
|
||||
target: RenderBodyTarget,
|
||||
expected_revision: u64,
|
||||
}
|
||||
pub(super) fn unbind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.optics.unbind";
|
||||
let input: OpticsUnbindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Unbind = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
input.target.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "optical removal review is stale"));
|
||||
@@ -134,8 +105,7 @@ pub(super) fn unbind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
let reply = OpticsUnbound { name: input.name, removed, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
Ok(json!({"name":input.name,"removed":removed}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -176,49 +146,4 @@ mod tests {
|
||||
s.execute("render.optics.unbind", &json!({"name":"Studio","target":target,"expected_revision":s.revision})).unwrap();
|
||||
assert!(s.doc.render.setups["Studio"].optics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_saved_optics_are_offline_undoable_and_keep_stable_identity_and_mass() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":3,"body_name":"Optics"})).unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Optics"],"material":"Steel"})).unwrap();
|
||||
s.execute("render.settings.set",&json!({"name":"CPU glass","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":81}}}}})).unwrap();
|
||||
assert!(s.render_job_host().is_none());
|
||||
let physical = s.execute("component.properties", &json!({"component":"root"})).unwrap();
|
||||
assert!(physical["total"]["mass_g"].as_f64().unwrap() > 0.0);
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let evaluations = s.evaluations;
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"CPU glass","body_index":0,"body_name":"Optics","expected_revision":s.revision,
|
||||
"optics":{"ior":1.51,"absorption_per_mm":[0.1,0.02,0.0],"boundary":"closed_mesh"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(s.execute("component.properties", &json!({"component":"root"})).unwrap(), physical);
|
||||
let saved = s.doc.clone();
|
||||
let scene = crate::product_scene::extract(&s).unwrap();
|
||||
let cpu = resolve_cpu(&s, &scene, &s.doc.render.setups["CPU glass"].optics, "check").unwrap();
|
||||
assert_eq!(cpu[0].body_name, "Optics");
|
||||
assert_eq!(cpu[0].ior, 1.51);
|
||||
assert_eq!(cpu[0].boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
assert_eq!(
|
||||
s.doc.render.setups["CPU glass"].optics.iter().next().unwrap().optics.boundary,
|
||||
solidcraft_doc::render_optics::OpticalBoundary::ClosedMesh
|
||||
);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.render, s.doc.render);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *saved);
|
||||
s.execute("browser.visibility", &json!({"items":["Optics"],"visible":false})).unwrap();
|
||||
let hidden = crate::product_scene::extract(&s).unwrap();
|
||||
assert!(resolve_cpu(&s, &hidden, &s.doc.render.setups["CPU glass"].optics, "check").unwrap_err().to_string().contains("hidden or absent"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,9 @@ pub(super) fn capture_settings(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
// Every numerical and method-dependent rejection happens before touching the source.
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if options.hdr.is_some() || options.environment.is_some() || options.sky.is_some() || params.get("assume_linear_srgb").is_some() {
|
||||
return Err(bad(
|
||||
command,
|
||||
"CPU HDR capture requires hdr/environment/sky unset and declared primaries; assume_linear_srgb is GPU-only",
|
||||
));
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if options.hdr.is_some() || options.environment.is_some() || params.get("assume_linear_srgb").is_some() {
|
||||
return Err(bad(command, "CPU HDR capture requires hdr/environment unset and declared primaries; assume_linear_srgb is GPU-only"));
|
||||
}
|
||||
CpuHdrEnvironment::check_controls(input.rotation_rad, input.intensity, input.samples.unwrap_or(16))
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
@@ -76,7 +73,7 @@ pub(super) fn capture_settings(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let info = metadata(&resource);
|
||||
let id = settings.resources.insert(resource).map_err(|e| bad(command, e.to_string()))?;
|
||||
match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
options.hdr = Some(CpuHdrEnvironment {
|
||||
resource: id,
|
||||
rotation_rad: input.rotation_rad,
|
||||
|
||||
@@ -6,64 +6,6 @@ fn save(s: &mut Session, patch: Value) {
|
||||
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_shot_patch_accepts_all_known_fields_and_refuses_unknowns_without_mutation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
save(&mut s, json!({"width":8,"height":8}));
|
||||
let before = s.doc.clone();
|
||||
let model = s.model.state();
|
||||
let camera = s.camera;
|
||||
let evaluations = s.evaluations;
|
||||
let undo = s.undo.len();
|
||||
let mut complete = json!(RenderSetup::default());
|
||||
complete["width"] = json!(16);
|
||||
complete["height"] = json!(12);
|
||||
complete["camera"] = json!(solidcraft_render::Camera { distance: 80.0, fov: 0.6, ..Default::default() });
|
||||
complete["job"] = json!({"method":"cpu_raytrace","options":{"path":{"max_depth":4},
|
||||
"progressive":{"samples_per_pixel":4,"seed":17},"lens":{"aperture_radius_mm":0.02,"focus_distance_mm":80.0},
|
||||
"output":{"linear_exr":true}}});
|
||||
for field in ["surfaces", "optics", "coats", "stage"] {
|
||||
complete[field] = json!([]);
|
||||
}
|
||||
assert_eq!(complete.as_object().unwrap().len(), 15);
|
||||
let expected: RenderSetup = serde_json::from_value(complete.clone()).unwrap();
|
||||
expected.validate().unwrap();
|
||||
let input = json!({"name":"Studio","set":complete});
|
||||
let original = input.clone();
|
||||
s.execute("render.settings.set", &input).unwrap();
|
||||
assert_eq!(s.doc.render.setups["Studio"], expected);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert_eq!(s.camera, camera);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(input, original);
|
||||
let saved = s.doc.clone();
|
||||
s.execute("render.settings.set", &input).unwrap();
|
||||
assert!(Arc::ptr_eq(&saved, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
for set in [json!({"unknown":true}), {
|
||||
let mut over_schema = complete;
|
||||
over_schema["unknown"] = json!(true);
|
||||
over_schema
|
||||
}] {
|
||||
let history = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":set})).is_err());
|
||||
assert!(Arc::ptr_eq(&saved, &s.doc));
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
|
||||
assert_eq!(s.camera, camera);
|
||||
}
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(), *s.doc);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
assert_eq!(s.camera, camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_editor_rotation_wraps_and_each_edit_is_one_undo() {
|
||||
let mut s = Session::default();
|
||||
@@ -338,14 +280,11 @@ fn render_lights_edit_refusals_are_atomic_for_hostile_numbers_ids_stale_reviews_
|
||||
assert_eq!(before, (s.undo.len(), s.revision));
|
||||
}
|
||||
light_fixture(&mut s, "gpu_path");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})).unwrap();
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional"}})).unwrap();
|
||||
let document = s.doc.clone();
|
||||
for command in ["render.lights.add", "render.lights.duplicate"] {
|
||||
let params = if command.ends_with("add") {
|
||||
json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})
|
||||
} else {
|
||||
json!({"name":"Studio","id":"directional:0"})
|
||||
};
|
||||
let params =
|
||||
if command.ends_with("add") { json!({"name":"Studio","set":{"kind":"point"}}) } else { json!({"name":"Studio","id":"directional:0"}) };
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
}
|
||||
@@ -770,7 +709,7 @@ fn portable_job_save_is_host_independent_atomic_and_undoable_without_geometry_ch
|
||||
save(&mut s, json!({"job":{"method":"cpu_raytrace"}}));
|
||||
assert_eq!(
|
||||
s.doc.render.setups["Studio"].job,
|
||||
Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() })
|
||||
Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options: solidcraft_doc::render_settings::CpuJob::default() })
|
||||
);
|
||||
assert_eq!(s.doc.render.setups["Studio"].exposure_ev, 2.5);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
@@ -1028,219 +967,12 @@ fn render_lights_removing_last_area_clears_area_only_sampling_settings() {
|
||||
light_fixture(&mut s, "cpu_raytrace");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"area"}})).unwrap();
|
||||
let mut setup = s.doc.render.setups["Studio"].clone();
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job {
|
||||
if let Some(RenderJob::CpuRaytrace { options }) = &mut setup.job {
|
||||
options.area_samples = Some(64);
|
||||
options.seed = Some(123);
|
||||
}
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"area:0","settings":setup})).unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Studio"].job else { panic!("CPU") };
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &s.doc.render.setups["Studio"].job else { panic!("CPU") };
|
||||
assert!(options.area_samples.is_none() && options.seed.is_none());
|
||||
assert!(options.area_lights.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_hard_directional_commands_materialize_defaults_and_keep_sun_distinct() {
|
||||
use crate::schema::render_lights::{Geometry, Visibility};
|
||||
let mut s = Session::default();
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{}}}));
|
||||
let implicit = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(implicit.len(), 3);
|
||||
let before = s.doc.clone();
|
||||
let duplicate = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"directional:1"})).unwrap();
|
||||
assert_eq!(duplicate["id"], "directional:3");
|
||||
let actual = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(actual.len(), 4);
|
||||
for (a, b) in actual.iter().take(3).zip(&implicit) {
|
||||
assert_eq!((a.intensity, a.colour), (b.intensity, b.colour));
|
||||
let (Geometry::Directional { direction: a, angular_radius_rad: None }, Geometry::Directional { direction: b, angular_radius_rad: None }) =
|
||||
(a.geometry, b.geometry)
|
||||
else {
|
||||
panic!("hard directions")
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let sun = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.25}})).unwrap();
|
||||
assert_eq!(sun["id"], "directional:0", "finite Sun is canonical first source");
|
||||
let original = s.doc.render.setups["Studio"].clone();
|
||||
let mut render_copy = original.clone();
|
||||
let visibility = Visibility { solo: Some("directional:3".into()), ..Default::default() };
|
||||
assert_eq!(crate::render_lights::filter(&mut render_copy, &visibility).unwrap(), None);
|
||||
let filtered = crate::render_lights::inspect(&render_copy).unwrap();
|
||||
for (i, light) in filtered.iter().enumerate() {
|
||||
assert_eq!(light.intensity > 0.0, i == 3);
|
||||
}
|
||||
assert_eq!(s.doc.render.setups["Studio"], original);
|
||||
for _ in 5..8 {
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional"}})).unwrap();
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
for (cmd, input) in [
|
||||
("render.lights.add", json!({"name":"Studio","set":{"kind":"directional"}})),
|
||||
("render.lights.duplicate", json!({"name":"Studio","id":"directional:1"})),
|
||||
("render.lights.duplicate", json!({"name":"Studio","id":"directional:0"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &input).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
}
|
||||
// Punctual sources have their own unchanged8-slot envelope. Filling the
|
||||
// directional envelope does not make the new point-light family unavailable.
|
||||
let point = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"point"}})).unwrap();
|
||||
assert_eq!(point["id"], "point:0");
|
||||
let with_point = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
let original_rows = crate::render_lights::inspect(&before.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(with_point.len(), 9);
|
||||
assert_eq!(&with_point[..8], original_rows.as_slice());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before, "one Undo restores the complete original8-directional setup");
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:7"})).unwrap();
|
||||
assert_eq!(crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap().len(), 7);
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(), *s.doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_light_id_remap_uses_original_identity_even_when_values_are_identical() {
|
||||
use crate::schema::render_lights::LightIdRemap;
|
||||
let mut s = Session::default();
|
||||
let hard = json!({"direction":[0,0,1],"color":[0.25,0.5,1],"intensity":2});
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{"lighting":{"directional_lights":[hard.clone(),hard]}}}}));
|
||||
let duplicated = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(duplicated["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(duplicated["id"], "directional:2");
|
||||
assert_eq!(
|
||||
map.iter().map(|m| (m.before.id.as_str(), m.after_id.as_str())).collect::<Vec<_>>(),
|
||||
[("directional:0", "directional:0"), ("directional:1", "directional:1")]
|
||||
);
|
||||
let added = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.25}})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(added["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 3);
|
||||
assert_eq!(added["id"], "directional:0");
|
||||
for (index, entry) in map.iter().enumerate() {
|
||||
assert_eq!(entry.before.id, format!("directional:{index}"));
|
||||
assert_eq!(entry.after_id, format!("directional:{}", index + 1));
|
||||
}
|
||||
let removed = s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(removed["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 3);
|
||||
for (index, entry) in map.iter().enumerate() {
|
||||
assert_eq!(entry.before.id, format!("directional:{}", index + 1));
|
||||
assert_eq!(entry.after_id, format!("directional:{index}"));
|
||||
}
|
||||
assert!(removed["id"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_light_edits_preserve_untouched_original_sun_and_area_radiance_bits() {
|
||||
let mut s = Session::default();
|
||||
let rgb = [0.7_f64, 1.0, 9.1];
|
||||
save(
|
||||
&mut s,
|
||||
json!({"job":{"method":"gpu_path","options":{"lighting":{
|
||||
"sun":{"direction":[1,2,3],"angular_radius_rad":0.02,"radiance":rgb},
|
||||
"area_lights":[{"center":[0,0,10],"u":[1,0,0],"v":[0,-1,0],"radiance":rgb}],
|
||||
"directional_lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":1}]
|
||||
}}}}),
|
||||
);
|
||||
let lighting = |s: &Session| {
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &s.doc.render.setups["Studio"].job else { panic!("gpu") };
|
||||
options.lighting.unwrap()
|
||||
};
|
||||
let original = lighting(&s);
|
||||
let bits = |a: [f64; 3]| a.map(f64::to_bits);
|
||||
for (command, params) in [
|
||||
("render.lights.add", json!({"name":"Studio","set":{"kind":"directional"}})),
|
||||
("render.lights.set", json!({"name":"Studio","id":"directional:1","set":{"direction":[2,3,4]}})),
|
||||
("render.lights.remove", json!({"name":"Studio","id":"directional:2"})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
let actual = lighting(&s);
|
||||
assert_eq!(bits(actual.sun.unwrap().radiance), bits(original.sun.unwrap().radiance));
|
||||
assert_eq!(bits(actual.area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
}
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
assert!(lighting(&s).sun.is_none());
|
||||
assert_eq!(bits(lighting(&s).area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})).unwrap();
|
||||
assert_eq!(bits(lighting(&s).area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_point_spot_commands_keep_cones_ranges_undo_visibility_identity_and_portable_reload() {
|
||||
use crate::schema::render_lights::{Geometry, Visibility};
|
||||
let mut s = Session::default();
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{}}}));
|
||||
let implicit = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
let add=s.execute("render.lights.add",&json!({"name":"Studio","set":{"kind":"spot","position_mm":[4,5,6],"emitted_axis":[-4,-5,-6],"inner_half_angle_radians":0.2,"outer_half_angle_radians":0.7,"reference_distance_mm":7.25,"minimum_distance_mm":0.002}})).unwrap();
|
||||
assert_eq!(add["id"], "spot:0");
|
||||
let authored = s.doc.clone();
|
||||
let all = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
for (a, b) in all.iter().take(3).zip(&implicit) {
|
||||
assert_eq!((a.geometry, a.intensity, a.colour), (b.geometry, b.intensity, b.colour));
|
||||
assert!(!a.implicit);
|
||||
}
|
||||
let Geometry::Spot { position_mm, emitted_axis, inner_half_angle_radians, outer_half_angle_radians, reference_distance_mm, minimum_distance_mm } =
|
||||
all[3].geometry
|
||||
else {
|
||||
panic!("spot")
|
||||
};
|
||||
assert_eq!((position_mm, emitted_axis), ([4.0, 5.0, 6.0], [-4.0, -5.0, -6.0]));
|
||||
assert_eq!((inner_half_angle_radians, outer_half_angle_radians, reference_distance_mm, minimum_distance_mm), (0.2, 0.7, 7.25, 0.002));
|
||||
let undo = s.undo.len();
|
||||
let point = s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":{"kind":"point"}})).unwrap();
|
||||
assert_eq!(point["id"], "point:0");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let rows = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(rows[3].geometry, Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, authored);
|
||||
let dup = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"spot:0"})).unwrap();
|
||||
assert_eq!(dup["id"], "spot:1");
|
||||
assert!(dup["id_remap"].as_array().unwrap().iter().any(|m| m["before"]["id"] == "spot:0" && m["after_id"] == "spot:0"));
|
||||
let before = s.doc.clone();
|
||||
let mut filtered = s.doc.render.setups["Studio"].clone();
|
||||
crate::render_lights::filter(&mut filtered, &Visibility { solo: Some("spot:1".into()), ..Default::default() }).unwrap();
|
||||
let rows = crate::render_lights::inspect(&filtered).unwrap();
|
||||
assert!(rows.iter().all(|l| (l.intensity > 0.0) == (l.id == "spot:1")));
|
||||
assert_eq!(s.doc, before);
|
||||
for _ in 2..8 {
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"point"}})).unwrap();
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
for command in ["render.lights.add", "render.lights.duplicate"] {
|
||||
let params = if command.ends_with("add") { json!({"name":"Studio","set":{"kind":"spot"}}) } else { json!({"name":"Studio","id":"spot:0"}) };
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
assert_eq!(
|
||||
crate::render_lights::inspect(&reopened.render.setups["Studio"]).unwrap(),
|
||||
crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gpu_spot_cone_hostile_edits_refuse_atomically_without_widening_or_ignoring_intent() {
|
||||
let mut s = Session::default();
|
||||
light_fixture(&mut s, "gpu_path");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"spot"}})).unwrap();
|
||||
for set in [
|
||||
json!({"inner_half_angle_radians":0.5,"outer_half_angle_radians":0.5}),
|
||||
json!({"inner_half_angle_radians":0.2,"outer_half_angle_radians":0.2000000001}),
|
||||
json!({"emitted_axis":[0,0,0]}),
|
||||
json!({"outer_half_angle_radians":2}),
|
||||
json!({"emitted_axis":[0,0,-1],"aim_at":[1,2,3]}),
|
||||
] {
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
|
||||
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":set})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
|
||||
}
|
||||
s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":{"kind":"point"}})).unwrap();
|
||||
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"point:0","set":{"inner_half_angle_radians":0.1}})).is_err());
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! Original native studio compositions. Independent of unbound SCENE-* concepts.
|
||||
//! Resolution is read-only; publication validates a complete candidate before one undo.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_presets::{
|
||||
CpuProgressive, CpuQualityContext, CpuQualityOptions, CpuQualityPlan, PresetParams, QualityOptions, QualityParams, QualityPlan, QualityResult,
|
||||
QualitySize, StudioComposition,
|
||||
};
|
||||
use crate::schema::render_presets::{PresetParams, QualityOptions, QualityParams, QualityPlan, QualitySize, StudioComposition};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_resources::{CpuHdrEnvironment, GpuHdrEnvironment},
|
||||
render_resources::GpuHdrEnvironment,
|
||||
render_settings::{RenderJob, RenderSettings},
|
||||
};
|
||||
use solidcraft_render::{
|
||||
@@ -67,8 +64,8 @@ fn candidate(s: &Session, input: &PresetParams, command: &str) -> Result<Candida
|
||||
}
|
||||
let source = recipe(&input.id, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if !matches!(&setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "native studio compositions require an explicitly selected CPU or GPU raytrace setup; no executor is substituted"));
|
||||
if !matches!(&setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "native studio compositions require an explicitly selected GPU path setup; no executor is substituted"));
|
||||
}
|
||||
let old = s.doc.render.setups.get(&input.name);
|
||||
let stage_replacement = !setup.stage.is_empty() || old.is_some_and(|saved| !saved.stage.is_empty());
|
||||
@@ -86,13 +83,7 @@ fn candidate(s: &Session, input: &PresetParams, command: &str) -> Result<Candida
|
||||
let hdr = source["hdr"].as_str().ok_or_else(|| bad(command, "native composition has no HDR identity"))?;
|
||||
let resource = super::library::hdr_resource(hdr, command)?;
|
||||
let resource_id = resource.id();
|
||||
let mut lighting = StudioLighting {
|
||||
punctual_lights: Default::default(),
|
||||
directional_lights: Default::default(),
|
||||
environment: [0.0; 3],
|
||||
sun: None,
|
||||
area_lights: AreaLights::default(),
|
||||
};
|
||||
let mut lighting = StudioLighting { environment: [0.0; 3], sun: None, area_lights: AreaLights::default() };
|
||||
let mut panels = Vec::new();
|
||||
let mut projection = Value::Null;
|
||||
if let Some(rig) = source["rig"].as_str() {
|
||||
@@ -130,31 +121,16 @@ fn candidate(s: &Session, input: &PresetParams, command: &str) -> Result<Candida
|
||||
});
|
||||
setup.stage = Stage::new(panels).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.exposure_ev = source["exposure_ev"].as_f64().ok_or_else(|| bad(command, "native composition exposure missing"))?;
|
||||
let executor = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
let hdr_samples = options.hdr.map_or(16, |hdr| hdr.samples);
|
||||
super::library::cpu_studio_lighting(options, &lighting, command)?;
|
||||
options.hdr = Some(CpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
samples: hdr_samples,
|
||||
});
|
||||
"cpu-raytrace"
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
options.environment = None;
|
||||
options.hdr = Some(GpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
assume_linear_srgb: false,
|
||||
});
|
||||
options.lighting = Some(lighting);
|
||||
"gpu-path"
|
||||
}
|
||||
_ => return Err(bad(command, "choose CPU or GPU ray tracing")),
|
||||
};
|
||||
if let Some(RenderJob::GpuPath { options, .. }) = &mut setup.job {
|
||||
options.environment = None;
|
||||
options.hdr = Some(GpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
assume_linear_srgb: false,
|
||||
});
|
||||
options.lighting = Some(lighting);
|
||||
}
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
super::staging::apply(&mut product, &setup, command)?;
|
||||
super::library::rig_clearance(&product, &lighting, command)?;
|
||||
@@ -172,7 +148,7 @@ fn candidate(s: &Session, input: &PresetParams, command: &str) -> Result<Candida
|
||||
rig_projection: projection,
|
||||
status: "resolved_native_projection".into(),
|
||||
document_changed: false,
|
||||
executor: executor.into(),
|
||||
executor: "gpu-path".into(),
|
||||
requires_lighting_consent: true,
|
||||
requires_stage_consent: stage_replacement,
|
||||
replaces: strings(&["lighting", "environment", "passive_stage", "exposure"]),
|
||||
@@ -229,15 +205,12 @@ pub(super) fn apply(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
pub(super) fn quality(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.quality.prepare";
|
||||
let input: QualityParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if !matches!(input.variant.as_str(), "fixed_samples_png" | "cpu_progressive_png") || !input.acknowledge_omissions {
|
||||
if input.variant != "fixed_samples_png" || !input.acknowledge_omissions {
|
||||
return Err(bad(
|
||||
command,
|
||||
"an explicit fixed_samples_png or cpu_progressive_png projection and omission acknowledgement are required; full Q-* requirements remain unbound",
|
||||
"explicit fixed_samples_png projection and omission acknowledgement are required; full Q-* requirements remain unbound",
|
||||
));
|
||||
}
|
||||
if input.variant == "fixed_samples_png" && input.cpu.is_some() {
|
||||
return Err(bad(command, "CPU budget context requires the cpu_progressive_png projection"));
|
||||
}
|
||||
let source = super::library::item("quality", &input.id, command)?;
|
||||
let number = |path: &[&str]| -> Result<u32> {
|
||||
let mut field = source;
|
||||
@@ -247,81 +220,6 @@ pub(super) fn quality(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
field.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(command, "quality field is not a bounded unsigned integer"))
|
||||
};
|
||||
let (width, height) = (number(&["resolution", "width"])?, number(&["resolution", "height"])?);
|
||||
if input.variant == "cpu_progressive_png" {
|
||||
if !matches!(input.id.as_str(), "Q-001" | "Q-002" | "Q-003") || source["motion"]["enabled"] != false {
|
||||
return Err(bad(
|
||||
command,
|
||||
"only the three canonical still-image CPU cost budgets are bound; optical/convergence concepts are not CPU presets",
|
||||
));
|
||||
}
|
||||
let samples = number(&["sampling", "minimum_samples"])?.max(16);
|
||||
let seed = number(&["seed"])?;
|
||||
let context = input.cpu.unwrap_or_default();
|
||||
let production = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: samples, seed });
|
||||
let mut eligible = false;
|
||||
let mut selected = None;
|
||||
let mut refusal = None;
|
||||
for budget in solidcraft_render::job::CpuBudget::ALL {
|
||||
eligible |= budget == context.minimum_budget;
|
||||
if !eligible {
|
||||
continue;
|
||||
}
|
||||
let admitted = solidcraft_render::job::cpu_camera_sampling(production, context.adaptive, budget)
|
||||
.and_then(|sampling| budget.validate_image(width, height, sampling));
|
||||
match admitted {
|
||||
Ok(()) => {
|
||||
selected = Some(budget);
|
||||
break;
|
||||
}
|
||||
Err(error) => refusal = Some(error),
|
||||
}
|
||||
}
|
||||
let budget = selected
|
||||
.ok_or_else(|| bad(command, refusal.map_or_else(|| "no existing CPU profile admits this budget".to_string(), |e| e.to_string())))?;
|
||||
let plan = CpuQualityPlan {
|
||||
id: input.id,
|
||||
name: source["name"].as_str().map(str::to_owned),
|
||||
variant: input.variant,
|
||||
status: "cpu_cost_budget_projection_not_full_quality_profile".into(),
|
||||
revision: s.revision,
|
||||
document_changed: false,
|
||||
source_unchanged: true,
|
||||
settings: QualitySize { width, height },
|
||||
cpu_options: CpuQualityOptions { progressive: CpuProgressive { samples_per_pixel: samples, seed }, budget },
|
||||
cpu_context: CpuQualityContext { minimum_budget: context.minimum_budget, adaptive: context.adaptive },
|
||||
requires_progressive: true,
|
||||
changed_fields: strings(&["width", "height", "progressive.samples_per_pixel", "progressive.seed", "budget"]),
|
||||
preserves: strings(&[
|
||||
"executor",
|
||||
"camera",
|
||||
"lens",
|
||||
"lighting",
|
||||
"environment",
|
||||
"stage",
|
||||
"optical_bindings",
|
||||
"surface_maps",
|
||||
"aging",
|
||||
"exposure",
|
||||
"tone_map",
|
||||
"finishing",
|
||||
"raw_master",
|
||||
"denoise",
|
||||
"path",
|
||||
"adaptive",
|
||||
]),
|
||||
omitted: strings(&[
|
||||
"catalog_maximum_samples",
|
||||
"catalog_transport_depth",
|
||||
"catalog_denoise_choice",
|
||||
"adaptive_stopping_and_error_target",
|
||||
"convergence_certification",
|
||||
"time_guarantee",
|
||||
]),
|
||||
source: source.as_object().cloned().unwrap_or_default(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
return reply(command, QualityResult::Cpu(plan));
|
||||
}
|
||||
let (samples, depth) = (number(&["sampling", "maximum_samples"])?, number(&["transport", "maximum_depth"])?);
|
||||
let seed = number(&["seed"])?;
|
||||
if solidcraft_render::job::GPU_PATH_LIMITS.fit(u64::from(width), u64::from(height), None) != Some((u64::from(width), u64::from(height)))
|
||||
@@ -358,7 +256,7 @@ pub(super) fn quality(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
source: source.as_object().cloned().unwrap_or_default(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(command, QualityResult::Gpu(plan))
|
||||
reply(command, plan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -210,261 +210,3 @@ fn finishing_and_linear_master_saved_intent_survive_reopen_without_host_and_reje
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_quality_projects_exact_catalog_costs_with_existing_admissible_profiles_without_mutation() {
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
let mut s = subject();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
for (id, width, height, samples, profile) in
|
||||
[("Q-001", 960, 540, 16, CpuBudget::Compact), ("Q-002", 1600, 900, 32, CpuBudget::Studio), ("Q-003", 2560, 1440, 64, CpuBudget::Studio)]
|
||||
{
|
||||
let plan = s.execute("render.quality.prepare", &json!({"id":id,"variant":"cpu_progressive_png","acknowledge_omissions":true})).unwrap();
|
||||
assert_eq!(plan["settings"], json!({"width":width,"height":height}));
|
||||
assert_eq!(plan["cpu_options"], json!({"budget":profile,"progressive":{"samples_per_pixel":samples,"seed":21026}}));
|
||||
assert_eq!(plan["changed_fields"], json!(["width", "height", "progressive.samples_per_pixel", "progressive.seed", "budget"]));
|
||||
assert!(plan.get("gpu_options").is_none());
|
||||
assert!(plan["cpu_options"].get("path").is_none());
|
||||
assert!(plan["cpu_options"].get("denoise").is_none());
|
||||
assert_eq!(plan["status"], "cpu_cost_budget_projection_not_full_quality_profile");
|
||||
assert_eq!(plan["document_changed"], false);
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
assert!(pixels * u64::from(samples) <= profile.limits().max_camera_samples);
|
||||
profile.validate_image(width, height, Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 21026 })).unwrap();
|
||||
for larger in [CpuBudget::Studio, CpuBudget::Workstation] {
|
||||
let kept = s
|
||||
.execute(
|
||||
"render.quality.prepare",
|
||||
&json!({"id":id,"variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"minimum_budget":larger}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kept["cpu_options"]["budget"], json!(larger), "an explicitly chosen larger profile must not be lowered");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
let pilot = s
|
||||
.execute(
|
||||
"render.quality.prepare",
|
||||
&json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,
|
||||
"cpu":{"adaptive":{"pilot_samples":256,"min_samples":16}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pilot["cpu_options"]["budget"], "studio", "Draft must budget272total passes, not hide pilots outside Compact64");
|
||||
let first_pixels = 960_u64 * 540;
|
||||
assert!(first_pixels * 272 < CpuBudget::Studio.limits().max_camera_samples);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_quality_rejects_unbound_ids_hostile_context_and_incompatible_adaptive_without_silent_substitution() {
|
||||
let mut s = subject();
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len());
|
||||
for value in [
|
||||
json!({"id":"Q-004","variant":"cpu_progressive_png","acknowledge_omissions":true}),
|
||||
json!({"id":"Q-999","variant":"cpu_progressive_png","acknowledge_omissions":true}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":false}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"minimum_budget":"unlimited"}}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"enable_gi":true}}),
|
||||
json!({"id":"Q-001","variant":"fixed_samples_png","acknowledge_omissions":true,"cpu":{}}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"adaptive":{"pilot_samples":16,"min_samples":32}}}),
|
||||
json!({"id":"Q-003","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"adaptive":{"pilot_samples":16,"min_samples":128}}}),
|
||||
] {
|
||||
assert!(s.execute("render.quality.prepare", &value).is_err(), "{value}");
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_native_studios_preserve_full_job_and_exact_hdr_rig_authority_for_all_twenty_five() {
|
||||
use solidcraft_doc::render_settings::CpuJob;
|
||||
use solidcraft_render::job::{CpuBudget, CpuPathOptions, CpuProgressiveOptions, ThinLens, adaptive::AdaptiveSampling};
|
||||
for entry in entries().as_array().unwrap() {
|
||||
let id = entry["id"].as_str().unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap();
|
||||
let camera = solidcraft_render::Camera { fov: 0.5, ..Default::default() };
|
||||
let options = CpuJob {
|
||||
budget: CpuBudget::Studio,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 947 }),
|
||||
adaptive: Some(AdaptiveSampling::default()),
|
||||
path: Some(CpuPathOptions { max_depth: 5 }),
|
||||
denoise: true,
|
||||
lens: Some(ThinLens { aperture_radius_mm: 0.2, focus_distance_mm: 100.0 }),
|
||||
output: Some(Box::new(solidcraft_render::job::OutputOptions { linear_exr: true, ..Default::default() })),
|
||||
area_samples: Some(64),
|
||||
seed: Some(731),
|
||||
volume: Some(solidcraft_render::job::volume::HomogeneousVolume { extinction_per_mm: 0.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
s.execute("render.settings.set", &json!({"name":"Shot","set":{"width":96,"height":64,"camera":camera,"job":{"method":"cpu_raytrace","transparent_background":true,"options":options}}})).unwrap();
|
||||
s.execute("render.library.environment.apply", &json!({"id":"HDR-001","name":"Shot","samples":64})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let revision = s.revision;
|
||||
let old = s.doc.render.setups["Shot"].clone();
|
||||
let plan = s.execute("render.preset.resolve", &json!({"id":id,"name":"Shot","expected_revision":revision})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.doc, &before));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(plan["executor"], "cpu-raytrace");
|
||||
let applied = s
|
||||
.execute("render.preset.apply", &json!({"id":id,"name":"Shot","expected_revision":revision,"replace_lighting_and_environment":true}))
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(applied["setup"], plan["setup"]);
|
||||
let saved = &s.doc.render.setups["Shot"];
|
||||
assert_eq!((saved.camera, saved.width, saved.height), (old.camera, old.width, old.height));
|
||||
assert_eq!((&saved.optics, &saved.coats, &saved.surfaces), (&old.optics, &old.coats, &old.surfaces));
|
||||
let Some(RenderJob::CpuRaytrace { options, transparent_background }) = &saved.job else { panic!("CPU method lost") };
|
||||
assert!(*transparent_background);
|
||||
let Some(RenderJob::CpuRaytrace { options: old_options, .. }) = &old.job else {
|
||||
panic!("the captured original setup must retain its CPU method")
|
||||
};
|
||||
let mut expected = (**old_options).clone();
|
||||
expected.preset = None;
|
||||
expected.lights = Some(vec![]);
|
||||
expected.punctual_lights = Some(vec![]);
|
||||
expected.environment = None;
|
||||
expected.sky = None;
|
||||
expected.sun = None;
|
||||
expected.hdr = Some(CpuHdrEnvironment {
|
||||
resource: serde_json::from_value(plan["resource_sha256"].clone()).unwrap(),
|
||||
rotation_rad: 0.35 + std::f64::consts::PI,
|
||||
intensity: entry["intensity"].as_f64().unwrap(),
|
||||
samples: 64,
|
||||
});
|
||||
expected.area_lights = Some(vec![]);
|
||||
if entry["rig"] == "RIG-002" {
|
||||
let bounds = crate::product_scene::extract(&s).unwrap().bounds;
|
||||
let size = bounds.max - bounds.min;
|
||||
let c = bounds.center();
|
||||
let (lighting, _) = super::super::library::rig_lighting(
|
||||
&super::super::library::RigInput {
|
||||
id: "RIG-002".into(),
|
||||
variant: "complete".into(),
|
||||
center_mm: [c.x, c.y, c.z],
|
||||
subject_extent_mm: size.x.max(size.y).max(size.z),
|
||||
},
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
expected.area_lights = Some(
|
||||
lighting
|
||||
.area_lights
|
||||
.iter()
|
||||
.map(|light| solidcraft_render::job::RectangleLightOptions {
|
||||
center_mm: light.center,
|
||||
edge_u_mm: light.u.map(|x| x * 2.0),
|
||||
edge_v_mm: light.v.map(|x| x * 2.0),
|
||||
color: [1.0; 3],
|
||||
intensity: light.radiance[0] / 16.0,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
assert_eq!(expected.area_lights.as_ref().unwrap().iter().map(|a| a.intensity).fold(0.0, f64::max), 29.691586369625);
|
||||
}
|
||||
assert_eq!(**options, expected, "all unrelated CPU intent survives {id}");
|
||||
let hdr = options.hdr.unwrap();
|
||||
assert_eq!(
|
||||
s.doc.render.resources.required(&hdr.resource).unwrap().bytes(),
|
||||
super::super::library_data::asset(entry["hdr"].as_str().unwrap()).unwrap().0
|
||||
);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
let saved_json = s.doc.to_json();
|
||||
let reopened = solidcraft_doc::Document::from_json(&saved_json).unwrap();
|
||||
assert_eq!(reopened.render.setups["Shot"], *saved);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), saved_json);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "bundled-render-library", feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn cpu_native_studio_cold_capture_matches_typed_source_every_prefix_and_raw_master() {
|
||||
use solidcraft_render::job::{CancelToken, CpuHost, ImageRequest, Integrator, JobId, Phase};
|
||||
use solidcraft_render::lighting::environment_input::EnvironmentInput;
|
||||
use solidcraft_render::raytrace::image::{ImagePoll, ImageTask};
|
||||
for id in ["NATIVE-STUDIO-001", "NATIVE-STUDIO-004", "RIG-020"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap();
|
||||
let camera = solidcraft_render::Camera { target: solidcore_record::Vec3::new(10.0, 7.5, 5.0), distance: 35.0, ..Default::default() };
|
||||
s.execute("render.settings.set",&json!({"name":"Shot","set":{"width":8,"height":8,"camera":camera,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":47},"area_samples":1,"seed":731,"output":{"linear_exr":true}}}}})).unwrap();
|
||||
s.execute("render.library.environment.apply", &json!({"id":"HDR-001","name":"Shot","samples":1})).unwrap();
|
||||
if id == "RIG-020" {
|
||||
s.execute("render.library.studio.apply", &json!({"name":"Shot","rig":{"id":id,"variant":"complete","center_mm":[10,7.5,5],"subject_extent_mm":20},"expected_revision":s.revision,"replace_lighting_and_environment":true,"enable_indirect":true})).unwrap();
|
||||
} else {
|
||||
s.execute("render.preset.apply", &json!({"id":id,"name":"Shot","expected_revision":s.revision,"replace_lighting_and_environment":true}))
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = solidcraft_io::write_design_container(&s.doc).unwrap();
|
||||
s = Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
s.execute("render.scene", &json!({})).unwrap();
|
||||
let setup = s.doc.render.setups["Shot"].clone();
|
||||
let Some(RenderJob::CpuRaytrace { options, transparent_background }) = &setup.job else { panic!() };
|
||||
let hdr = options.hdr;
|
||||
if id == "RIG-020" {
|
||||
assert!(hdr.is_none());
|
||||
assert!(options.sun.is_some());
|
||||
} else {
|
||||
assert_eq!(hdr.unwrap().samples, 1);
|
||||
}
|
||||
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
|
||||
let mut scene = crate::product_scene::extract(&s).unwrap();
|
||||
super::super::staging::apply(&mut scene, &setup, "test").unwrap();
|
||||
let render = |quantum| {
|
||||
let request = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(runtime.clone()),
|
||||
camera: setup.camera,
|
||||
width: setup.width,
|
||||
height: setup.height,
|
||||
background: setup.background,
|
||||
transparent_background: *transparent_background,
|
||||
studio_weights: None,
|
||||
environment: None,
|
||||
textures: None,
|
||||
};
|
||||
let input = hdr.map(|hdr| {
|
||||
EnvironmentInput::new(s.doc.render.resources.required(&hdr.resource).unwrap().source(), hdr.rotation_rad, hdr.intensity, hdr.samples)
|
||||
.unwrap()
|
||||
});
|
||||
let mut task = ImageTask::new_with_environment(scene.clone(), request, CancelToken::default(), runtime.clone(), input).unwrap();
|
||||
let mut prefixes = Vec::new();
|
||||
for _ in 0..2_000_000 {
|
||||
match task.step_with_previews(quantum).unwrap() {
|
||||
ImagePoll::Pending(_) => {}
|
||||
ImagePoll::Preview { png, samples_per_pixel } => prefixes.push((samples_per_pixel, png)),
|
||||
ImagePoll::Complete(png) => return (prefixes, png, task.take_linear_exr().unwrap()),
|
||||
}
|
||||
}
|
||||
panic!("bounded native studio source failed to finish")
|
||||
};
|
||||
let expected = render(1);
|
||||
assert_eq!(expected.0.iter().map(|(sample, _)| *sample).collect::<Vec<_>>(), [1, 2]);
|
||||
assert!(expected.0.iter().all(|(_, png)| png.starts_with(b"\x89PNG\r\n\x1a\n")));
|
||||
assert_eq!(expected, render(4096), "every complete prefix, final PNG and raw master is independent of work quantum");
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
s.set_render_job_host(Some(host)).unwrap();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
s.execute("render.preflight", &json!({"settings":setup})).unwrap();
|
||||
let reply = s.execute("render.start", &json!({"settings":setup})).unwrap();
|
||||
let job: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
assert_eq!(frame.png(), expected.1.as_slice());
|
||||
assert_eq!(frame.linear_exr().unwrap(), expected.2.as_slice());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Resolve portable surface intent into the existing captured GPU texture contract.
|
||||
//! Definition/feature/occurrence identities are checked on every Check and Start.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_bindings::{SurfaceBindParams, SurfaceBindingSaved, SurfaceUnbindParams};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{
|
||||
@@ -133,32 +132,32 @@ pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &SurfaceBindi
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub(super) const BIND_CONTRACT: TypedContract<SurfaceBindParams> = TypedContract::new();
|
||||
pub(super) const UNBIND_CONTRACT: TypedContract<SurfaceUnbindParams> = TypedContract::new();
|
||||
|
||||
/// Save `setup` under `name` in one undo step (none when nothing changed) and reply with it.
|
||||
fn empty() -> Value {
|
||||
json!({})
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Bind {
|
||||
name: String,
|
||||
surface: Value,
|
||||
#[serde(default = "empty")]
|
||||
set: Value,
|
||||
#[serde(default)]
|
||||
replace: bool,
|
||||
}
|
||||
fn publish(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<Value> {
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
// Serialized before the document changes, so a setup that can't be written leaves it as it was.
|
||||
let setup = serde_json::to_value(&setup).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
let reply = SurfaceBindingSaved {
|
||||
name,
|
||||
setup,
|
||||
mapping_space: "definition_local_mm".into(),
|
||||
geometry_and_physical_materials_unchanged: true,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
Ok(json!({"name":name,"setup":setup,"mapping_space":"definition_local_mm","geometry_and_physical_materials_unchanged":true}))
|
||||
}
|
||||
pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
super::library::require_bundled("render.library.surface.bind")?;
|
||||
let command = "render.library.surface.bind";
|
||||
let input: SurfaceBindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Bind = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|old| old.surfaces != setup.surfaces) {
|
||||
return Err(bad(command, "setup patch cannot erase or replace saved surface bindings; use explicit bind/unbind commands"));
|
||||
@@ -167,14 +166,6 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "persistent authored maps require an explicit CPU raytrace or GPU path setup"));
|
||||
}
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &setup.job {
|
||||
if !setup.optics.is_empty() && options.path.is_none() {
|
||||
return Err(bad(command, "CPU maps with Glass require Indirect lighting and separate targets"));
|
||||
}
|
||||
// Saving validates portable intent; capture later resolves stable aging
|
||||
// targets. The final setup validator still forbids maps on a Glass target.
|
||||
options.validate_surface_maps(setup.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
let prepared = super::library::prepare_surface(s, &input.surface)?;
|
||||
let id = prepared["id"].as_str().ok_or_else(|| bad(command, "missing prepared surface identifier"))?;
|
||||
let name = prepared["texture"]["body_name"].as_str().ok_or_else(|| bad(command, "missing prepared body identity"))?;
|
||||
@@ -201,9 +192,17 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
super::textures::validate_targets(&scene, &resolved, command)?;
|
||||
publish(s, input.name, setup, command)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Unbind {
|
||||
name: String,
|
||||
target: RenderBodyTarget,
|
||||
#[serde(default = "empty")]
|
||||
set: Value,
|
||||
}
|
||||
pub(super) fn unbind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.surface.unbind";
|
||||
let input: SurfaceUnbindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Unbind = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
input.target.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
setup.surfaces.remove(&input.target);
|
||||
|
||||
@@ -41,8 +41,6 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::R
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Override {
|
||||
#[serde(default)]
|
||||
model: Option<solidcraft_render::glass_model::GlassModel>,
|
||||
body_index: usize,
|
||||
body_name: String,
|
||||
ior: f64,
|
||||
@@ -94,10 +92,6 @@ pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &
|
||||
}
|
||||
let [r, g, b] = input.absorption_per_mm;
|
||||
let mut transmission = Transmission::new(Vec3::new(r, g, b), thickness).map_err(|e| bad(command, e.to_string()))?;
|
||||
if let Some(model) = input.model {
|
||||
model.validate(input.ior).map_err(|e| bad(command, e.to_string()))?;
|
||||
transmission = transmission.with_model(model).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
if let Some(enabled) = input.shadow_transmission {
|
||||
transmission = transmission.with_shadow_transmission(enabled);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ fn divider(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
Ok(json!({"position":input.position.clamp(0.05,0.95)}))
|
||||
}
|
||||
|
||||
pub(crate) fn batch_request(s: &Session, params: &Value) -> Result<Value> {
|
||||
fn batch(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: BatchParams = serde_json::from_value(params.clone()).map_err(|e| bad("render.batch", e.to_string()))?;
|
||||
s.render_batch_ready().map_err(|reason| bad("render.batch", reason))?;
|
||||
if s.model.is_stale() {
|
||||
@@ -187,11 +187,7 @@ pub(crate) fn batch_request(s: &Session, params: &Value) -> Result<Value> {
|
||||
return Err(bad("render.batch", "replace must be false or omitted"));
|
||||
}
|
||||
request.insert("settings".into(), json!(settings));
|
||||
Ok(Value::Object(request))
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let request = std::sync::Arc::new(batch_request(s, params)?);
|
||||
let request = std::sync::Arc::new(Value::Object(request));
|
||||
let shots = s
|
||||
.doc
|
||||
.render
|
||||
|
||||
@@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
|
||||
@@ -500,7 +500,7 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
|
||||
// A pierce is drawn at the point it holds.
|
||||
Pierce { p, .. } => pt(p),
|
||||
PointOnCurve { p, .. } | PointAlong { p, .. } => pt(p),
|
||||
PointOnCurve { p, .. } => pt(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
Horizontal { l } | Vertical { l } => mid(l),
|
||||
Parallel { a, .. } | Perpendicular { a, .. } | Collinear { a, .. } | Equal { a, .. } | Concentric { a, .. } | Angle { a, .. } => mid(a),
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Divide Curve (#8969): points that divide sketch curves into equal lengths.
|
||||
//!
|
||||
//! Each point is held by a [`ConstraintKind::PointAlong`] at its share k/N of the curve's length,
|
||||
//! so it stays on the curve and keeps its share when the curve is edited, dragged or
|
||||
//! dimensioned. The length is measured along the exact curve (arc length), not its parameter:
|
||||
//! a spline's points are evenly spaced along it however its fit or control points are spread.
|
||||
//! The curve itself is left as it is. Curves of 3D sketches and model edges are not divided yet.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch, point_along};
|
||||
|
||||
use super::sketch::{add_c, edit};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most segments one curve is divided into.
|
||||
pub(crate) const MAX_SEGMENTS: usize = 500;
|
||||
/// Most curves one command divides.
|
||||
const MAX_CURVES: usize = 200;
|
||||
/// Most points one command adds.
|
||||
const MAX_POINTS: usize = 2_000;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("sketch.point.divide", "Divide Curve", divide)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("point")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!(
|
||||
"curve: id | curves: [ids] (each divided on its own); segments: whole number from 2 to 500. Places points that divide each curve into `segments` pieces of equal length along it (arc length): segments − 1 points on an open curve (line, arc, spline, conic), `segments` points on a closed one (a circle from its point on sketch +X, an ellipse from its major-axis end, a closed spline from its first point). Each point is constrained to its share of the curve's length, so it stays on the curve and keeps equal spacing as the curve changes; the curve is not changed. Infinite lines are refused. Returns points: [ids], per curve in order, and constraints: [ids]",
|
||||
created_doc!()
|
||||
))];
|
||||
|
||||
/// Whether curve `c` is closed: its points go all the way round, from its start.
|
||||
fn closed(sk: &Sketch, c: usize) -> bool {
|
||||
matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. } | CurveKind::Spline { periodic: true, .. }))
|
||||
}
|
||||
|
||||
/// The shares of the length where the points of `segments` equal pieces go.
|
||||
pub(crate) fn shares(segments: usize, closed: bool) -> Vec<f64> {
|
||||
let first = usize::from(!closed);
|
||||
(first..segments).map(|k| k as f64 / segments as f64).collect()
|
||||
}
|
||||
|
||||
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point.divide";
|
||||
let mut ids = string_list(cmd, p, "curves")?;
|
||||
ids.extend(string_list(cmd, p, "curve")?);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "`curve` (or `curves`) must name the curves to divide"));
|
||||
}
|
||||
if ids.len() > MAX_CURVES {
|
||||
return Err(bad(cmd, format!("at most {MAX_CURVES} curves at once")));
|
||||
}
|
||||
let n = num(p, "segments")
|
||||
.filter(|n| n.fract() == 0.0 && *n >= 2.0 && *n <= MAX_SEGMENTS as f64)
|
||||
.ok_or_else(|| bad(cmd, format!("`segments` must be a whole number from 2 to {MAX_SEGMENTS}")))? as usize;
|
||||
let ((points, constraints), info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let mut curves = Vec::new();
|
||||
for id in &ids {
|
||||
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
let curve = sk.curves.get(c).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
if curve.infinite {
|
||||
return Err(bad(cmd, format!("`{id}` is an infinite line: it has no length to divide")));
|
||||
}
|
||||
if !curves.contains(&c) {
|
||||
curves.push(c);
|
||||
}
|
||||
}
|
||||
let total: usize = curves.iter().map(|c| shares(n, closed(sk, *c)).len()).sum();
|
||||
if total > MAX_POINTS {
|
||||
return Err(bad(cmd, format!("that would add {total} points; at most {MAX_POINTS} at once")));
|
||||
}
|
||||
let (mut points, mut constraints) = (Vec::new(), Vec::new());
|
||||
for c in curves {
|
||||
for at in shares(n, closed(sk, c)) {
|
||||
let id = sk.curves.get(c).map(|x| x.id.clone()).unwrap_or_default();
|
||||
let pos = point_along(sk, c, at).ok_or_else(|| bad(cmd, format!("`{id}` has no length to divide")))?;
|
||||
let q = sk.add_point(pos, None)?;
|
||||
constraints.push(add_c(sk, ConstraintKind::PointAlong { p: q, c, at })?);
|
||||
points.push(sk.points.get(q).map(|x| x.id.clone()).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Ok((points, constraints))
|
||||
})?;
|
||||
Ok(json!({"points": points, "constraints": constraints, "sketch": info}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_divide_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,212 +0,0 @@
|
||||
//! `sketch.point.divide` (#8969): points dividing sketch curves into equal lengths, held at their
|
||||
//! shares of the length as the curves change.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::Vec2Geom as _;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> Sketch {
|
||||
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn pt(sk: &Sketch, r: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(r).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
v.as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn line_ends(sk: &Sketch, l: &str) -> (Vec2, Vec2) {
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(sk.curve_index(l).unwrap()).map(|c| &c.kind) else { panic!("{l} is not a line") };
|
||||
(sk.point(*a).unwrap(), sk.point(*b).unwrap())
|
||||
}
|
||||
|
||||
/// Length along `poly` to the point of it nearest `q`, and how far `q` is from it.
|
||||
fn station(poly: &[Vec2], q: Vec2) -> (f64, f64) {
|
||||
let mut best = (0.0, f64::INFINITY);
|
||||
let mut run_len = 0.0;
|
||||
for w in poly.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 0.0 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = q.dist(a + d * t);
|
||||
if dist < best.1 {
|
||||
best = (run_len + d.len() * t, dist);
|
||||
}
|
||||
run_len += d.len();
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_divided_in_four_keeps_its_quarters_when_it_grows() {
|
||||
let mut s = new_sketch();
|
||||
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
|
||||
let r = run(&mut s, "sketch.point.divide", json!({"curve": l, "segments": 4}));
|
||||
let points = strs(&r["points"]);
|
||||
assert_eq!(points.len(), 3, "{r}");
|
||||
assert_eq!(strs(&r["created"]["points"]), points, "{r}");
|
||||
assert_eq!(strs(&r["constraints"]).len(), 3, "{r}");
|
||||
let sk = sketch(&s);
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
let want = Vec2::new(25.0 * (k + 1) as f64, 0.0);
|
||||
assert!(pt(&sk, id).dist(want) < 1e-9, "{id}: {:?}", pt(&sk, id));
|
||||
}
|
||||
// The line itself is unchanged: still one line between the same two points.
|
||||
assert_eq!(sk.curves.len(), 1);
|
||||
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::PointAlong { .. })).count(), 3);
|
||||
|
||||
// Lengthen and turn the line: the points keep their quarters.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "type": "length", "value": 200}));
|
||||
let (_, b) = line_ends(&sketch(&s), &l);
|
||||
run(&mut s, "sketch.drag", json!({"entity": format!("{l}.end"), "from": [b.x, b.y], "to": [b.x, b.y + 80.0]}));
|
||||
let sk = sketch(&s);
|
||||
let (a, b) = line_ends(&sk, &l);
|
||||
assert!((a.dist(b) - 200.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
assert!(b.y.abs() > 1.0, "the drag turned the line: {b:?}");
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
let want = a + (b - a) * ((k + 1) as f64 / 4.0);
|
||||
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_is_divided_by_its_length_and_a_circle_all_the_way_round() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "arc"}));
|
||||
let r = run(&mut s, "sketch.point.divide", json!({"curve": "arc", "segments": 3}));
|
||||
let sk = sketch(&s);
|
||||
let points = strs(&r["points"]);
|
||||
assert_eq!(points.len(), 2, "{r}");
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
let want = Vec2::from_angle(std::f64::consts::FRAC_PI_6 * (k + 1) as f64) * 20.0;
|
||||
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
|
||||
}
|
||||
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 10, "id": "c"}));
|
||||
let r = run(&mut s, "sketch.point.divide", json!({"curves": ["c"], "segments": 4}));
|
||||
let points = strs(&r["points"]);
|
||||
assert_eq!(points.len(), 4, "a closed curve gets as many points as pieces: {r}");
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["c"], "type": "diameter", "value": 30}));
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index("c").unwrap();
|
||||
let CurveKind::Circle { c, .. } = sk.curves[ci].kind else { panic!() };
|
||||
let centre = sk.point(c).unwrap();
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
let want = centre + Vec2::from_angle(std::f64::consts::FRAC_PI_2 * k as f64) * 15.0;
|
||||
assert!(pt(&sk, id).dist(want) < 1e-6, "{id}: {:?} want {want:?}", pt(&sk, id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_is_divided_into_equal_lengths_along_it() {
|
||||
let mut s = new_sketch();
|
||||
// Fit points bunched at one end: equal parameter steps would not be equal lengths.
|
||||
let sp = strs(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [2, 3], [4, 4], [40, 10], [80, -10]]}))["curves"])[0].clone();
|
||||
let r = run(&mut s, "sketch.point.divide", json!({"curve": sp, "segments": 5}));
|
||||
let points = strs(&r["points"]);
|
||||
assert_eq!(points.len(), 4, "{r}");
|
||||
let check = |sk: &Sketch| {
|
||||
let poly = sk.polyline(sk.curve_index(&sp).unwrap());
|
||||
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
let (along, off) = station(&poly, pt(sk, id));
|
||||
assert!(off < 0.05, "{id} is {off} off the spline");
|
||||
let want = total * (k + 1) as f64 / 5.0;
|
||||
assert!((along - want).abs() < 0.005 * total, "{id} at {along} of {total}, want {want}");
|
||||
}
|
||||
};
|
||||
check(&sketch(&s));
|
||||
// Move a fit point: the points slide to the new equal lengths.
|
||||
run(&mut s, "sketch.drag", json!({"entity": format!("{sp}.end"), "from": [80, -10], "to": [60, 30]}));
|
||||
let sk = sketch(&s);
|
||||
assert!(pt(&sk, &format!("{sp}.end")).dist(Vec2::new(60.0, 30.0)) < 1e-6);
|
||||
check(&sk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_curves_divide_in_one_undo_step() {
|
||||
let mut s = new_sketch();
|
||||
let a = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": false}))["curves"])[0].clone();
|
||||
let b = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [30, 20]], "infer": false}))["curves"])[0].clone();
|
||||
let before = sketch(&s).points.len();
|
||||
let r = run(&mut s, "sketch.point.divide", json!({"curves": [a, b], "segments": 10}));
|
||||
assert_eq!(strs(&r["points"]).len(), 18, "{r}");
|
||||
assert_eq!(sketch(&s).points.len(), before + 18);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(sketch(&s).points.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaking_a_divided_line_leaves_its_points_on_it() {
|
||||
let mut s = new_sketch();
|
||||
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
|
||||
let points = strs(&run(&mut s, "sketch.point.divide", json!({"curve": l, "segments": 4}))["points"]);
|
||||
run(&mut s, "sketch.break", json!({"curve": l, "points": [[60, 0]]}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.constraints.iter().all(|c| !matches!(c.kind, ConstraintKind::PointAlong { .. })), "the shares of the old length no longer hold");
|
||||
for (k, id) in points.iter().enumerate() {
|
||||
assert!(pt(&sk, id).dist(Vec2::new(25.0 * (k + 1) as f64, 0.0)) < 1e-6, "{id} moved: {:?}", pt(&sk, id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_divide_parameters_are_refused() {
|
||||
let mut s = new_sketch();
|
||||
let l = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0]], "infer": false}))["curves"])[0].clone();
|
||||
let before = sketch(&s);
|
||||
for p in [
|
||||
json!({"curve": l, "segments": 1}),
|
||||
json!({"curve": l, "segments": 0}),
|
||||
json!({"curve": l, "segments": -3}),
|
||||
json!({"curve": l, "segments": 2.5}),
|
||||
json!({"curve": l, "segments": 1e300}),
|
||||
json!({"curve": l, "segments": 501}),
|
||||
json!({"curve": l, "segments": "four"}),
|
||||
json!({"curve": l}),
|
||||
json!({"segments": 4}),
|
||||
json!({"curve": "nope", "segments": 4}),
|
||||
json!({"curve": 7, "segments": 4}),
|
||||
json!({"curves": [l, 3], "segments": 4}),
|
||||
json!({"curves": vec![l.clone(); 201], "segments": 4}),
|
||||
] {
|
||||
assert!(s.execute("sketch.point.divide", &p).is_err(), "{p}");
|
||||
assert_eq!(sketch(&s), before, "{p} changed the sketch");
|
||||
}
|
||||
// Five hundred segments on each of five curves is more points than one command adds.
|
||||
let many: Vec<String> = (0..5)
|
||||
.map(|i| strs(&run(&mut s, "sketch.line", json!({"points": [[0, i * 5 + 10], [50, i * 5 + 10]], "infer": false}))["curves"])[0].clone())
|
||||
.collect();
|
||||
assert!(s.execute("sketch.point.divide", &json!({"curves": many, "segments": 500})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_out_of_range_is_not_a_constraint() {
|
||||
let mut sk = Sketch::new();
|
||||
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap();
|
||||
let l = sk.add_curve(CurveKind::Line { a, b }, None).unwrap();
|
||||
let p = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap();
|
||||
for at in [f64::NAN, f64::INFINITY, -0.1, 1.5] {
|
||||
assert!(sk.add_constraint(ConstraintKind::PointAlong { p, c: l, at }, None).is_err(), "{at}");
|
||||
}
|
||||
assert!(sk.add_constraint(ConstraintKind::PointAlong { p: a, c: l, at: 0.5 }, None).is_err(), "a line's own end");
|
||||
assert!(sk.add_constraint(ConstraintKind::PointAlong { p, c: l, at: 0.5 }, None).is_ok());
|
||||
}
|
||||
@@ -295,18 +295,6 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64], cmd: &str) -> Result<(Vec<us
|
||||
Ok((pieces, pts))
|
||||
}
|
||||
|
||||
/// Points placed at a share of curve `ci`'s length (Divide Curve) stay on what is left of it, free to
|
||||
/// slide: a trim or a break changes the length they share, and holding the shares would move them.
|
||||
pub(super) fn loosen_along(sk: &mut Sketch, ci: usize) {
|
||||
for k in &mut sk.constraints {
|
||||
if let ConstraintKind::PointAlong { p, c, .. } = k.kind
|
||||
&& c == ci
|
||||
{
|
||||
k.kind = ConstraintKind::PointOnCurve { p, c };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the pieces of a split tied together: lines collinear, arcs on one circle.
|
||||
fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> {
|
||||
for w in pieces.windows(2) {
|
||||
@@ -686,7 +674,6 @@ fn hold_cut(sk: &mut Sketch, before: Option<&Sketch>, midpoints: &[usize], pi: u
|
||||
/// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the
|
||||
/// removed piece as a polyline.
|
||||
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Value, Vec<Vec2>)> {
|
||||
loosen_along(sk, ci);
|
||||
let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default();
|
||||
if sk.curves.get(ci).is_some_and(|c| c.infinite) {
|
||||
return Err(bad(cmd, "an infinite line has no ends to trim back to: make it a normal line first"));
|
||||
@@ -929,7 +916,6 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
separate_end(sk, ci, end)?;
|
||||
return Ok(ids_of(sk, &[ci]));
|
||||
}
|
||||
loosen_along(sk, ci);
|
||||
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
|
||||
let r = if let Some(points) = &points {
|
||||
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
|
||||
@@ -1383,7 +1369,7 @@ fn arc_end_held(sk: &Sketch, ci: usize, end: usize) -> bool {
|
||||
use solidcraft_sketch::ConstraintKind::*;
|
||||
match c.kind {
|
||||
Coincident { p, q } => p == end || q == end,
|
||||
Fix { p } | PointOnCurve { p, .. } | PointAlong { p, .. } | Midpoint { p, .. } => p == end,
|
||||
Fix { p } | PointOnCurve { p, .. } | Midpoint { p, .. } => p == end,
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => p == end || q == end,
|
||||
_ => false,
|
||||
}
|
||||
@@ -1501,7 +1487,7 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result<String> {
|
||||
match c.kind {
|
||||
Coincident { p, q } => p == end || q == end,
|
||||
Fix { p } => p == end,
|
||||
PointOnCurve { p, .. } | PointAlong { p, .. } => p == end,
|
||||
PointOnCurve { p, .. } => p == end,
|
||||
Midpoint { p, .. } => p == end,
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } => p == end || q == end,
|
||||
Symmetric { p, q, .. } => p == end || q == end,
|
||||
|
||||
@@ -6,9 +6,7 @@ use solidcraft_render::{Camera, StandardView};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::schema::view_navigation::{
|
||||
AmbientOcclusionParams, AmbientOcclusionResult, CameraReply, GraphicsPreset, GraphicsPresetParams, GraphicsPresetResult, HistoryDock,
|
||||
HistoryLayoutParams, HistoryLayoutResult, LayoutMode, LayoutParams, LayoutResult, NavigateParams, NavigateResult, NavigatorParams,
|
||||
NormalToParams, PresetQuality, RenderBrowser, RenderFocus, RenderLive, RenderSelect, RibbonTabsOnly, RibbonTabsOnlyParams, StandardViewParams,
|
||||
CameraReply, NavigatorParams, NormalToParams, RenderBrowser, RenderFocus, RenderSelect, RibbonTabsOnly, RibbonTabsOnlyParams, StandardViewParams,
|
||||
TabStep, TabStepParams, ZoomTool,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
@@ -17,17 +15,11 @@ use crate::{Result, Sel, Session};
|
||||
const ZOOM_TOOL_CONTRACT: TypedContract<ZoomTool> = TypedContract::new();
|
||||
const FOCUS_CONTRACT: TypedContract<RenderFocus> = TypedContract::new();
|
||||
const NAVIGATOR_CONTRACT: TypedContract<NavigatorParams> = TypedContract::new();
|
||||
const RENDER_LIVE_CONTRACT: TypedContract<RenderLive> = TypedContract::new();
|
||||
const BROWSER_CONTRACT: TypedContract<RenderBrowserRequest> = TypedContract::new();
|
||||
const TAB_STEP_CONTRACT: TypedContract<TabStepParams> = TypedContract::new();
|
||||
const STANDARD_VIEW_CONTRACT: TypedContract<StandardViewParams> = TypedContract::new();
|
||||
const NORMAL_TO_CONTRACT: TypedContract<NormalToParams> = TypedContract::new();
|
||||
const RIBBON_TABS_ONLY_CONTRACT: TypedContract<RibbonTabsOnlyParams> = TypedContract::new();
|
||||
const LAYOUT_CONTRACT: TypedContract<LayoutParams> = TypedContract::new();
|
||||
const HISTORY_LAYOUT_CONTRACT: TypedContract<HistoryLayoutParams> = TypedContract::new();
|
||||
const GRAPHICS_PRESET_CONTRACT: TypedContract<GraphicsPresetParams> = TypedContract::new();
|
||||
const AMBIENT_OCCLUSION_CONTRACT: TypedContract<AmbientOcclusionParams> = TypedContract::new();
|
||||
const NAVIGATE_CONTRACT: TypedContract<NavigateParams> = TypedContract::new();
|
||||
|
||||
// Both existing request forms share the Browser command and its published schema.
|
||||
#[derive(serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
|
||||
@@ -103,10 +95,6 @@ CommandSpec::new("view.render_browser", "Render Browser", crate::checked!("view.
|
||||
CommandSpec::new("view.render_inspector", "Render Inspector", render_inspector)
|
||||
.icon("inspect").noundo().doc_only()
|
||||
.params("visible: bool → {visible}; per-document Inspector visibility; never edits the design or opens a dialog"),
|
||||
CommandSpec::new("view.render_live", "Pause or Resume Live Render", crate::checked!("view.render_live", render_live))
|
||||
.icon("render_image").noundo().doc_only()
|
||||
.contract(&RENDER_LIVE_CONTRACT).output(output_schema_for::<RenderLive>)
|
||||
.params("paused: bool → {paused}; pause or resume this document's live Render preview; headless validation only, never saves settings or changes a final image"),
|
||||
CommandSpec::new("view.render_select", "Render selection", crate::checked!("view.render_select", render_select))
|
||||
.contract(&RENDER_SELECT).output(output_schema_for::<RenderSelect>)
|
||||
.icon("select").noundo().doc_only()
|
||||
@@ -122,24 +110,22 @@ CommandSpec::new("view.render_dock", "Move Render Dock", crate::checked!("view.r
|
||||
.contract(&RENDER_DOCK_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::view_navigation::RenderDock>)
|
||||
.params("panel: inspector|browser; side: left|right → {panel, side}; Render dock UI preference; headless echo only, no document edits or undo"),
|
||||
CommandSpec::new("view.layout", "Workspace layout", crate::checked!("view.layout", layout))
|
||||
.icon("settings").noundo().doc_only().contract(&LAYOUT_CONTRACT).output(output_schema_for::<LayoutResult>).params("mode: floating|compact → validated workspace layout; display only, never opens a dialog"),
|
||||
CommandSpec::new("view.history_layout", "History layout", crate::checked!("view.history_layout", history_layout))
|
||||
.icon("settings").noundo().doc_only().contract(&HISTORY_LAYOUT_CONTRACT).output(output_schema_for::<HistoryLayoutResult>).params("width?: 1..1000000; dock?: bottom|left|under_browser|browser (browser: the history in the browser, no timeline); collapsed?: bool → validated layout request; display only, never opens a dialog"),
|
||||
CommandSpec::new("view.graphics_preset", "Graphics Preset", crate::checked!("view.graphics_preset", graphics_preset))
|
||||
.icon("settings").noundo().doc_only().contract(&GRAPHICS_PRESET_CONTRACT).output(output_schema_for::<GraphicsPresetResult>).params("preset: performance|quality → preset, enabled, display_quality, supersampling, supersampling_adaptive; Quality: AO, Fine, adaptive 2x SSAA; Performance: no AO, Normal, no SSAA"),
|
||||
CommandSpec::new("view.ambient_occlusion", "Ambient Occlusion", crate::checked!("view.ambient_occlusion", ambient_occlusion))
|
||||
.icon("settings").noundo().doc_only().contract(&AMBIENT_OCCLUSION_CONTRACT).output(output_schema_for::<AmbientOcclusionResult>).params("enabled: bool → enabled; apply soft contact shading in the GPU viewport"),
|
||||
CommandSpec::new("view.layout", "Workspace layout", layout)
|
||||
.icon("settings").noundo().doc_only().params("mode: floating|compact → validated workspace layout; display only, never opens a dialog"),
|
||||
CommandSpec::new("view.history_layout", "History layout", history_layout)
|
||||
.icon("settings").noundo().doc_only().params("width?: 1..1000000; dock?: bottom|left|under_browser|browser (browser: the history in the browser, no timeline); collapsed?: bool → validated layout request; display only, never opens a dialog"),
|
||||
CommandSpec::new("view.graphics_preset", "Graphics Preset", graphics_preset)
|
||||
.icon("settings").noundo().doc_only().params("preset: performance|quality → preset, enabled, display_quality, supersampling, supersampling_adaptive; Quality: AO, Fine, adaptive 2x SSAA; Performance: no AO, Normal, no SSAA"),
|
||||
CommandSpec::new("view.ambient_occlusion", "Ambient Occlusion", ambient_occlusion)
|
||||
.icon("settings").noundo().doc_only().params("enabled: bool → enabled; apply soft contact shading in the GPU viewport"),
|
||||
CommandSpec::new("view.perspective", "Perspective view", perspective)
|
||||
.icon("perspective").noundo().doc_only().params("enabled: bool → enabled; draw the view in perspective, or orthographic when false; a sketch being edited is always shown orthographic"),
|
||||
CommandSpec::new("view.angle_readout", "View Angles", crate::checked!("view.angle_readout", angle_readout))
|
||||
.icon("camera").noundo().doc_only().contract(&ANGLE_READOUT_CONTRACT).output(crate::schema::output_schema_for::<crate::schema::view_navigation::AngleReadoutResult>).params("enabled: bool → enabled; show the view's pitch, yaw and roll (degrees, as view.apply's camera in radians) and Ortho or Persp under the orientation gizmo; display only, never opens a dialog"),
|
||||
CommandSpec::new("view.navigate", "Navigate View", crate::checked!("view.navigate", navigate))
|
||||
CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
.icon("orbit")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&NAVIGATE_CONTRACT)
|
||||
.output(output_schema_for::<NavigateResult>)
|
||||
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90), fit?: bool (frame the whole model from the new direction as well, as home does; default false); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), free_orbit?: bool (orbit through poles, default false), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?"),
|
||||
CommandSpec::new("view.normal_to", "Normal to Selection", crate::checked!("view.normal_to", normal_to))
|
||||
.icon("camera")
|
||||
@@ -385,11 +371,6 @@ fn render_focus(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(request))
|
||||
}
|
||||
|
||||
fn render_live(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderLive = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_live", e.to_string()))?;
|
||||
Ok(json!(request))
|
||||
}
|
||||
|
||||
fn render_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderSelect = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_select", e.to_string()))?;
|
||||
let result = serde_json::to_value(request).map_err(|e| bad("view.render_select", e.to_string()))?;
|
||||
@@ -476,39 +457,31 @@ fn layout(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.and_then(Value::as_str)
|
||||
.filter(|mode| matches!(*mode, "floating" | "compact"))
|
||||
.ok_or_else(|| bad("view.layout", "mode must be floating or compact"))?;
|
||||
let mode = if mode == "compact" { LayoutMode::Compact } else { LayoutMode::Floating };
|
||||
typed_reply("view.layout", LayoutResult { mode, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A command's typed reply as the JSON it sends.
|
||||
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
Ok(json!({"mode": mode}))
|
||||
}
|
||||
|
||||
fn history_layout(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.history_layout";
|
||||
let mut result = HistoryLayoutResult { width: None, dock: None, collapsed: None, param_warnings: Vec::new() };
|
||||
let mut result = serde_json::Map::new();
|
||||
if let Some(value) = p.get("width") {
|
||||
let width = value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (1.0..=1_000_000.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "width must be finite and within 1..1000000"))?;
|
||||
result.width = Some(width);
|
||||
result.insert("width".into(), json!(width));
|
||||
}
|
||||
if let Some(value) = p.get("dock") {
|
||||
let dock = match value.as_str() {
|
||||
Some("bottom") => HistoryDock::Bottom,
|
||||
Some("left") => HistoryDock::Left,
|
||||
Some("under_browser") => HistoryDock::UnderBrowser,
|
||||
Some("browser") => HistoryDock::Browser,
|
||||
_ => return Err(bad(cmd, "dock must be bottom, left, under_browser or browser")),
|
||||
};
|
||||
result.dock = Some(dock);
|
||||
let dock = value
|
||||
.as_str()
|
||||
.filter(|v| matches!(*v, "bottom" | "left" | "under_browser" | "browser"))
|
||||
.ok_or_else(|| bad(cmd, "dock must be bottom, left, under_browser or browser"))?;
|
||||
result.insert("dock".into(), json!(dock));
|
||||
}
|
||||
if let Some(value) = p.get("collapsed") {
|
||||
result.collapsed = Some(value.as_bool().ok_or_else(|| bad(cmd, "collapsed must be a boolean"))?);
|
||||
let collapsed = value.as_bool().ok_or_else(|| bad(cmd, "collapsed must be a boolean"))?;
|
||||
result.insert("collapsed".into(), json!(collapsed));
|
||||
}
|
||||
typed_reply(cmd, result)
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -576,7 +549,7 @@ fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "unknown navigation action")),
|
||||
}
|
||||
}
|
||||
typed_reply(cmd, NavigateResult { camera: cam, fit, continuous: None, param_warnings: Vec::new() })
|
||||
Ok(json!({"camera": cam, "fit": fit}))
|
||||
}
|
||||
|
||||
/// Direct manipulation is stateless, bounded and independent of document evaluation.
|
||||
@@ -628,7 +601,7 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
if !cam.is_valid() {
|
||||
return Err(bad(cmd, "motion produced an invalid camera"));
|
||||
}
|
||||
typed_reply(cmd, NavigateResult { camera: cam, fit: false, continuous: Some(true), param_warnings: Vec::new() })
|
||||
Ok(json!({"camera":cam, "fit":false, "continuous":true}))
|
||||
}
|
||||
|
||||
/// At a halfway tie prefer the heading nearer the front plan view.
|
||||
@@ -1077,7 +1050,7 @@ mod tests {
|
||||
|
||||
fn ambient_occlusion(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let enabled = p.get("enabled").and_then(Value::as_bool).ok_or_else(|| bad("view.ambient_occlusion", "enabled must be a boolean"))?;
|
||||
typed_reply("view.ambient_occlusion", AmbientOcclusionResult { enabled, param_warnings: Vec::new() })
|
||||
Ok(json!({"enabled": enabled}))
|
||||
}
|
||||
|
||||
fn perspective(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1156,23 +1129,14 @@ mod ambient_occlusion_tests {
|
||||
|
||||
/// One preset for contact shading, tessellation and supersampling. MSAA stays independent.
|
||||
fn graphics_preset(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = match p.get("preset").and_then(Value::as_str) {
|
||||
Some("quality") => GraphicsPreset::Quality,
|
||||
Some("performance") => GraphicsPreset::Performance,
|
||||
let preset = p.get("preset").and_then(Value::as_str);
|
||||
let enabled = match preset {
|
||||
Some("quality") => true,
|
||||
Some("performance") => false,
|
||||
_ => return Err(bad("view.graphics_preset", "preset must be performance or quality")),
|
||||
};
|
||||
let enabled = preset == GraphicsPreset::Quality;
|
||||
typed_reply(
|
||||
"view.graphics_preset",
|
||||
GraphicsPresetResult {
|
||||
preset,
|
||||
enabled,
|
||||
display_quality: if enabled { PresetQuality::Fine } else { PresetQuality::Normal },
|
||||
supersampling: if enabled { 2.0 } else { 1.0 },
|
||||
supersampling_adaptive: true,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
Ok(json!({"preset": preset, "enabled": enabled, "display_quality": if enabled { "fine" } else { "normal" },
|
||||
"supersampling": if enabled { 2.0 } else { 1.0 }, "supersampling_adaptive": true}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1234,7 +1198,7 @@ fn render_workspace_focus_and_browser_validate_without_side_effects() {
|
||||
let camera = session.camera;
|
||||
let document = session.doc.clone();
|
||||
let before = (session.undo.len(), session.redo.len(), session.revision, session.evaluations, session.is_dirty());
|
||||
for (id, field) in [("view.render_focus", "on"), ("view.render_browser", "open"), ("view.render_live", "paused")] {
|
||||
for (id, field) in [("view.render_focus", "on"), ("view.render_browser", "open")] {
|
||||
let command = crate::find_command(id).unwrap();
|
||||
assert!(command.contract.is_some() && command.output.is_some());
|
||||
for on in [true, false] {
|
||||
|
||||
@@ -27,7 +27,6 @@ pub mod product_scene;
|
||||
pub mod recovery;
|
||||
pub mod render_jobs;
|
||||
pub mod render_lights;
|
||||
pub mod render_preparation;
|
||||
pub mod sample;
|
||||
pub mod schema;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1814,14 +1813,7 @@ impl Session {
|
||||
fn define_inline(&mut self, id: &str, mut v: Value) -> Result<Value> {
|
||||
let mut doc = (*self.doc).clone();
|
||||
let defined = doc.define_inline().map_err(|e| EngineError::BadParams { cmd: id.to_string(), msg: e.to_string() })?;
|
||||
let Some(first) = defined.first() else {
|
||||
// Only definitions of parameters that already exist, typed again as they were made:
|
||||
// nothing new to report, but the text now names the parameter.
|
||||
if doc != *self.doc {
|
||||
self.doc = Arc::new(doc);
|
||||
}
|
||||
return Ok(v);
|
||||
};
|
||||
let Some(first) = defined.first() else { return Ok(v) };
|
||||
if let Some(out) = v.as_object_mut() {
|
||||
out.insert("defined".into(), serde_json::json!(first.name));
|
||||
out.insert("definitions".into(), serde_json::json!(defined));
|
||||
|
||||
@@ -32,6 +32,13 @@ fn owner() -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Execution {
|
||||
pub executor: solidcraft_render::ExecutorId,
|
||||
pub options: solidcraft_render::job::JobOptions,
|
||||
pub environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
|
||||
pub shot: Option<String>,
|
||||
}
|
||||
|
||||
struct LiveJob {
|
||||
handle: JobHandle,
|
||||
shot: Option<String>,
|
||||
@@ -61,9 +68,6 @@ pub(crate) struct BatchShot {
|
||||
pub struct RenderQueueState {
|
||||
pub running: usize,
|
||||
pub waiting: usize,
|
||||
/// A real native preparation claim, separate from an invented running job.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub preparing: bool,
|
||||
pub job: Option<JobId>,
|
||||
pub shot: Option<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -81,10 +85,8 @@ pub(crate) struct Jobs {
|
||||
completed: VecDeque<Completed>,
|
||||
selected: Option<solidcraft_render::job::JobId>,
|
||||
waiting: VecDeque<BatchShot>,
|
||||
pub(crate) batch_job: Option<JobId>,
|
||||
pub(crate) batch_error: Option<String>,
|
||||
pub(crate) native_batch: Option<crate::render_preparation::batch::NativeState>,
|
||||
pub(crate) native_epoch: u64,
|
||||
batch_job: Option<JobId>,
|
||||
batch_error: Option<String>,
|
||||
comparison: Option<[Option<JobId>; 2]>,
|
||||
}
|
||||
|
||||
@@ -104,8 +106,6 @@ impl Default for Jobs {
|
||||
waiting: VecDeque::new(),
|
||||
batch_job: None,
|
||||
batch_error: None,
|
||||
native_batch: None,
|
||||
native_epoch: 0,
|
||||
comparison: None,
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,6 @@ impl Default for Jobs {
|
||||
|
||||
impl Drop for Jobs {
|
||||
fn drop(&mut self) {
|
||||
if let Some(native) = &self.native_batch {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
for job in &self.live {
|
||||
let _ = job.handle.cancel();
|
||||
}
|
||||
@@ -123,9 +120,6 @@ impl Drop for Jobs {
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub(crate) fn owner_key(&self) -> u64 {
|
||||
self.owner
|
||||
}
|
||||
pub(crate) fn set_comparison(&mut self, sides: Option<[Option<JobId>; 2]>) {
|
||||
self.comparison = sides;
|
||||
if let Some([left, right]) = sides {
|
||||
@@ -193,66 +187,24 @@ impl Jobs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_prepared(
|
||||
pub(crate) fn start_with_environment(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
prepared: solidcraft_render::job::PreparedSubmission,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: solidcraft_render::job::ImageRequest,
|
||||
replace: bool,
|
||||
shot: Option<String>,
|
||||
execution: Execution,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
self.try_start_prepared(revision, &mut Some(prepared), replace, shot)
|
||||
}
|
||||
|
||||
pub(crate) fn try_start_prepared(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
slot: &mut Option<solidcraft_render::job::PreparedSubmission>,
|
||||
replace: bool,
|
||||
shot: Option<String>,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
self.try_start_prepared_inner(revision, slot, replace, shot, None)
|
||||
}
|
||||
pub(crate) fn try_start_prepared_inner(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
slot: &mut Option<solidcraft_render::job::PreparedSubmission>,
|
||||
replace: bool,
|
||||
shot: Option<String>,
|
||||
ticket: Option<crate::render_preparation::batch::Ticket>,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
let prepared = slot.as_ref().ok_or_else(|| JobError::new(ErrorCode::InvalidRequest, "prepared render was already consumed"))?;
|
||||
let batch_next = if let Some(ticket) = ticket {
|
||||
let native = self
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.filter(|n| n.ticket == ticket && n.claimed && !n.cancel.is_cancelled())
|
||||
.ok_or_else(|| JobError::new(ErrorCode::Cancelled, "native shot queue claim is no longer current"))?;
|
||||
if replace || native.next >= native.total {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid native shot queue front"));
|
||||
}
|
||||
Some(native.next + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if ticket.is_none() && (self.batch_job.is_some() || self.native_batch.is_some()) {
|
||||
let Execution { executor, options, environment, shot } = execution;
|
||||
if self.batch_job.is_some() {
|
||||
if !replace {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "cancel the shot queue before starting another render"));
|
||||
}
|
||||
self.waiting.clear();
|
||||
self.batch_job = None;
|
||||
if let Some(native) = self.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
}
|
||||
self.collect()?;
|
||||
let host = self
|
||||
.host_for(prepared.executor())
|
||||
.ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?
|
||||
.clone();
|
||||
if !host.same_host(prepared.host()) {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "prepared render host changed before enqueue"));
|
||||
}
|
||||
prepared.check_cancel()?;
|
||||
let host = self.host_for(&executor).ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?.clone();
|
||||
if replace {
|
||||
for job in self.live.drain(..) {
|
||||
let mut status = job.handle.cancel()?;
|
||||
@@ -276,18 +228,18 @@ impl Jobs {
|
||||
}
|
||||
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
|
||||
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
|
||||
let handle = host.try_submit_prepared(JobId { session: self.owner, serial, generation }, revision, slot)?;
|
||||
let handle = host.submit_with_environment_and_options(
|
||||
JobId { session: self.owner, serial, generation },
|
||||
revision,
|
||||
scene,
|
||||
request,
|
||||
options,
|
||||
environment,
|
||||
)?;
|
||||
let status = handle.status()?;
|
||||
self.serial = serial;
|
||||
self.generation = generation;
|
||||
self.live.push(LiveJob { handle, shot });
|
||||
if let Some(next) = batch_next {
|
||||
if let Some(native) = &mut self.native_batch {
|
||||
native.next = next;
|
||||
native.claimed = false;
|
||||
}
|
||||
self.batch_job = Some(status.job);
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
@@ -296,7 +248,7 @@ impl Jobs {
|
||||
source_id: JobId,
|
||||
presentation: solidcraft_render::job::presentation::Presentation,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
if self.batch_job.is_some() || self.native_batch.is_some() {
|
||||
if self.batch_job.is_some() {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "cancel the shot queue before regrading"));
|
||||
}
|
||||
self.collect()?;
|
||||
@@ -342,38 +294,6 @@ impl Jobs {
|
||||
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
/// Metadata-only identity validation for deferred native cancellation.
|
||||
/// Do not collect terminal work, clone a handle/frame or advance a queue on
|
||||
/// this read path. Actual cancellation still uses its ordinary handler.
|
||||
pub(crate) fn check_retained_or_historical_job(&self, id: JobId) -> Result<(), JobError> {
|
||||
self.check_owner(id)?;
|
||||
if self.live.iter().any(|job| job.handle.id() == id)
|
||||
|| self.completed.iter().any(|result| result.handle.id() == id)
|
||||
|| self.history.iter().any(|status| status.job == id)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot existing status without collecting or evicting registry owners.
|
||||
/// Native queue polling uses this path; ordinary status/result APIs retain
|
||||
/// their existing explicit collection semantics.
|
||||
pub(crate) fn peek_status(&self, id: JobId) -> Result<JobStatus, JobError> {
|
||||
self.check_owner(id)?;
|
||||
if let Some(handle) =
|
||||
self.live.iter().map(|job| &job.handle).chain(self.completed.iter().map(|result| &result.handle)).find(|handle| handle.id() == id)
|
||||
{
|
||||
return handle.status();
|
||||
}
|
||||
self.history
|
||||
.iter()
|
||||
.find(|status| status.job == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
pub(crate) fn status(&mut self, id: JobId) -> Result<JobStatus, JobError> {
|
||||
self.check_owner(id)?;
|
||||
self.collect()?;
|
||||
@@ -428,12 +348,7 @@ impl crate::Session {
|
||||
let active = self.render_jobs.batch_job.and_then(|id| self.render_jobs.live.iter().find(|job| job.handle.id() == id));
|
||||
RenderQueueState {
|
||||
running: self.render_jobs.live.iter().filter(|job| job.handle.status().is_ok_and(|status| !status.progress.phase.terminal())).count(),
|
||||
waiting: self
|
||||
.render_jobs
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.map_or(self.render_jobs.waiting.len(), |n| n.total.saturating_sub(n.next + usize::from(n.claimed))),
|
||||
preparing: self.render_jobs.native_batch.as_ref().is_some_and(|n| n.claimed),
|
||||
waiting: self.render_jobs.waiting.len(),
|
||||
job: self.render_jobs.batch_job,
|
||||
shot: active.and_then(|job| job.shot.clone()),
|
||||
error: self.render_jobs.batch_error.clone(),
|
||||
@@ -441,29 +356,13 @@ impl crate::Session {
|
||||
}
|
||||
|
||||
pub fn render_batch_ready(&self) -> std::result::Result<(), String> {
|
||||
self.render_batch_ready_with_claim(false)
|
||||
}
|
||||
|
||||
/// Recheck ordinary Batch enablement without treating this exact first
|
||||
/// preparation's own fixed claim as a competing queue.
|
||||
pub(crate) fn render_batch_ready_for_first(&self, ticket: crate::render_preparation::batch::Ticket) -> std::result::Result<(), String> {
|
||||
if !ticket.matches(self) || !self.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket && n.claimed && n.next == 0) {
|
||||
return Err("native first shot claim is no longer current".into());
|
||||
}
|
||||
self.render_batch_ready_with_claim(true)
|
||||
}
|
||||
|
||||
fn render_batch_ready_with_claim(&self, own_first_claim: bool) -> std::result::Result<(), String> {
|
||||
if self.doc.render.setups.is_empty() {
|
||||
return Err("no saved shots to render".into());
|
||||
}
|
||||
if self.doc.render.setups.len() > 32 {
|
||||
return Err("render all shots is limited to 32 shots".into());
|
||||
}
|
||||
if (!own_first_claim && self.render_jobs.native_batch.is_some())
|
||||
|| self.render_jobs.batch_job.is_some()
|
||||
|| self.render_queue_state().running != 0
|
||||
{
|
||||
if self.render_jobs.batch_job.is_some() || self.render_queue_state().running != 0 {
|
||||
return Err("a render or shot queue is already active".into());
|
||||
}
|
||||
Ok(())
|
||||
@@ -506,42 +405,6 @@ impl crate::Session {
|
||||
|
||||
/// Non-blocking queue scheduling. The existing host still owns all render work.
|
||||
pub fn advance_render_batch(&mut self) -> crate::Result<()> {
|
||||
// Native foreground/background status polling never captures a shot.
|
||||
if self.render_jobs.native_batch.is_some() {
|
||||
if self.render_jobs.native_batch.as_ref().is_some_and(|n| !n.ticket.matches(self)) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.batch_job = None;
|
||||
self.render_jobs.batch_error = Some("native shot queue belongs to a previous document owner".into());
|
||||
return Ok(());
|
||||
}
|
||||
if self.render_jobs.native_batch.as_ref().is_some_and(|n| n.cancel.is_cancelled()) {
|
||||
self.render_jobs.native_batch = None;
|
||||
self.render_jobs.batch_job = None;
|
||||
self.render_jobs.batch_error = Some("native shot queue was cancelled".into());
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(job) = self.render_jobs.batch_job {
|
||||
let status = self.render_jobs.peek_status(job).map_err(job_error)?;
|
||||
if status.progress.phase.terminal() {
|
||||
let failed = status.progress.phase != Phase::Completed;
|
||||
let finished = self.render_jobs.native_batch.as_ref().is_some_and(|n| n.next == n.total && !n.claimed);
|
||||
if failed || finished {
|
||||
if let Some(native) = self.render_jobs.native_batch.take()
|
||||
&& failed
|
||||
{
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.batch_job = None;
|
||||
if failed {
|
||||
self.render_jobs.batch_error = status.error.map(|e| e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(job) = self.render_jobs.batch_job else { return Ok(()) };
|
||||
let status = self.render_jobs.status(job).map_err(job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
@@ -558,25 +421,11 @@ impl crate::Session {
|
||||
|
||||
pub(crate) fn cancel_render_batch(&mut self, job: JobId) {
|
||||
if self.render_jobs.batch_job == Some(job) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.waiting.clear();
|
||||
self.render_jobs.batch_job = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_native_preparation(&mut self) -> bool {
|
||||
if self.render_jobs.batch_job.is_none() && self.render_jobs.native_batch.as_ref().is_some_and(|n| n.claimed) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the UI draft's label alongside an unnamed active request, never in the design.
|
||||
pub fn label_render_job(&mut self, id: JobId, shot: &str) -> crate::Result<()> {
|
||||
solidcraft_doc::render_settings::RenderSettings::validate_name(shot).map_err(|error| crate::EngineError::Other(error.to_string()))?;
|
||||
@@ -745,291 +594,4 @@ mod displayed_result_tests {
|
||||
let reply = s.execute("render.start", &json!({"settings":{"width":8,"height":8}})).unwrap();
|
||||
assert!(s.execute("render.result.pin", &json!({"job":reply["job"]})).is_err(), "a pending result is never exportable");
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn first_native_plan(s: &mut crate::Session) -> (crate::render_preparation::batch::Plan, JobId) {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
let mut params = Some(json!({}));
|
||||
let claim = batch::claim(s, &mut params, &mut None, CancelToken::default()).unwrap();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let (returning, returned) = std::sync::mpsc::sync_channel(1);
|
||||
let thread = std::thread::spawn(move || {
|
||||
batch::with_scope(claim, |claim| {
|
||||
ready.send(batch::prepare(claim)).unwrap();
|
||||
drop(returned.recv().unwrap());
|
||||
});
|
||||
});
|
||||
let mut prepared = receiver.recv().unwrap();
|
||||
assert_eq!(batch::finish_retained(s, &mut prepared).unwrap(), json!({"queued":2}));
|
||||
let plan = batch::take_plan(&mut prepared).unwrap().unwrap();
|
||||
returning.send(prepared).unwrap();
|
||||
thread.join().unwrap();
|
||||
(plan, s.render_queue_state().job.unwrap())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn native_next_discard_uses_cached_status_but_hostile_missing_status_fails_only_its_exact_ticket() {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
for hostile_missing_status in [false, true] {
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let old = complete(&mut s, [11, 23, 37]);
|
||||
let old_handle = s.render_job_handle(old).unwrap();
|
||||
let old_frame = old_handle.frame().unwrap().unwrap();
|
||||
let old_png = old_frame.png().to_vec();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, first) = first_native_plan(&mut s);
|
||||
for _ in 0..10_000 {
|
||||
if s.render_job_handle(first).unwrap().status().unwrap().progress.phase.terminal() {
|
||||
break;
|
||||
}
|
||||
host.pump(4096).unwrap();
|
||||
}
|
||||
assert_eq!(s.render_job_handle(first).unwrap().status().unwrap().progress.phase, Phase::Completed);
|
||||
let retained = s.render_retained_results().unwrap();
|
||||
let index = retained.iter().position(|r| r.frame.job == first).unwrap();
|
||||
s.execute("render.result.discard", &json!({"index":index})).unwrap();
|
||||
assert!(s.render_job_handle(first).is_err());
|
||||
assert_eq!(
|
||||
s.render_jobs.status(first).unwrap().progress.phase,
|
||||
Phase::Completed,
|
||||
"ordinary Discard preserves the recent status cache and is not an UnknownJob reproducer"
|
||||
);
|
||||
if hostile_missing_status {
|
||||
// Defensive hostile registry-state injection, not an ordinary
|
||||
// UI Discard claim: public regrade/replace cannot rotate this
|
||||
// status while preserving the same native ticket.
|
||||
s.render_jobs.history.retain(|status| status.job != first);
|
||||
}
|
||||
let bytes = plan.retained_bytes();
|
||||
let held = host.admitted_bytes();
|
||||
let mut slot = Some(plan);
|
||||
let result = batch::claim_next(&mut s, &mut slot, CancelToken::default());
|
||||
if hostile_missing_status {
|
||||
let error = match result {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("missing current status must refuse"),
|
||||
};
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("render job is no longer retained"));
|
||||
assert_eq!(s.render_queue_state().error.as_deref(), Some(message.as_str()));
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, false));
|
||||
assert!(s.render_queue_state().job.is_none());
|
||||
assert!(slot.is_some());
|
||||
assert_eq!(host.admitted_bytes(), held, "metadata failure cannot release the retained Plan backing");
|
||||
assert_eq!(old_frame.png(), old_png);
|
||||
assert_eq!(s.render_jobs.status(old).unwrap().progress.phase, Phase::Completed);
|
||||
let token = CancelToken::default();
|
||||
let replacement = batch::claim(&mut s, &mut Some(json!({})), &mut None, token.clone()).unwrap();
|
||||
let current = json!(s.render_queue_state());
|
||||
assert!(batch::claim_next(&mut s, &mut slot, CancelToken::default()).is_err());
|
||||
assert_eq!(json!(s.render_queue_state()), current, "stale Plan refusal cannot fail a replacement epoch");
|
||||
assert!(!token.is_cancelled());
|
||||
let before = host.admitted_bytes();
|
||||
let retired = slot.take().unwrap();
|
||||
std::thread::spawn(move || batch::retire_plan(retired)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), before - bytes, "only actual worker retirement releases Plan credit");
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(replacement, drop)).join().unwrap();
|
||||
} else {
|
||||
let claim = result.unwrap().unwrap();
|
||||
assert!(slot.is_none());
|
||||
assert_eq!(s.render_queue_state().waiting, 0);
|
||||
assert!(s.render_queue_state().preparing);
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(claim, drop)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - bytes);
|
||||
}
|
||||
assert_eq!(old_frame.png(), old_png);
|
||||
assert_eq!(old_handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn immutable_native_cancel_identity_checks_live_completed_and_discarded_history_without_collecting_or_advancing() {
|
||||
use crate::render_preparation::batch;
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, job) = first_native_plan(&mut s);
|
||||
let document = s.doc.clone();
|
||||
let model = s.model.state();
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
let check = |s: &crate::Session| {
|
||||
let identities = || {
|
||||
(
|
||||
s.render_jobs.live.iter().map(|job| job.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.completed.iter().map(|result| result.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.history.iter().map(|status| status.job).collect::<Vec<_>>(),
|
||||
s.render_jobs.serial,
|
||||
s.render_jobs.generation,
|
||||
json!(s.render_queue_state()),
|
||||
)
|
||||
};
|
||||
let before = identities();
|
||||
let bytes = host.admitted_bytes();
|
||||
let owners = (Arc::strong_count(&document), Arc::strong_count(&model));
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
assert_eq!(batch::check_cancel(s, &json!({"job":job})).unwrap(), Some(job));
|
||||
assert_eq!(batch::check_cancel(s, &json!({})).unwrap(), None);
|
||||
assert_eq!(identities(), before, "pure cancel validation cannot collect or advance a native shot front");
|
||||
assert_eq!(host.admitted_bytes(), bytes, "validation neither reserves nor drops renderer/plan backing");
|
||||
assert_eq!((Arc::strong_count(&document), Arc::strong_count(&model)), owners, "no source clone or retirement");
|
||||
assert!(Arc::ptr_eq(&s.doc, &document));
|
||||
assert!(Arc::ptr_eq(&s.model.state(), &model));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
};
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Queued);
|
||||
check(&s);
|
||||
for _ in 0..10_000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (1, 0, 0));
|
||||
check(&s);
|
||||
assert_eq!(
|
||||
(s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()),
|
||||
(1, 0, 0),
|
||||
"a completed-but-uncollected handle remains in live metadata"
|
||||
);
|
||||
s.render_jobs.collect().unwrap();
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 1, 1));
|
||||
check(&s);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
let png = frame.png().to_vec();
|
||||
s.execute("render.result.discard", &json!({"index":0})).unwrap();
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 0, 1));
|
||||
assert!(s.render_job_handle(job).is_err());
|
||||
check(&s);
|
||||
assert_eq!(frame.png(), png, "checking an actual discarded result uses history, not a replacement image");
|
||||
let unknown = JobId { serial: u64::MAX, generation: u64::MAX, ..job };
|
||||
let foreign = JobId { session: job.session + 1, ..job };
|
||||
for (id, code) in [(unknown, ErrorCode::UnknownJob), (foreign, ErrorCode::WrongOwner)] {
|
||||
let before = json!(s.render_queue_state());
|
||||
let bytes = host.admitted_bytes();
|
||||
assert_eq!(s.render_jobs.check_retained_or_historical_job(id).unwrap_err().code, code);
|
||||
assert!(batch::check_cancel(&s, &json!({"job":id})).is_err());
|
||||
assert_eq!(json!(s.render_queue_state()), before);
|
||||
assert_eq!(host.admitted_bytes(), bytes);
|
||||
}
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (1, false));
|
||||
let held = host.admitted_bytes();
|
||||
let plan_bytes = plan.retained_bytes();
|
||||
assert!(batch::cancel(&mut s).unwrap(), "terminal history is sufficient to cancel the remaining native queue");
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing, s.render_queue_state().job), (0, false, None));
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 0, 1));
|
||||
assert_eq!(host.admitted_bytes(), held, "metadata cancellation retains the Plan lease until its worker retires it");
|
||||
assert_eq!(frame.png(), png);
|
||||
assert!(Arc::ptr_eq(&s.doc, &document) && Arc::ptr_eq(&s.model.state(), &model));
|
||||
std::thread::spawn(move || batch::retire_plan(plan)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - plan_bytes);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn native_queue_status_and_next_claim_keep_eight_results_and_terminal_live_backing_until_explicit_collection() {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
for cancelled in [false, true] {
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let mut old_ids = Vec::new();
|
||||
let mut oldest_frame = None;
|
||||
for index in 0..8 {
|
||||
let id = complete(&mut s, [index, 31, 63]);
|
||||
s.render_retained_results().unwrap();
|
||||
old_ids.push(id);
|
||||
if index == 0 {
|
||||
oldest_frame = Some(Arc::downgrade(&s.render_job_handle(id).unwrap().frame().unwrap().unwrap()));
|
||||
}
|
||||
}
|
||||
let oldest_frame = oldest_frame.unwrap();
|
||||
assert_eq!(s.render_jobs.completed.len(), 8);
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, job) = first_native_plan(&mut s);
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
if cancelled {
|
||||
assert_eq!(handle.cancel().unwrap().progress.phase, Phase::Cancelled);
|
||||
} else {
|
||||
for _ in 0..10_000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
}
|
||||
let actual = handle.status().unwrap();
|
||||
let peek = s.render_jobs.peek_status(job).unwrap();
|
||||
assert_eq!((peek.job, peek.progress.phase), (job, actual.progress.phase));
|
||||
assert_eq!(peek.error.as_ref().map(|e| (e.code, e.message.as_str())), actual.error.as_ref().map(|e| (e.code, e.message.as_str())));
|
||||
let foreign = JobId { session: job.session + 1, ..job };
|
||||
let unknown = JobId { serial: u64::MAX, generation: u64::MAX, ..job };
|
||||
assert_eq!(s.render_jobs.peek_status(foreign).unwrap_err().code, ErrorCode::WrongOwner);
|
||||
assert_eq!(s.render_jobs.peek_status(unknown).unwrap_err().code, ErrorCode::UnknownJob);
|
||||
let held = host.admitted_bytes();
|
||||
let membership = |s: &crate::Session| {
|
||||
(
|
||||
s.render_jobs.live.iter().map(|j| j.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.completed.iter().map(|j| j.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.history.iter().map(|j| j.job).collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let before = membership(&s);
|
||||
assert_eq!(before.0, vec![job]);
|
||||
assert_eq!(before.1, old_ids);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
s.advance_render_batch().unwrap();
|
||||
assert_eq!(membership(&s), before, "native status polling cannot collect a terminal live job or evict result0");
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
let mut slot = Some(plan);
|
||||
let claim = batch::claim_next(&mut s, &mut slot, CancelToken::default());
|
||||
assert_eq!(membership(&s), before, "metadata-only next-front claim cannot drive ordinary result retention");
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
if cancelled {
|
||||
assert!(claim.is_err());
|
||||
assert!(slot.is_some());
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, false));
|
||||
assert!(s.render_queue_state().job.is_none());
|
||||
assert_eq!(s.render_queue_state().error.as_deref(), Some("native shot queue was cancelled"));
|
||||
let bytes = slot.as_ref().unwrap().retained_bytes();
|
||||
let retired = slot.take().unwrap();
|
||||
std::thread::spawn(move || batch::retire_plan(retired)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - bytes);
|
||||
let rows = s.render_retained_results().unwrap();
|
||||
assert_eq!(rows.iter().map(|r| r.frame.job).collect::<Vec<_>>(), old_ids);
|
||||
assert!(s.render_jobs.live.is_empty());
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
} else {
|
||||
let claim = claim.unwrap().unwrap();
|
||||
assert!(slot.is_none());
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, true));
|
||||
assert_eq!(s.render_queue_state().job, Some(job), "keep the last real job while preparing the next camera");
|
||||
// Ordinary explicit result collection still applies its existing8-row policy.
|
||||
let rows = s.render_retained_results().unwrap();
|
||||
let mut expected = old_ids[1..].to_vec();
|
||||
expected.push(job);
|
||||
assert_eq!(rows.iter().map(|r| r.frame.job).collect::<Vec<_>>(), expected);
|
||||
assert!(s.render_jobs.live.is_empty());
|
||||
assert!(oldest_frame.upgrade().is_none(), "only explicit ordinary collection evicts the oldest completed frame");
|
||||
assert!(host.admitted_bytes() < held);
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(claim, drop)).join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ use solidcore_record::Vec3;
|
||||
use solidcraft_doc::render_settings::{RenderJob, RenderSetup};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::job::LightingPreset;
|
||||
use solidcraft_render::job::{
|
||||
AreaLight, AreaLights, DirectionalLightOptions, DirectionalLights, PunctualLightOptions, PunctualLights, RectangleLightOptions, SpotConeOptions,
|
||||
Sun,
|
||||
};
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, DirectionalLightOptions, PunctualLightOptions, RectangleLightOptions, SpotConeOptions, Sun};
|
||||
|
||||
pub fn parse_id(id: &str) -> Result<(&str, usize), String> {
|
||||
if id.len() > 16 {
|
||||
@@ -85,8 +82,6 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
(edit.height_mm, 0.000002, 2e6),
|
||||
(edit.reference_distance_mm, 0.001, 1e6),
|
||||
(edit.minimum_distance_mm, 0.001, 1e6),
|
||||
(edit.inner_half_angle_radians, 0.0, std::f64::consts::FRAC_PI_2),
|
||||
(edit.outer_half_angle_radians, 0.0, std::f64::consts::FRAC_PI_2),
|
||||
] {
|
||||
if value.is_some_and(|v| !v.is_finite() || !(low..=high).contains(&v)) {
|
||||
return Err("light edit exceeds finite numerical bounds".into());
|
||||
@@ -95,7 +90,6 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
for (value, low, high) in [
|
||||
(edit.position_mm, -1e12, 1e12),
|
||||
(edit.direction, -1e6, 1e6),
|
||||
(edit.emitted_axis, -1e9, 1e9),
|
||||
(edit.edge_u_mm, -2e6, 2e6),
|
||||
(edit.edge_v_mm, -2e6, 2e6),
|
||||
(edit.radiance, 0.0, 1e8),
|
||||
@@ -122,25 +116,15 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
Geometry::Area { edge_u_mm, .. } => vector(edge_u_mm).len(),
|
||||
_ => 10.0,
|
||||
};
|
||||
let old_punctual = match light.geometry {
|
||||
Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm }
|
||||
| Geometry::Spot { position_mm, reference_distance_mm, minimum_distance_mm, .. } => {
|
||||
Some((position_mm, reference_distance_mm, minimum_distance_mm))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (punctual_position, punctual_reference, punctual_minimum) = old_punctual.unwrap_or((array(point), distance, 0.001));
|
||||
let type_changed = edit.kind.as_ref().is_some_and(|kind| kind != light.geometry.kind());
|
||||
if let Some(kind) = edit.kind.as_ref().filter(|_| type_changed) {
|
||||
light.geometry = match kind.as_str() {
|
||||
"directional" => Geometry::Directional { direction: array(direction), angular_radius_rad: None },
|
||||
"point" => {
|
||||
Geometry::Point { position_mm: punctual_position, reference_distance_mm: punctual_reference, minimum_distance_mm: punctual_minimum }
|
||||
}
|
||||
"point" => Geometry::Point { position_mm: array(point), reference_distance_mm: distance, minimum_distance_mm: 0.001 },
|
||||
"spot" => Geometry::Spot {
|
||||
position_mm: punctual_position,
|
||||
reference_distance_mm: punctual_reference,
|
||||
minimum_distance_mm: punctual_minimum,
|
||||
position_mm: array(point),
|
||||
reference_distance_mm: distance,
|
||||
minimum_distance_mm: 0.001,
|
||||
emitted_axis: array(-direction),
|
||||
inner_half_angle_radians: 0.2,
|
||||
outer_half_angle_radians: 0.5,
|
||||
@@ -160,7 +144,7 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
}
|
||||
if let Some(rgb) = edit.radiance {
|
||||
if !matches!(light.geometry, Geometry::Area { .. } | Geometry::Directional { angular_radius_rad: Some(_), .. }) {
|
||||
return Err("radiance requires an area light or finite Sun".into());
|
||||
return Err("radiance requires an area light or GPU sun".into());
|
||||
}
|
||||
(light.intensity, light.colour) = radiance(rgb);
|
||||
}
|
||||
@@ -177,14 +161,6 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
if !matches!(light.geometry, Geometry::Directional { .. }) && edit.direction.is_some() {
|
||||
return Err("direction requires a directional light".into());
|
||||
}
|
||||
if !matches!(light.geometry, Geometry::Spot { .. })
|
||||
&& (edit.emitted_axis.is_some() || edit.inner_half_angle_radians.is_some() || edit.outer_half_angle_radians.is_some())
|
||||
{
|
||||
return Err("cone angles and emitted axis require a spot light".into());
|
||||
}
|
||||
if edit.emitted_axis.is_some() && edit.aim_at.is_some() {
|
||||
return Err("choose a spot emitted axis or an aim point, not both".into());
|
||||
}
|
||||
let moved = type_changed || edit.azimuth_deg.is_some() || edit.elevation_deg.is_some() || edit.distance_mm.is_some() || edit.aim_at.is_some();
|
||||
match &mut light.geometry {
|
||||
Geometry::Directional { direction: axis, angular_radius_rad } => {
|
||||
@@ -206,14 +182,7 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
if edit.softness.is_some() || edit.size_mm.is_some() {
|
||||
return Err("point/spot lights have no finite emitter size or soft shadows".into());
|
||||
}
|
||||
if moved
|
||||
&& !(type_changed
|
||||
&& old_punctual.is_some()
|
||||
&& edit.azimuth_deg.is_none()
|
||||
&& edit.elevation_deg.is_none()
|
||||
&& edit.distance_mm.is_none()
|
||||
&& edit.aim_at.is_none())
|
||||
{
|
||||
if moved {
|
||||
*position_mm = array(point);
|
||||
}
|
||||
if let Some(position) = edit.position_mm {
|
||||
@@ -267,62 +236,9 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
{
|
||||
*emitted_axis = array(solidcraft_render::surface::unit_direction(vector(aim) - vector(*position_mm)).unwrap_or(-Vec3::Z));
|
||||
}
|
||||
if let Geometry::Spot { emitted_axis, inner_half_angle_radians, outer_half_angle_radians, .. } = &mut light.geometry {
|
||||
if let Some(axis) = edit.emitted_axis {
|
||||
*emitted_axis = axis;
|
||||
}
|
||||
if let Some(inner) = edit.inner_half_angle_radians {
|
||||
*inner_half_angle_radians = inner;
|
||||
}
|
||||
if let Some(outer) = edit.outer_half_angle_radians {
|
||||
*outer_half_angle_radians = outer;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_vector_bits(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.into_iter().zip(b).all(|(a, b)| a.to_bits() == b.to_bits())
|
||||
}
|
||||
|
||||
// IDs refer to the original family slots until write publishes the new order.
|
||||
// Preserve an untouched authored source's raw radiance rather than performing an
|
||||
// unrelated divide/multiply round trip through the common intensity/colour UI.
|
||||
fn original_sun(lighting: solidcraft_render::job::StudioLighting, light: &Light) -> Option<Sun> {
|
||||
original_sun_value(lighting.sun?, light)
|
||||
}
|
||||
fn original_sun_value(original: Sun, light: &Light) -> Option<Sun> {
|
||||
if light.id != "directional:0" {
|
||||
return None;
|
||||
}
|
||||
let Geometry::Directional { direction, angular_radius_rad: Some(radius) } = light.geometry else {
|
||||
return None;
|
||||
};
|
||||
let (intensity, colour) = radiance(original.radiance);
|
||||
(same_vector_bits(direction, original.direction)
|
||||
&& radius.to_bits() == original.angular_radius_rad.to_bits()
|
||||
&& light.intensity.to_bits() == intensity.to_bits()
|
||||
&& same_vector_bits(light.colour, colour))
|
||||
.then_some(original)
|
||||
}
|
||||
fn original_area(lighting: solidcraft_render::job::StudioLighting, light: &Light) -> Option<AreaLight> {
|
||||
let (kind, index) = parse_id(&light.id).ok()?;
|
||||
if kind != "area" {
|
||||
return None;
|
||||
}
|
||||
let original = *lighting.area_lights.iter().nth(index)?;
|
||||
let Geometry::Area { position_mm, edge_u_mm, edge_v_mm } = light.geometry else {
|
||||
return None;
|
||||
};
|
||||
let (intensity, colour) = radiance(original.radiance);
|
||||
(same_vector_bits(position_mm, original.center)
|
||||
&& same_vector_bits(edge_u_mm, original.u.map(|x| x * 2.0))
|
||||
&& same_vector_bits(edge_v_mm, original.v.map(|x| x * 2.0))
|
||||
&& light.intensity.to_bits() == intensity.to_bits()
|
||||
&& same_vector_bits(light.colour, colour))
|
||||
.then_some(original)
|
||||
}
|
||||
|
||||
/// Materializes a canonical rig in a cloned setup. Caller validates and publishes
|
||||
/// atomically. Unsupported backend families are refused, never silently dropped.
|
||||
pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
@@ -336,28 +252,23 @@ pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
return Err("light family limit exceeded (8 directional, 8 punctual, 4 area)".into());
|
||||
}
|
||||
match setup.job.as_mut() {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
let mut sun = None;
|
||||
let mut hard = Vec::new();
|
||||
for light in directional {
|
||||
if let Geometry::Directional { direction, angular_radius_rad } = light.geometry {
|
||||
if let Some(angular_radius_rad) = angular_radius_rad {
|
||||
if sun.is_some() {
|
||||
return Err("lighting supports one finite Sun; use hard directionals for additional distant sources".into());
|
||||
}
|
||||
sun = Some(options.sun.and_then(|sun| original_sun_value(sun, light)).unwrap_or(Sun {
|
||||
direction,
|
||||
angular_radius_rad,
|
||||
radiance: light.colour.map(|value| value * light.intensity),
|
||||
}));
|
||||
} else {
|
||||
hard.push(DirectionalLightOptions { direction, color: light.colour, intensity: light.intensity });
|
||||
}
|
||||
}
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if directional.iter().any(|light| matches!(light.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. })) {
|
||||
return Err("CPU directional lights have hard shadows; softness is not supported".into());
|
||||
}
|
||||
options.sun = sun;
|
||||
options.preset = None;
|
||||
options.lights = Some(hard);
|
||||
options.lights = Some(
|
||||
directional
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let direction = match l.geometry {
|
||||
Geometry::Directional { direction, .. } => direction,
|
||||
_ => [0.0, 0.0, 1.0],
|
||||
};
|
||||
DirectionalLightOptions { direction, color: l.colour, intensity: l.intensity }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
options.punctual_lights = Some(
|
||||
punctual
|
||||
.into_iter()
|
||||
@@ -408,69 +319,25 @@ pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
let mut lighting = options.lighting.unwrap_or_default();
|
||||
let mut sun = None;
|
||||
let mut hard = Vec::new();
|
||||
for light in directional {
|
||||
if let Geometry::Directional { direction, angular_radius_rad } = light.geometry {
|
||||
if let Some(angular_radius_rad) = angular_radius_rad {
|
||||
if sun.is_some() {
|
||||
return Err("GPU lighting supports one finite Sun; use hard directional lights for additional distant sources".into());
|
||||
}
|
||||
sun = Some(original_sun(lighting, light).unwrap_or(Sun {
|
||||
direction,
|
||||
angular_radius_rad,
|
||||
radiance: light.colour.map(|c| c * light.intensity),
|
||||
}));
|
||||
} else {
|
||||
hard.push(DirectionalLightOptions { direction, color: light.colour, intensity: light.intensity });
|
||||
}
|
||||
}
|
||||
if !punctual.is_empty() || directional.len() > 1 {
|
||||
return Err("GPU lighting supports one Sun and four Areas; choose an explicit studio before editing legacy default lights".into());
|
||||
}
|
||||
let punctual: Vec<_> = punctual
|
||||
.into_iter()
|
||||
.filter_map(|light| {
|
||||
let (position_mm, reference_distance_mm, minimum_distance_mm, cone) = match light.geometry {
|
||||
Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm } => {
|
||||
(position_mm, reference_distance_mm, minimum_distance_mm, None)
|
||||
}
|
||||
Geometry::Spot {
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
emitted_axis,
|
||||
inner_half_angle_radians,
|
||||
outer_half_angle_radians,
|
||||
} => (
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
Some(SpotConeOptions { emitted_axis, inner_half_angle_radians, outer_half_angle_radians }),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(PunctualLightOptions {
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
cone,
|
||||
color: light.colour,
|
||||
intensity: light.intensity,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
lighting.punctual_lights = PunctualLights::new(&punctual).map_err(|e| e.to_string())?;
|
||||
lighting.sun = sun;
|
||||
lighting.directional_lights = DirectionalLights::new(&hard).map_err(|e| e.to_string())?;
|
||||
let mut lighting = options.lighting.unwrap_or_default();
|
||||
lighting.sun = directional.first().and_then(|l| match l.geometry {
|
||||
Geometry::Directional { direction, angular_radius_rad } => {
|
||||
Some(Sun { direction, angular_radius_rad: angular_radius_rad.unwrap_or(0.02), radiance: l.colour.map(|c| c * l.intensity) })
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let areas: Vec<_> = area
|
||||
.into_iter()
|
||||
.filter_map(|l| match l.geometry {
|
||||
Geometry::Area { position_mm, edge_u_mm, edge_v_mm } => Some(original_area(lighting, l).unwrap_or(AreaLight {
|
||||
Geometry::Area { position_mm, edge_u_mm, edge_v_mm } => Some(AreaLight {
|
||||
center: position_mm,
|
||||
u: edge_u_mm.map(|v| v * 0.5),
|
||||
v: edge_v_mm.map(|v| v * 0.5),
|
||||
radiance: l.colour.map(|c| c * l.intensity),
|
||||
})),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -514,7 +381,7 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
setup.validate().map_err(|e| e.to_string())?;
|
||||
let mut lights = Vec::new();
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
let implicit = options.lights.is_none();
|
||||
let defaults;
|
||||
let directional = match &options.lights {
|
||||
@@ -524,21 +391,10 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
&defaults
|
||||
}
|
||||
};
|
||||
if let Some(sun) = options.sun {
|
||||
let (intensity, colour) = radiance(sun.radiance);
|
||||
push(
|
||||
&mut lights,
|
||||
0,
|
||||
false,
|
||||
intensity,
|
||||
colour,
|
||||
Geometry::Directional { direction: sun.direction, angular_radius_rad: Some(sun.angular_radius_rad) },
|
||||
);
|
||||
}
|
||||
for (index, light) in directional.iter().enumerate() {
|
||||
push(
|
||||
&mut lights,
|
||||
index + usize::from(options.sun.is_some()),
|
||||
index,
|
||||
implicit,
|
||||
light.intensity,
|
||||
light.color,
|
||||
@@ -587,34 +443,6 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
Geometry::Directional { direction: sun.direction, angular_radius_rad: Some(sun.angular_radius_rad) },
|
||||
);
|
||||
}
|
||||
for (index, light) in lighting.directional_lights.iter().enumerate() {
|
||||
push(
|
||||
&mut lights,
|
||||
index + usize::from(lighting.sun.is_some()),
|
||||
false,
|
||||
light.intensity,
|
||||
light.color,
|
||||
Geometry::Directional { direction: light.direction, angular_radius_rad: None },
|
||||
);
|
||||
}
|
||||
for (index, light) in lighting.punctual_lights.iter().enumerate() {
|
||||
let geometry = match light.cone {
|
||||
Some(cone) => Geometry::Spot {
|
||||
position_mm: light.position_mm,
|
||||
reference_distance_mm: light.reference_distance_mm,
|
||||
minimum_distance_mm: light.minimum_distance_mm,
|
||||
emitted_axis: cone.emitted_axis,
|
||||
inner_half_angle_radians: cone.inner_half_angle_radians,
|
||||
outer_half_angle_radians: cone.outer_half_angle_radians,
|
||||
},
|
||||
None => Geometry::Point {
|
||||
position_mm: light.position_mm,
|
||||
reference_distance_mm: light.reference_distance_mm,
|
||||
minimum_distance_mm: light.minimum_distance_mm,
|
||||
},
|
||||
};
|
||||
push(&mut lights, index, false, light.intensity, light.color, geometry);
|
||||
}
|
||||
for (index, light) in lighting.area_lights.iter().enumerate() {
|
||||
let (intensity, colour) = radiance(light.radiance);
|
||||
push(
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
//! Native UI preparation of the same strict Check/Start commands beside the
|
||||
//! window. No job starts in the scratch owner; commit uses the real registry.
|
||||
pub mod batch;
|
||||
pub(crate) mod batch_payload;
|
||||
|
||||
use crate::{Result, Session, cmd::PreparedCapture};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_render::job::CancelToken;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Check,
|
||||
Start,
|
||||
}
|
||||
impl Kind {
|
||||
fn command(self) -> &'static str {
|
||||
match self {
|
||||
Self::Check => "render.preflight",
|
||||
Self::Start => "render.start",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move-only, fully proved capture. The already-created scratch owner provides
|
||||
/// exact source provenance without a second CAD snapshot or a cache-pointer guess.
|
||||
/// Renderer resources/leases drop before that temporary CAD owner.
|
||||
pub struct Prepared {
|
||||
capture: PreparedCapture,
|
||||
source: Session,
|
||||
kind: Kind,
|
||||
shot: Option<String>,
|
||||
params: Value,
|
||||
completed: bool,
|
||||
}
|
||||
impl Prepared {
|
||||
pub fn kind(&self) -> Kind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes Session::scratch, not the live window Session. The caller carries its
|
||||
/// VFS scope and cancellation token onto its native preparation worker.
|
||||
pub fn prepare(snapshot: Session, kind: Kind, params: Value, cancel: CancelToken) -> Result<Prepared> {
|
||||
prepare_with_metadata(snapshot, kind, params, cancel, None)
|
||||
}
|
||||
fn prepare_with_metadata(
|
||||
snapshot: Session,
|
||||
kind: Kind,
|
||||
params: Value,
|
||||
cancel: CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)?;
|
||||
let params = if params.is_null() { Value::Object(Default::default()) } else { params };
|
||||
validate_command(&snapshot, kind, ¶ms)?;
|
||||
let shot = params.get("setup").and_then(Value::as_str).map(str::to_owned);
|
||||
let capture = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
crate::cmd::prepare_capture_with_metadata(&snapshot, ¶ms, kind.command(), &cancel, retained)
|
||||
})) {
|
||||
Ok(result) => result?,
|
||||
Err(cause) => {
|
||||
let message =
|
||||
cause.downcast_ref::<&str>().map(|value| value.to_string()).or_else(|| cause.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
return Err(crate::EngineError::Internal(kind.command().into(), message));
|
||||
}
|
||||
};
|
||||
submission(&capture)?.check_cancel().map_err(crate::render_jobs::job_error)?;
|
||||
Ok(Prepared { capture, source: snapshot, kind, shot, params, completed: false })
|
||||
}
|
||||
|
||||
// Keep the existing engine command enablement and embedding-host refusal.
|
||||
// UI/control schema Off/Warn/Strict remains its ordinary dispatch policy.
|
||||
fn validate_command(session: &Session, kind: Kind, params: &Value) -> Result<()> {
|
||||
validate_command_id(session, kind.command(), params)
|
||||
}
|
||||
fn validate_command_id(session: &Session, id: &str, params: &Value) -> Result<()> {
|
||||
let spec = crate::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.enabled)(session).map_err(|why| crate::EngineError::Disabled(spec.id.into(), why))?;
|
||||
session.check_command(spec, params)
|
||||
}
|
||||
|
||||
fn submission(capture: &PreparedCapture) -> Result<&solidcraft_render::job::PreparedSubmission> {
|
||||
capture.submission.as_ref().ok_or_else(|| crate::EngineError::Other("prepared render was already consumed".into()))
|
||||
}
|
||||
|
||||
fn same_moves(a: &BTreeMap<u64, solidcraft_doc::Mat>, b: &BTreeMap<u64, solidcraft_doc::Mat>) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|((ka, a), (kb, b))| ka == kb && a.iter().flatten().zip(b.iter().flatten()).all(|(a, b)| a.to_bits() == b.to_bits()))
|
||||
}
|
||||
fn camera_bits(camera: Option<solidcraft_render::Camera>) -> Option<(solidcraft_render::UpAxis, [u64; 8])> {
|
||||
camera.map(|camera| {
|
||||
(
|
||||
camera.up,
|
||||
[camera.target.x, camera.target.y, camera.target.z, camera.yaw, camera.pitch, camera.roll, camera.distance, camera.fov].map(f64::to_bits),
|
||||
)
|
||||
})
|
||||
}
|
||||
fn current(source: &Session, session: &Session) -> bool {
|
||||
source.uid == session.uid
|
||||
&& source.revision == session.revision
|
||||
&& Arc::ptr_eq(&source.doc, &session.doc)
|
||||
&& Arc::ptr_eq(&source.model.state(), &session.model.state())
|
||||
&& !session.model.is_stale()
|
||||
&& source.active_sketch == session.active_sketch
|
||||
&& source.feature_edit_sketch == session.feature_edit_sketch
|
||||
&& source.active_component == session.active_component
|
||||
&& source.active_occurrence_path == session.active_occurrence_path
|
||||
&& source.visibility == session.visibility
|
||||
&& same_moves(&source.pending_moves, &session.pending_moves)
|
||||
&& same_moves(&source.exploded, &session.exploded)
|
||||
&& camera_bits(source.camera) == camera_bits(session.camera)
|
||||
}
|
||||
|
||||
/// Strict synchronous owner wrapper. Native UI completion uses finish_retained
|
||||
/// and returns the retained owner to its preparation worker for disposal.
|
||||
pub fn finish(session: &mut Session, mut prepared: Prepared) -> Result<Value> {
|
||||
finish_retained(session, &mut prepared)
|
||||
}
|
||||
|
||||
/// Revalidate and commit without destroying the large captured source on the
|
||||
/// caller. Every error retains the capsule and its leases; success retains its
|
||||
/// scratch source. The native caller returns this owner to the same worker and
|
||||
/// waits for retirement acknowledgement before reporting resources released.
|
||||
pub fn finish_retained(session: &mut Session, prepared: &mut Prepared) -> Result<Value> {
|
||||
finish_inner(session, prepared, None)
|
||||
}
|
||||
fn finish_inner(session: &mut Session, prepared: &mut Prepared, ticket: Option<batch::Ticket>) -> Result<Value> {
|
||||
if prepared.completed {
|
||||
return Err(crate::EngineError::Other("prepared render was already completed".into()));
|
||||
}
|
||||
if !current(&prepared.source, session) {
|
||||
return Err(crate::EngineError::Other("render source changed while preparing; check again".into()));
|
||||
}
|
||||
validate_command(session, prepared.kind, &prepared.params)?;
|
||||
let captured = submission(&prepared.capture)?;
|
||||
let installed = session
|
||||
.render_jobs
|
||||
.host_for(captured.executor())
|
||||
.ok_or_else(|| crate::EngineError::Other("prepared render host is no longer installed".into()))?;
|
||||
if !installed.same_host(captured.host()) || !installed.is_available() {
|
||||
return Err(crate::EngineError::Other("prepared render host changed before completion".into()));
|
||||
}
|
||||
captured.check_cancel().map_err(crate::render_jobs::job_error)?;
|
||||
if prepared.kind == Kind::Check {
|
||||
prepared.completed = true;
|
||||
return Ok(std::mem::take(&mut prepared.capture.reply));
|
||||
}
|
||||
let status = session
|
||||
.render_jobs
|
||||
.try_start_prepared_inner(session.revision, &mut prepared.capture.submission, prepared.capture.replace, prepared.shot.clone(), ticket)
|
||||
.map_err(crate::render_jobs::job_error)?;
|
||||
prepared.completed = true;
|
||||
let mut status = json!(status);
|
||||
if let (Some(out), Some(ignored)) = (status.as_object_mut(), prepared.capture.reply.get("ignored_controls")) {
|
||||
out.insert("ignored_controls".into(), ignored.clone());
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
//! Native camera queue preparation. Runtime command metadata is distinct from
|
||||
//! funded renderer storage: the long-lived shared request is an exact Vec arena.
|
||||
use super::{
|
||||
Kind,
|
||||
batch_payload::{self, Payload},
|
||||
};
|
||||
use crate::{EngineError, Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_io::vfs::PathScope;
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{CancelToken, Phase, RetainedMetadata},
|
||||
};
|
||||
use std::mem::size_of;
|
||||
|
||||
fn bad(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
fn check(cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Ticket {
|
||||
uid: u64,
|
||||
owner: u64,
|
||||
epoch: u64,
|
||||
}
|
||||
impl Ticket {
|
||||
pub(crate) fn matches(self, s: &Session) -> bool {
|
||||
self.uid == s.uid && self.owner == s.render_jobs.owner_key()
|
||||
}
|
||||
}
|
||||
pub(crate) struct NativeState {
|
||||
pub ticket: Ticket,
|
||||
pub next: usize,
|
||||
pub total: usize,
|
||||
pub claimed: bool,
|
||||
pub cancel: CancelToken,
|
||||
}
|
||||
struct Row {
|
||||
name: String,
|
||||
camera: Camera,
|
||||
}
|
||||
/// Opaque persistent shared request. Backing precedes its independent lease.
|
||||
pub struct Plan {
|
||||
ticket: Ticket,
|
||||
payload: Payload,
|
||||
rows: Vec<Row>,
|
||||
scope: Option<PathScope>,
|
||||
next: usize,
|
||||
lease: RetainedMetadata,
|
||||
}
|
||||
impl Plan {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn retained_bytes(&self) -> u64 {
|
||||
self.lease.bytes()
|
||||
}
|
||||
}
|
||||
/// One exact source/front claim; a failed channel delivery retains this owner.
|
||||
pub struct Claim {
|
||||
ticket: Ticket,
|
||||
front: usize,
|
||||
first: bool,
|
||||
total: usize,
|
||||
source: Session,
|
||||
params: Option<Value>,
|
||||
scope: Option<PathScope>,
|
||||
plan: Option<Plan>,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
impl Claim {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn is_first(&self) -> bool {
|
||||
self.first
|
||||
}
|
||||
}
|
||||
/// Success or failure owns its complete retirement path. There is no heavy Err
|
||||
/// payload implicitly destroyed on the window.
|
||||
pub struct Prepared {
|
||||
ticket: Ticket,
|
||||
front: usize,
|
||||
first: bool,
|
||||
total: usize,
|
||||
plan: Option<Plan>,
|
||||
inner: Option<super::Prepared>,
|
||||
// Exact first caller input is command metadata, not persistent Plan backing.
|
||||
// Keep it through current-owner policy validation and worker retirement.
|
||||
first_params: Option<Value>,
|
||||
error: Option<String>,
|
||||
cancel: CancelToken,
|
||||
committed: bool,
|
||||
finished: bool,
|
||||
}
|
||||
impl Prepared {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn is_first(&self) -> bool {
|
||||
self.first
|
||||
}
|
||||
pub fn committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller starts its empty bounded worker first. Metadata refusal takes neither
|
||||
/// owned input nor scope. No source capture, resource IO or job starts here.
|
||||
pub fn claim(s: &mut Session, params: &mut Option<Value>, scope: &mut Option<PathScope>, cancel: CancelToken) -> Result<Claim> {
|
||||
check(&cancel)?;
|
||||
let input = params.as_ref().ok_or_else(|| bad("batch request was already claimed"))?;
|
||||
solidcraft_io::vfs::scoped(scope.clone(), || super::validate_command_id(s, "render.batch", input))?;
|
||||
s.render_batch_ready().map_err(EngineError::Other)?;
|
||||
if s.model.is_stale() {
|
||||
return Err(bad("finish the modelling gesture before rendering shots"));
|
||||
}
|
||||
let epoch = s.render_jobs.native_epoch.checked_add(1).ok_or_else(|| bad("native shot queue identity exhausted"))?;
|
||||
let owner = s.render_jobs.owner_key();
|
||||
if owner == 0 {
|
||||
return Err(bad("render session identity exhausted"));
|
||||
}
|
||||
let ticket = Ticket { uid: s.uid, owner, epoch };
|
||||
let total = s.doc.render.setups.len();
|
||||
let source = s.scratch();
|
||||
let params = params.take().ok_or_else(|| bad("batch request vanished before claim"))?;
|
||||
s.render_jobs.native_epoch = epoch;
|
||||
s.render_jobs.batch_error = None;
|
||||
s.render_jobs.native_batch = Some(NativeState { ticket, next: 0, total, claimed: true, cancel: cancel.clone() });
|
||||
Ok(Claim { ticket, front: 0, first: true, total, source, params: Some(params), scope: scope.take(), plan: None, cancel })
|
||||
}
|
||||
/// None means the actual current job is still running or its front is claimed.
|
||||
/// Refusal preserves the plan; the consumer retires it on its worker.
|
||||
pub fn claim_next(s: &mut Session, slot: &mut Option<Plan>, cancel: CancelToken) -> Result<Option<Claim>> {
|
||||
check(&cancel)?;
|
||||
let plan = slot.as_ref().ok_or_else(|| bad("batch plan was already claimed"))?;
|
||||
if !plan.owner_matches(s) {
|
||||
return Err(bad("batch plan belongs to another real session owner"));
|
||||
}
|
||||
// Verify the exact real owner/epoch/front before metadata errors may fail
|
||||
// this queue. A stale Plan is not authority to alter a replacement ticket.
|
||||
let native =
|
||||
s.render_jobs.native_batch.as_ref().filter(|n| n.ticket == plan.ticket).ok_or_else(|| bad("native shot queue is no longer current"))?;
|
||||
if native.claimed {
|
||||
return Ok(None);
|
||||
}
|
||||
if native.next != plan.next || native.total != plan.rows.len() || native.next >= native.total {
|
||||
return Err(bad("native shot queue front changed"));
|
||||
}
|
||||
let ticket = plan.ticket;
|
||||
let ready = (|| {
|
||||
s.advance_render_batch()?;
|
||||
if !s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket) {
|
||||
return Err(bad("native shot queue is no longer current"));
|
||||
}
|
||||
if let Some(job) = s.render_jobs.batch_job {
|
||||
let status = s.render_jobs.peek_status(job).map_err(crate::render_jobs::job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
return Ok(false);
|
||||
}
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(bad("previous native shot did not complete"));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
})();
|
||||
match ready {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(None),
|
||||
Err(error) => {
|
||||
// Fixed metadata only: retain Plan/source backing for its original
|
||||
// worker retirement and never touch a newer queue on this owner.
|
||||
fail(s, ticket, &error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let source = s.scratch();
|
||||
let front = plan.next;
|
||||
let total = plan.rows.len();
|
||||
let ticket = plan.ticket;
|
||||
let plan = slot.take().ok_or_else(|| bad("batch plan vanished before claim"))?;
|
||||
if let Some(native) = &mut s.render_jobs.native_batch {
|
||||
native.claimed = true;
|
||||
native.cancel = cancel.clone();
|
||||
}
|
||||
Ok(Some(Claim { ticket, front, first: false, total, source, params: None, scope: None, plan: Some(plan), cancel }))
|
||||
}
|
||||
|
||||
fn shot_params(mut value: Value, camera: Camera) -> Result<Value> {
|
||||
value
|
||||
.get_mut("settings")
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| bad("queued render settings are missing"))?
|
||||
.insert("camera".into(), json!(camera));
|
||||
Ok(value)
|
||||
}
|
||||
fn plan_bytes(source: &Session, payload: batch_payload::Counts, scope: Option<&PathScope>) -> Result<u64> {
|
||||
let rows = source.doc.render.setups.len();
|
||||
let mut bytes = (size_of::<Plan>() as u64).checked_add(payload.bytes()?).ok_or_else(|| bad("batch plan byte overflow"))?;
|
||||
bytes = bytes
|
||||
.checked_add(rows.checked_mul(size_of::<Row>()).ok_or_else(|| bad("batch row byte overflow"))? as u64)
|
||||
.ok_or_else(|| bad("batch plan byte overflow"))?;
|
||||
for name in source.doc.render.setups.keys() {
|
||||
bytes = bytes.checked_add(name.len() as u64).ok_or_else(|| bad("batch name byte overflow"))?;
|
||||
}
|
||||
bytes
|
||||
.checked_add(scope.map_or(Ok(0), PathScope::retained_heap_bytes).map_err(|e| bad(&e.to_string()))?)
|
||||
.ok_or_else(|| bad("batch scope byte overflow"))
|
||||
}
|
||||
fn first(claim: Claim, out: &mut Prepared) -> Result<()> {
|
||||
out.first_params = claim.params;
|
||||
let input = out.first_params.as_ref().ok_or_else(|| bad("initial batch input missing"))?;
|
||||
let empty = Value::Object(Default::default());
|
||||
let input = if input.is_null() { &empty } else { input };
|
||||
// Both the original Batch and every nested Start retain their exact guard
|
||||
// inputs for current-owner validation before the first real enqueue.
|
||||
let common = crate::cmd::batch_request(&claim.source, input)?;
|
||||
let (name, camera) = claim.source.doc.render.setups.iter().next().map(|(n, s)| (n.clone(), s.camera)).ok_or_else(|| bad("no saved shots"))?;
|
||||
let params = shot_params(common.clone(), camera)?;
|
||||
let mut inner = super::prepare(claim.source, Kind::Start, params, claim.cancel.clone())?;
|
||||
inner.shot = Some(name);
|
||||
out.inner = Some(inner);
|
||||
let counts = batch_payload::count(&common, &claim.cancel)?;
|
||||
let source = &out.inner.as_ref().ok_or_else(|| bad("missing first prepared owner"))?.source;
|
||||
let bytes = plan_bytes(source, counts, claim.scope.as_ref())?;
|
||||
let lease = super::submission(&out.inner.as_ref().ok_or_else(|| bad("missing first preparation"))?.capture)?
|
||||
.reserve_retained_metadata(bytes)
|
||||
.map_err(crate::render_jobs::job_error)?;
|
||||
let mut rows = Vec::new();
|
||||
rows.try_reserve_exact(claim.total).map_err(|_| bad("could not allocate batch rows"))?;
|
||||
if rows.capacity() != claim.total {
|
||||
return Err(bad("batch row capacity exceeds reservation"));
|
||||
}
|
||||
for (name, setup) in &source.doc.render.setups {
|
||||
check(&claim.cancel)?;
|
||||
let mut copied = String::new();
|
||||
copied.try_reserve_exact(name.len()).map_err(|_| bad("could not retain batch name"))?;
|
||||
if copied.capacity() != name.len() {
|
||||
return Err(bad("batch name capacity exceeds reservation"));
|
||||
}
|
||||
copied.push_str(name);
|
||||
rows.push(Row { name: copied, camera: setup.camera });
|
||||
}
|
||||
let payload = Payload::new(common, counts, &claim.cancel)?;
|
||||
// No normalized Arc<Value> remains alongside this arena. The exact original
|
||||
// Batch input belongs only to this first Prepared command metadata owner,
|
||||
// never to the persistent Plan, and retires on the same worker after finish.
|
||||
let plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
|
||||
if plan.actual_bytes()? != plan.lease.bytes() {
|
||||
return Err(bad("batch arena actual backing disagrees with reservation"));
|
||||
}
|
||||
out.plan = Some(plan);
|
||||
Ok(())
|
||||
}
|
||||
fn next(mut claim: Claim, out: &mut Prepared) -> Result<()> {
|
||||
out.plan = claim.plan.take();
|
||||
let plan = out.plan.as_ref().ok_or_else(|| bad("missing next-shot plan"))?;
|
||||
let row = plan.rows.get(claim.front).ok_or_else(|| bad("batch camera row missing"))?;
|
||||
let params = shot_params(plan.payload.value(&claim.cancel)?, row.camera)?;
|
||||
let mut inner = super::prepare_with_metadata(claim.source, Kind::Start, params, claim.cancel.clone(), Some(&plan.lease))?;
|
||||
inner.shot = Some(row.name.clone());
|
||||
out.inner = Some(inner);
|
||||
Ok(())
|
||||
}
|
||||
impl Plan {
|
||||
fn actual_bytes(&self) -> Result<u64> {
|
||||
let mut bytes = (size_of::<Self>() as u64).checked_add(self.payload.heap_bytes()?).ok_or_else(|| bad("batch actual byte overflow"))?;
|
||||
bytes = bytes
|
||||
.checked_add(self.rows.capacity().checked_mul(size_of::<Row>()).ok_or_else(|| bad("batch actual row overflow"))? as u64)
|
||||
.ok_or_else(|| bad("batch actual row overflow"))?;
|
||||
for row in &self.rows {
|
||||
bytes = bytes.checked_add(row.name.capacity() as u64).ok_or_else(|| bad("batch actual name overflow"))?;
|
||||
}
|
||||
bytes
|
||||
.checked_add(self.scope.as_ref().map_or(Ok(0), PathScope::retained_heap_bytes).map_err(|e| bad(&e.to_string()))?)
|
||||
.ok_or_else(|| bad("batch actual scope overflow"))
|
||||
}
|
||||
}
|
||||
/// Always returns the exact owner, including failures. Caller must retire it on
|
||||
/// this same worker after finish/stale/cancel, and wait for acknowledgement.
|
||||
pub fn prepare(claim: Claim) -> Prepared {
|
||||
let mut out = Prepared {
|
||||
ticket: claim.ticket,
|
||||
front: claim.front,
|
||||
first: claim.first,
|
||||
total: claim.total,
|
||||
plan: None,
|
||||
inner: None,
|
||||
first_params: None,
|
||||
error: None,
|
||||
cancel: claim.cancel.clone(),
|
||||
committed: false,
|
||||
finished: false,
|
||||
};
|
||||
let scope = claim.scope.clone().or_else(|| claim.plan.as_ref().and_then(|p| p.scope.clone()));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
solidcraft_io::vfs::scoped(scope, || {
|
||||
check(&claim.cancel)?;
|
||||
if claim.first { first(claim, &mut out) } else { next(claim, &mut out) }
|
||||
})
|
||||
}));
|
||||
out.error = match result {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(error)) => Some(error.to_string()),
|
||||
Err(_) => Some("native batch preparation failed internally".into()),
|
||||
};
|
||||
out
|
||||
}
|
||||
fn fail(s: &mut Session, ticket: Ticket, message: &str) {
|
||||
if s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket) {
|
||||
if let Some(native) = s.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
s.render_jobs.batch_job = None;
|
||||
s.render_jobs.batch_error = Some(message.into());
|
||||
}
|
||||
}
|
||||
/// The real owner commits; a different current tab cannot adopt its jobs. All
|
||||
/// error and successful source backing remains in `out` for worker retirement.
|
||||
pub fn finish_retained(s: &mut Session, out: &mut Prepared) -> Result<Value> {
|
||||
if !out.owner_matches(s) {
|
||||
return Err(bad("batch completion belongs to another real session owner"));
|
||||
}
|
||||
if out.finished {
|
||||
return Err(bad("batch preparation was already completed"));
|
||||
}
|
||||
let native = s
|
||||
.render_jobs
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.filter(|n| n.ticket == out.ticket && n.claimed && n.next == out.front && n.total == out.total)
|
||||
.ok_or_else(|| bad("native shot queue claim is no longer current"))?;
|
||||
if let Err(error) = check(&native.cancel) {
|
||||
out.finished = true;
|
||||
fail(s, out.ticket, &error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
let result = (|| {
|
||||
check(&out.cancel)?;
|
||||
if let Some(error) = &out.error {
|
||||
return Err(bad(error));
|
||||
}
|
||||
let scope = out.plan.as_ref().and_then(|p| p.scope.clone());
|
||||
let inner = out.inner.as_mut().ok_or_else(|| bad("batch preparation has no proved job"))?;
|
||||
let reply = solidcraft_io::vfs::scoped(scope, || {
|
||||
if out.first {
|
||||
let input = out.first_params.as_ref().ok_or_else(|| bad("first batch guard input missing"))?;
|
||||
let spec = crate::find_command("render.batch").ok_or_else(|| EngineError::UnknownCommand("render.batch".into()))?;
|
||||
// The ordinary enablement shares this readiness predicate. Only
|
||||
// this exact first claimed front is exempt from its own busy bit.
|
||||
s.render_batch_ready_for_first(out.ticket).map_err(|why| EngineError::Disabled(spec.id.into(), why))?;
|
||||
s.check_command(spec, input)?;
|
||||
}
|
||||
super::finish_inner(s, inner, Some(out.ticket))
|
||||
})?;
|
||||
out.committed = true;
|
||||
let plan = out.plan.as_mut().ok_or_else(|| bad("batch completion has no plan"))?;
|
||||
plan.next = out.front + 1;
|
||||
Ok(if out.first { json!({"queued":out.total}) } else { reply })
|
||||
})();
|
||||
out.finished = true;
|
||||
if let Err(error) = &result
|
||||
&& !out.committed
|
||||
{
|
||||
fail(s, out.ticket, &error.to_string());
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn take_plan(out: &mut Prepared) -> Result<Option<Plan>> {
|
||||
if !out.committed {
|
||||
return Err(bad("batch plan cannot advance before its actual job"));
|
||||
}
|
||||
if out.plan.as_ref().is_some_and(|p| p.next < p.rows.len()) { Ok(out.plan.take()) } else { Ok(None) }
|
||||
}
|
||||
/// Metadata-only cancellation, including a first claim which has no fabricated
|
||||
/// job ID. Idle/closed plans still require their separate worker retirement.
|
||||
pub fn cancel(s: &mut Session) -> Result<bool> {
|
||||
let Some(native) = s.render_jobs.native_batch.take() else { return Ok(false) };
|
||||
native.cancel.cancel();
|
||||
if let Some(job) = s.render_jobs.batch_job.take() {
|
||||
// A discarded completed front can survive only in the bounded status
|
||||
// history. It needs no live handle or additional cancellation; looking
|
||||
// it up must not collect or evict another retained result here.
|
||||
let status = s.render_jobs.peek_status(job).map_err(crate::render_jobs::job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
let _ = s.render_jobs.handle(job).map_err(crate::render_jobs::job_error)?.cancel().map_err(crate::render_jobs::job_error)?;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Cleanup for a particular idle plan. Owner equality alone is deliberately
|
||||
/// insufficient: an identical replacement queue has a different epoch.
|
||||
pub fn cancel_plan(s: &mut Session, plan: &Plan) -> Result<bool> {
|
||||
if !plan.owner_matches(s) || !s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == plan.ticket) {
|
||||
return Ok(false);
|
||||
}
|
||||
cancel(s)
|
||||
}
|
||||
|
||||
/// The native worker wraps its entire send/return/drop lifecycle here. Send the
|
||||
/// release acknowledgement only after this returns, so both captured backing
|
||||
/// and its temporary caller permission context have really retired. The plan
|
||||
/// itself still owns/funds exactly one persistent scope between jobs.
|
||||
pub fn with_scope<T>(claim: Claim, f: impl FnOnce(Claim) -> T) -> T {
|
||||
let scope = claim.scope.clone().or_else(|| claim.plan.as_ref().and_then(|p| p.scope.clone()));
|
||||
solidcraft_io::vfs::scoped(scope, || f(claim))
|
||||
}
|
||||
/// Idle, closed or rejected plan-only messages need no second stored scope.
|
||||
/// Hold the lease until original scope storage and all backing have dropped.
|
||||
pub fn retire_plan(plan: Plan) {
|
||||
let Plan { payload, rows, scope, lease, .. } = plan;
|
||||
solidcraft_io::vfs::scoped(scope, || drop((payload, rows)));
|
||||
drop(lease);
|
||||
}
|
||||
/// Pure ordinary cancellation enablement/policy/typed-argument check. A pending
|
||||
/// UI input has no invented job: None may cancel that input; Some must follow
|
||||
/// the ordinary actual-job cancellation route, including ownership validation.
|
||||
pub fn check_cancel(s: &Session, params: &Value) -> Result<Option<solidcraft_render::job::JobId>> {
|
||||
super::validate_command_id(s, "render.cancel", params)?;
|
||||
let job = crate::cmd::render_cancel_params(params)?;
|
||||
if let Some(job) = job {
|
||||
s.render_jobs.check_retained_or_historical_job(job).map_err(crate::render_jobs::job_error)?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Owned shared command data. Only public Vec/String capacities are retained;
|
||||
//! no serde_json object allocation survives conversion into the plan.
|
||||
use crate::{EngineError, Result};
|
||||
use serde_json::{Number, Value};
|
||||
use solidcraft_render::job::CancelToken;
|
||||
use std::mem::size_of;
|
||||
|
||||
fn bad(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
fn add(a: u64, b: usize) -> Result<u64> {
|
||||
a.checked_add(u64::try_from(b).map_err(|_| bad("batch payload byte overflow"))?).ok_or_else(|| bad("batch payload byte overflow"))
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Numeric {
|
||||
Signed(i64),
|
||||
Unsigned(u64),
|
||||
Float(u64),
|
||||
}
|
||||
impl Numeric {
|
||||
fn capture(value: Number) -> Result<Self> {
|
||||
if value.is_i64() {
|
||||
return value.as_i64().map(Self::Signed).ok_or_else(|| bad("batch signed number"));
|
||||
}
|
||||
if value.is_u64() {
|
||||
return value.as_u64().map(Self::Unsigned).ok_or_else(|| bad("batch unsigned number"));
|
||||
}
|
||||
if value.is_f64() {
|
||||
return value.as_f64().filter(|n| n.is_finite()).map(|n| Self::Float(n.to_bits())).ok_or_else(|| bad("batch finite number"));
|
||||
}
|
||||
Err(bad("batch command number is not representable without changing its value"))
|
||||
}
|
||||
fn value(self) -> Result<Value> {
|
||||
Ok(Value::Number(match self {
|
||||
Self::Signed(v) => v.into(),
|
||||
Self::Unsigned(v) => v.into(),
|
||||
Self::Float(v) => Number::from_f64(f64::from_bits(v)).ok_or_else(|| bad("batch stored number"))?,
|
||||
}))
|
||||
}
|
||||
}
|
||||
enum Node {
|
||||
Pending(Value),
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(Numeric),
|
||||
String(String),
|
||||
Array { begin: usize, end: usize },
|
||||
Object { begin: usize, end: usize },
|
||||
}
|
||||
struct Edge {
|
||||
key: Option<String>,
|
||||
child: usize,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Counts {
|
||||
nodes: usize,
|
||||
edges: usize,
|
||||
strings: u64,
|
||||
}
|
||||
impl Counts {
|
||||
pub(crate) fn bytes(self) -> Result<u64> {
|
||||
let bytes = self
|
||||
.nodes
|
||||
.checked_mul(size_of::<Node>())
|
||||
.and_then(|n| self.edges.checked_mul(size_of::<Edge>()).and_then(|e| n.checked_add(e)))
|
||||
.ok_or_else(|| bad("batch arena storage overflow"))?;
|
||||
add(self.strings, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the existing serde_json command parser nesting envelope, not a
|
||||
// material/light/shot count. Valid typed rendering commands are shallower.
|
||||
const JSON_DEPTH: usize = 128;
|
||||
enum Frame<'a> {
|
||||
Array(std::slice::Iter<'a, Value>),
|
||||
Object(serde_json::map::Iter<'a>),
|
||||
}
|
||||
fn cancelled(cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)
|
||||
}
|
||||
pub(crate) fn count(value: &Value, cancel: &CancelToken) -> Result<Counts> {
|
||||
let mut counts = Counts { nodes: 0, edges: 0, strings: 0 };
|
||||
let mut stack: [Option<Frame<'_>>; JSON_DEPTH] = std::array::from_fn(|_| None);
|
||||
let mut depth = 0;
|
||||
let mut next = Some(value);
|
||||
loop {
|
||||
cancelled(cancel)?;
|
||||
if let Some(value) = next.take() {
|
||||
counts.nodes = counts.nodes.checked_add(1).ok_or_else(|| bad("batch arena node overflow"))?;
|
||||
match value {
|
||||
Value::String(v) => counts.strings = add(counts.strings, v.capacity())?,
|
||||
Value::Array(v) => {
|
||||
counts.edges = counts.edges.checked_add(v.len()).ok_or_else(|| bad("batch arena edge overflow"))?;
|
||||
*stack.get_mut(depth).ok_or_else(|| bad("batch request exceeds the existing JSON nesting envelope"))? =
|
||||
Some(Frame::Array(v.iter()));
|
||||
depth += 1;
|
||||
}
|
||||
Value::Object(v) => {
|
||||
counts.edges = counts.edges.checked_add(v.len()).ok_or_else(|| bad("batch arena edge overflow"))?;
|
||||
*stack.get_mut(depth).ok_or_else(|| bad("batch request exceeds the existing JSON nesting envelope"))? =
|
||||
Some(Frame::Object(v.iter()));
|
||||
depth += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while depth != 0 && next.is_none() {
|
||||
match stack.get_mut(depth - 1).and_then(Option::as_mut).ok_or_else(|| bad("batch arena traversal"))? {
|
||||
Frame::Array(iter) => next = iter.next(),
|
||||
Frame::Object(iter) => {
|
||||
if let Some((key, value)) = iter.next() {
|
||||
counts.strings = add(counts.strings, key.capacity())?;
|
||||
next = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if next.is_none() {
|
||||
*stack.get_mut(depth - 1).ok_or_else(|| bad("batch arena traversal pop"))? = None;
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if next.is_none() {
|
||||
return Ok(counts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Payload {
|
||||
nodes: Vec<Node>,
|
||||
edges: Vec<Edge>,
|
||||
strings: u64,
|
||||
}
|
||||
impl Payload {
|
||||
/// Caller reserves Counts::bytes plus its fixed plan/rows/scope backing
|
||||
/// before this new bulk allocation. The old command Value is caller-owned
|
||||
/// metadata and is dismantled here on the preparation worker.
|
||||
pub(crate) fn new(value: Value, counts: Counts, cancel: &CancelToken) -> Result<Self> {
|
||||
cancelled(cancel)?;
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
nodes.try_reserve_exact(counts.nodes).map_err(|_| bad("could not allocate batch arena nodes"))?;
|
||||
edges.try_reserve_exact(counts.edges).map_err(|_| bad("could not allocate batch arena edges"))?;
|
||||
if nodes.capacity() != counts.nodes || edges.capacity() != counts.edges {
|
||||
return Err(bad("batch arena capacity exceeds its reservation"));
|
||||
}
|
||||
nodes.push(Node::Pending(value));
|
||||
let mut at = 0;
|
||||
while at < nodes.len() {
|
||||
cancelled(cancel)?;
|
||||
let slot = nodes.get_mut(at).ok_or_else(|| bad("batch arena pending node"))?;
|
||||
let Node::Pending(value) = std::mem::replace(slot, Node::Null) else {
|
||||
return Err(bad("batch arena node already resolved"));
|
||||
};
|
||||
let begin = edges.len();
|
||||
let resolved = match value {
|
||||
Value::Null => Node::Null,
|
||||
Value::Bool(v) => Node::Bool(v),
|
||||
Value::Number(v) => Node::Number(Numeric::capture(v)?),
|
||||
Value::String(v) => Node::String(v),
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
cancelled(cancel)?;
|
||||
if nodes.len() == nodes.capacity() || edges.len() == edges.capacity() {
|
||||
return Err(bad("batch arena array exceeds reservation"));
|
||||
}
|
||||
let child = nodes.len();
|
||||
nodes.push(Node::Pending(value));
|
||||
edges.push(Edge { key: None, child });
|
||||
}
|
||||
Node::Array { begin, end: edges.len() }
|
||||
}
|
||||
Value::Object(values) => {
|
||||
for (key, value) in values {
|
||||
cancelled(cancel)?;
|
||||
if nodes.len() == nodes.capacity() || edges.len() == edges.capacity() {
|
||||
return Err(bad("batch arena object exceeds reservation"));
|
||||
}
|
||||
let child = nodes.len();
|
||||
nodes.push(Node::Pending(value));
|
||||
edges.push(Edge { key: Some(key), child });
|
||||
}
|
||||
Node::Object { begin, end: edges.len() }
|
||||
}
|
||||
};
|
||||
*nodes.get_mut(at).ok_or_else(|| bad("batch arena resolved slot"))? = resolved;
|
||||
at += 1;
|
||||
}
|
||||
if nodes.len() != counts.nodes || edges.len() != counts.edges {
|
||||
return Err(bad("batch arena accounting disagrees with conversion"));
|
||||
}
|
||||
Ok(Self { nodes, edges, strings: counts.strings })
|
||||
}
|
||||
pub(crate) fn heap_bytes(&self) -> Result<u64> {
|
||||
add(
|
||||
self.strings,
|
||||
self.nodes
|
||||
.capacity()
|
||||
.checked_mul(size_of::<Node>())
|
||||
.and_then(|n| self.edges.capacity().checked_mul(size_of::<Edge>()).and_then(|e| n.checked_add(e)))
|
||||
.ok_or_else(|| bad("batch arena actual capacity overflow"))?,
|
||||
)
|
||||
}
|
||||
/// Exactly one per-shot raw command Value is reconstructed on the worker.
|
||||
/// Like individual command metadata it remains caller-owned, is moved into
|
||||
/// Prepared.params, and retires on that worker. It is not decoded renderer
|
||||
/// storage or a second retained common batch request.
|
||||
pub(crate) fn value(&self, cancel: &CancelToken) -> Result<Value> {
|
||||
self.node_value(0, 0, cancel)
|
||||
}
|
||||
fn node_value(&self, at: usize, depth: usize, cancel: &CancelToken) -> Result<Value> {
|
||||
cancelled(cancel)?;
|
||||
if depth > JSON_DEPTH {
|
||||
return Err(bad("batch arena stored nesting"));
|
||||
}
|
||||
let copy = |text: &str| -> Result<String> {
|
||||
let mut value = String::new();
|
||||
value.try_reserve_exact(text.len()).map_err(|_| bad("could not copy batch command text"))?;
|
||||
value.push_str(text);
|
||||
Ok(value)
|
||||
};
|
||||
Ok(match self.nodes.get(at).ok_or_else(|| bad("batch arena stored index"))? {
|
||||
Node::Null => Value::Null,
|
||||
Node::Bool(v) => Value::Bool(*v),
|
||||
Node::Number(v) => v.value()?,
|
||||
Node::String(v) => Value::String(copy(v)?),
|
||||
Node::Array { begin, end } => {
|
||||
let entries = self.edges.get(*begin..*end).ok_or_else(|| bad("batch arena array range"))?;
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(entries.len()).map_err(|_| bad("could not copy batch command array"))?;
|
||||
for edge in entries {
|
||||
values.push(self.node_value(edge.child, depth + 1, cancel)?);
|
||||
}
|
||||
Value::Array(values)
|
||||
}
|
||||
Node::Object { begin, end } => {
|
||||
let entries = self.edges.get(*begin..*end).ok_or_else(|| bad("batch arena object range"))?;
|
||||
let mut values = serde_json::Map::new();
|
||||
for edge in entries {
|
||||
let key = edge.key.as_deref().ok_or_else(|| bad("batch arena object key"))?;
|
||||
values.insert(copy(key)?, self.node_value(edge.child, depth + 1, cancel)?);
|
||||
}
|
||||
Value::Object(values)
|
||||
}
|
||||
Node::Pending(_) => return Err(bad("batch arena conversion incomplete")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_element_bytes() -> (usize, usize) {
|
||||
(size_of::<Node>(), size_of::<Edge>())
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Payload {
|
||||
pub(crate) fn test_capacities(&self) -> (usize, usize, u64) {
|
||||
(self.nodes.capacity(), self.edges.capacity(), self.strings)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,8 @@ pub mod parameter_sweep;
|
||||
pub mod parameters;
|
||||
pub mod parts;
|
||||
pub mod plastic;
|
||||
pub mod point_cloud;
|
||||
pub mod print_face;
|
||||
pub mod render;
|
||||
pub mod render_bindings;
|
||||
pub mod render_camera;
|
||||
pub mod render_hdr;
|
||||
pub mod render_library;
|
||||
pub mod render_lights;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//! Contracts and result types for the commands in `cmd/point_cloud.rs` that have them.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::IdOrName;
|
||||
|
||||
/// Read a point cloud's file again from where it was inserted. The points, colours, segments,
|
||||
/// triangles and properties are replaced; the name, placement, units, point size and colouring
|
||||
/// are kept, and so are the filters on properties the file still has. The file is read under the
|
||||
/// limit the cloud was inserted with, so a file that grew is thinned only past that limit.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PointCloudReloadParams {
|
||||
/// The point cloud, by id or name.
|
||||
pub cloud: IdOrName,
|
||||
}
|
||||
|
||||
/// What `pointcloud.reload` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PointCloudReloadResult {
|
||||
/// The cloud's id.
|
||||
pub cloud: u64,
|
||||
/// How many points it has now.
|
||||
pub points: usize,
|
||||
/// How many segments it has now.
|
||||
pub segments: usize,
|
||||
/// How many triangles it has now.
|
||||
pub triangles: usize,
|
||||
/// What could not be read from the file, or was thinned out of it.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "point_cloud_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,32 +0,0 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::{PointCloudReloadParams, PointCloudReloadResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<PointCloudReloadParams>(), &["cloud"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[("pointcloud.reload", json!({"cloud": [1]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reply_matches_the_output_schema() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-pointcloud-schema-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("two.xyz");
|
||||
std::fs::write(&path, "0 0 0\n1 0 0\n").unwrap();
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let id = s.execute("pointcloud.insert", &json!({"path": path})).unwrap()["cloud"].clone();
|
||||
let reply = s.execute("pointcloud.reload", &json!({"cloud": id})).unwrap();
|
||||
assert!(reply.get("param_warnings").is_none(), "{reply}");
|
||||
let schema = output_schema_for::<PointCloudReloadResult>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -25,57 +25,3 @@ pub struct EnvironmentResult {
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
}
|
||||
|
||||
/// Shared clear-coat numbers; the handler validates the renderer's exact domain.
|
||||
#[derive(Clone, Copy, Deserialize, JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CoatNumbers {
|
||||
pub weight: f64,
|
||||
pub roughness: f64,
|
||||
pub ior: f64,
|
||||
}
|
||||
impl Default for CoatNumbers {
|
||||
fn default() -> Self {
|
||||
let coat = solidcraft_doc::render_coat::CoatMaterial::default();
|
||||
Self { weight: coat.weight, roughness: coat.roughness, ior: coat.ior }
|
||||
}
|
||||
}
|
||||
impl CoatNumbers {
|
||||
pub fn material(self) -> solidcraft_doc::render_coat::CoatMaterial {
|
||||
solidcraft_doc::render_coat::CoatMaterial { weight: self.weight, roughness: self.roughness, ior: self.ior }
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CoatBindParams {
|
||||
pub name: String,
|
||||
/// Numerical shot snapshot; cannot replace existing coat bindings through this patch.
|
||||
#[serde(default = "empty_patch")]
|
||||
pub set: Value,
|
||||
pub body_index: usize,
|
||||
pub body_name: String,
|
||||
pub coat: CoatNumbers,
|
||||
pub expected_revision: u64,
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CoatUnbindParams {
|
||||
pub name: String,
|
||||
/// Exact saved definition/feature/component/occurrence_path, checked by the document type.
|
||||
#[schemars(with = "std::collections::BTreeMap<String, Value>")]
|
||||
pub target: Value,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CoatBindResult {
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
pub geometry_and_engineering_unchanged: bool,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CoatUnbindResult {
|
||||
pub name: String,
|
||||
pub removed: bool,
|
||||
}
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
//! Contracts and result types for the render binding commands (#11741): a body of a render setup
|
||||
//! bound to a library surface's maps (`cmd/render/surface_bindings.rs`) or to optical numbers
|
||||
//! (`cmd/render/optical_bindings.rs`). The handlers read these same parameter types, so each
|
||||
//! contract is exactly what its handler takes; like the handlers, a contract refuses unknown
|
||||
//! keys.
|
||||
//!
|
||||
//! Three parameters keep their own readers, and their schemas here are written by hand:
|
||||
//! - `target` and `optics` are the doc crate's `RenderBodyTarget` and `OpticalMaterial`, which
|
||||
//! don't derive `JsonSchema` (the doc crate doesn't depend on schemars).
|
||||
//! - `surface` is passed on whole to `render.library.surface.prepare`, which checks it.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::render_optics::OpticalMaterial;
|
||||
use solidcraft_doc::render_surface::RenderBodyTarget;
|
||||
|
||||
/// `set`'s default: no change to the setup.
|
||||
fn no_change() -> Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
/// `set`: changes to the setup, as `render.settings.set` takes them. `patched_settings` in
|
||||
/// `cmd/render.rs` refuses anything but an object of at most 13 fields, with its own message.
|
||||
/// The fields of a render setup, as `render.settings.get` gives them. A `set` patch may give up
|
||||
/// to 13 of them; `patched_settings` reads the patched setup as a `RenderSetup`, which refuses any
|
||||
/// other key.
|
||||
const SETUP_FIELDS: &[&str] = &[
|
||||
"backend",
|
||||
"mode",
|
||||
"camera",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"exposure_ev",
|
||||
"environment_rotation",
|
||||
"environment_intensity",
|
||||
"background",
|
||||
"job",
|
||||
"surfaces",
|
||||
"optics",
|
||||
"stage",
|
||||
];
|
||||
|
||||
/// Each setup field, without its own schema: `patched_settings` checks the values.
|
||||
fn setup_properties() -> serde_json::Map<String, Value> {
|
||||
SETUP_FIELDS.iter().map(|field| (field.to_string(), Value::Object(serde_json::Map::new()))).collect()
|
||||
}
|
||||
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Changes to the setup made with the binding, as `render.settings.set` takes them: at most 13 of the setup's fields. Default: none.",
|
||||
"type": "object",
|
||||
"properties": setup_properties(),
|
||||
"additionalProperties": false,
|
||||
"maxProperties": 13,
|
||||
"default": {}
|
||||
})
|
||||
}
|
||||
|
||||
/// A body of the design, by the identity a render setup keeps for it: its definition, the
|
||||
/// feature that made it, and the component occurrence it's in. The ids survive a rebuild, so a
|
||||
/// binding follows the body, and never another body that took its place in the list.
|
||||
fn target_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A body, by the identity a render setup keeps for it, as the setup's `surfaces` or `optics` list gives it. A component and its occurrence path are both 0 and empty for a body outside components.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"definition": {"description": "The design or component definition that holds the body: 1 to 1024 bytes, without control characters.", "type": "string"},
|
||||
"feature": {"description": "The id of the feature that made the body: 1 or more.", "type": "integer", "minimum": 1},
|
||||
"component": {"description": "The id of the component the body is in, or 0 outside components.", "type": "integer", "minimum": 0},
|
||||
"occurrence_path": {"description": "The occurrence ids from the top of the design down to the body's occurrence, each 1 or more and none twice; empty outside components.", "type": "array", "items": {"type": "integer", "minimum": 1}}
|
||||
},
|
||||
"required": ["definition", "feature", "component", "occurrence_path"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
/// Optical numbers for a body, as `solidcraft_doc::render_optics::OpticalMaterial` reads them.
|
||||
/// Every field has a default, so `{}` is clear glass with a refractive index of 1.5.
|
||||
fn optics_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The body's optical numbers, for the CPU or GPU renderer. Each field has a default: `{}` is clear glass with a refractive index of 1.5. These are render-only numbers, not an engineering material.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ior": {"description": "Refractive index. Default: 1.5.", "type": "number"},
|
||||
"absorption_per_mm": {"description": "How much light the inside absorbs per mm, as linear red, green and blue: each 0 or more. Default: none.", "type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 3, "maxItems": 3},
|
||||
"boundary": {"description": "How the body's surface bounds the medium: `thin_sheet` (a sheet of `thickness_mm`), `closed_box` or `closed_mesh` (the thickness comes from the geometry). Default: closed_mesh.", "type": "string", "enum": ["thin_sheet", "closed_box", "closed_mesh"]},
|
||||
"thickness_mm": {"description": "A thin sheet's thickness (mm); 0 for a closed boundary. Default: 0.", "type": "number", "minimum": 0},
|
||||
"shadow_transmission": {"description": "Let light through for shadows too. Default: false.", "type": "boolean"},
|
||||
"model": {
|
||||
"description": "Advanced Glass interface. Default: null (no model). A model requires the interface IOR to stay in [1, 3], including the full 380 to 780 nm range when dispersion is enabled.",
|
||||
"type": ["object", "null"],
|
||||
"default": null,
|
||||
"properties": {
|
||||
"roughness": {
|
||||
"description": "Isotropic roughness, from 0 (smooth) to 1. Very small positive values can exceed transport precision. Default: 0.",
|
||||
"type": "number", "minimum": 0, "maximum": 1, "default": 0
|
||||
},
|
||||
"dispersion": {
|
||||
"description": "Three-band chromatic dispersion. Default: null (no dispersion).",
|
||||
"type": ["object", "null"],
|
||||
"default": null,
|
||||
"properties": {
|
||||
"abbe_number": {"description": "Abbe number, from 5 to 200.", "type": "number", "minimum": 5, "maximum": 200}
|
||||
},
|
||||
"required": ["abbe_number"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"thin_film": {
|
||||
"description": "One lossless film. Default: null (no film).",
|
||||
"type": ["object", "null"],
|
||||
"default": null,
|
||||
"properties": {
|
||||
"ior": {"description": "Film refractive index, from 1 to 3.", "type": "number", "minimum": 1, "maximum": 3},
|
||||
"thickness_nm": {"description": "Film thickness in nm, from 0 to 100000. Zero has no film effect.", "type": "number", "minimum": 0, "maximum": 100000}
|
||||
},
|
||||
"required": ["ior", "thickness_nm"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"subsurface": {
|
||||
"description": "Light scattering inside the body, as in wax or skin; `null` for none. Default: none.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scattering_per_mm": {"description": "How often light scatters per mm: 0 to 100.", "type": "number", "minimum": 0, "maximum": 100},
|
||||
"anisotropy": {"description": "Which way it scatters: -0.95 (back) to 0.95 (forward). Default: 0, every way alike.", "type": "number", "minimum": -0.95, "maximum": 0.95}
|
||||
},
|
||||
"required": ["scattering_per_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
/// `surface`: a library surface placed on a body, exactly as `render.library.surface.prepare`
|
||||
/// takes it. That command isn't converted yet, so its keys are described here.
|
||||
fn surface_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The library surface and where it goes on the body, as `render.library.surface.prepare` takes it.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "The surface: `SURF-` and its number, as `render.library.list` gives it.", "type": "string"},
|
||||
"body_index": {"description": "The body's place in the render scene, from 0, as `render.library.targets` gives it.", "type": "integer", "minimum": 0},
|
||||
"body_name": {"description": "The body's name at that place, as `render.library.targets` gives it; a body that moved in the list is refused.", "type": "string"},
|
||||
"origin": {"description": "Where the maps start, in mm, as [x, y, z]. Default: the centre of the body's bounding box.", "type": ["array", "null"], "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"plane": {"description": "The plane the maps lie in: `xy`, `yz` or `zx`. Default: xy.", "type": "string", "enum": ["xy", "yz", "zx"]},
|
||||
"scale": {"description": "How much larger than authored the maps repeat: 0.001 to 1000. Default: 1.", "type": "number", "minimum": 0.001, "maximum": 1000}
|
||||
},
|
||||
"required": ["id", "body_index", "body_name"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind a body of a render setup to a library surface's authored maps, for the CPU or GPU renderer,
|
||||
/// in one undo step. The binding keeps the maps' exact source hashes and the body's identity,
|
||||
/// mapped in the body's own millimetres, so it follows the body when it moves. Needs a build with
|
||||
/// the bundled render library and a CPU or GPU setup.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SurfaceBindParams {
|
||||
/// The render setup, saved or new.
|
||||
pub name: String,
|
||||
#[schemars(schema_with = "surface_schema")]
|
||||
pub surface: Value,
|
||||
#[serde(default = "no_change")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
/// Replace a binding the body already has. Without it, such a body is refused.
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
/// Remove one body's surface binding from a render setup, in one undo step. The design's
|
||||
/// geometry, and any render device, aren't needed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SurfaceUnbindParams {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
#[schemars(schema_with = "target_schema")]
|
||||
pub target: RenderBodyTarget,
|
||||
#[serde(default = "no_change")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
}
|
||||
|
||||
/// Bind a body of a render setup to optical numbers (glass, tinted media, subsurface
|
||||
/// scattering), for the CPU or GPU renderer, in one undo step. The design's geometry and engineering
|
||||
/// materials don't change. The setup may render on the CPU or GPU.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OpticsBindParams {
|
||||
/// The render setup, saved or new.
|
||||
pub name: String,
|
||||
#[serde(default = "no_change")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
/// The body's place in the render scene, from 0, as `render.library.targets` gives it.
|
||||
pub body_index: usize,
|
||||
/// The body's name at that place; a body that moved in the list is refused.
|
||||
pub body_name: String,
|
||||
#[schemars(schema_with = "optics_schema")]
|
||||
pub optics: OpticalMaterial,
|
||||
/// The design's revision when the caller last read it, as `document.inspect` gives it. If
|
||||
/// the design changed since, the call is refused: read it again and retry.
|
||||
pub expected_revision: u64,
|
||||
/// Replace optics the body already has. Without it, such a body is refused.
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
/// Remove one body's optical binding from a saved render setup, in one undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OpticsUnbindParams {
|
||||
/// The saved render setup.
|
||||
pub name: String,
|
||||
#[schemars(schema_with = "target_schema")]
|
||||
pub target: RenderBodyTarget,
|
||||
/// The design's revision when the caller last read it, as `document.inspect` gives it. If
|
||||
/// the design changed since, the call is refused: read it again and retry.
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// What `render.library.surface.bind` and `render.library.surface.unbind` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SurfaceBindingSaved {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// The setup as saved, as the render settings commands give it.
|
||||
pub setup: Value,
|
||||
/// Always `definition_local_mm`: the maps are placed in the body's own millimetres.
|
||||
pub mapping_space: String,
|
||||
/// Always true: the design's geometry and physical materials don't change.
|
||||
pub geometry_and_physical_materials_unchanged: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.optics.bind` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OpticsBindingSaved {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// The setup as saved, as the render settings commands give it.
|
||||
pub setup: Value,
|
||||
/// Always true: the design's geometry and engineering data don't change.
|
||||
pub geometry_and_engineering_unchanged: bool,
|
||||
/// What the image producer checks before it draws the body: closed topology and nested
|
||||
/// containment.
|
||||
pub certification: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.optics.unbind` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct OpticsUnbound {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// Whether the body had optics to remove; `false` leaves the setup as it was.
|
||||
pub removed: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_bindings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,199 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{OpticsBindParams, OpticsBindingSaved, OpticsUnbindParams, OpticsUnbound, SurfaceBindParams, SurfaceBindingSaved, SurfaceUnbindParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// A design with one box, `Lens`, and a saved render setup, `Studio`, that renders with the GPU
|
||||
/// path tracer, at the parameter-check level `level`.
|
||||
fn with_a_studio(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("solid.box", json!({"length": 10, "width": 10, "height": 4, "body_name": "Lens"})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": {"job": {"method": "gpu_path"}}})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `render.optics.bind`'s call for the box, at the design's revision `revision`.
|
||||
fn optics_on_the_lens(revision: u64) -> Value {
|
||||
json!({"name": "Studio", "body_index": 0, "body_name": "Lens", "expected_revision": revision,
|
||||
"optics": {"ior": 1.45, "subsurface": {"scattering_per_mm": 0.4, "anisotropy": 0.2}}})
|
||||
}
|
||||
|
||||
/// The identity `Studio` keeps for the box once its optics are bound, as the unbind commands take it.
|
||||
fn lens_target(s: &Session) -> Value {
|
||||
let target = s.doc.render.setups.get("Studio").and_then(|setup| setup.optics.iter().next()).map(|b| b.target.clone());
|
||||
serde_json::to_value(target.unwrap_or_else(|| panic!("no optics bound"))).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<SurfaceBindParams>(), &["name", "replace", "set", "surface"]);
|
||||
assert_keys(input_schema_for::<SurfaceUnbindParams>(), &["name", "set", "target"]);
|
||||
assert_keys(input_schema_for::<OpticsBindParams>(), &["body_index", "body_name", "expected_revision", "name", "optics", "replace", "set"]);
|
||||
assert_keys(input_schema_for::<OpticsUnbindParams>(), &["expected_revision", "name", "target"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_glass_optics_matches_the_schema_and_the_saved_binding() {
|
||||
let input = input_schema_for::<OpticsBindParams>();
|
||||
let input = input.as_value();
|
||||
let optics = &input["properties"]["optics"];
|
||||
for (value, expected) in [
|
||||
(json!({}), true),
|
||||
(json!({"model": null}), true),
|
||||
(json!({"model": {}}), true),
|
||||
(json!({"model": {"dispersion": null, "thin_film": null}}), true),
|
||||
(json!({"model": {"roughness": 1.01}}), false),
|
||||
(json!({"model": {"dispersion": {}}}), false),
|
||||
(json!({"model": {"dispersion": {"abbe_number": 4.99}}}), false),
|
||||
(json!({"model": {"thin_film": {"ior": 1.37}}}), false),
|
||||
(json!({"model": {"thin_film": {"ior": 3.01, "thickness_nm": 263}}}), false),
|
||||
(json!({"model": {"thin_film": {"ior": 1.37, "thickness_nm": 100001}}}), false),
|
||||
(json!({"model": {"thin_film": {"ior": 1.37, "thickness_nm": 263, "extra": true}}}), false),
|
||||
] {
|
||||
assert_eq!(conforms(input, optics, &value), expected, "{value}");
|
||||
}
|
||||
|
||||
let mut session = with_a_studio(ParamCheck::Strict);
|
||||
let mut request = optics_on_the_lens(session.revision);
|
||||
let model = json!({"roughness": 0.43, "dispersion": {"abbe_number": 57.0}, "thin_film": {"ior": 1.37, "thickness_nm": 263.0}});
|
||||
request["optics"] = json!({"ior": 1.45, "model": &model});
|
||||
assert!(conforms(input, input, &request), "{request}");
|
||||
let reply = session.execute("render.optics.bind", &request).unwrap();
|
||||
assert_eq!(reply["setup"]["optics"][0]["optics"]["model"], model);
|
||||
assert_eq!(reply["geometry_and_engineering_unchanged"], true);
|
||||
}
|
||||
|
||||
/// The handlers refuse these calls after reading them; strict refuses them with their message.
|
||||
/// They need a body, a saved setup and the design's revision, so the shared helper, which has
|
||||
/// a sketch only, doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let stranger = json!({"definition": "", "feature": 1, "component": 0, "occurrence_path": []});
|
||||
let cases: Vec<(&str, Box<dyn Fn(u64) -> Value>)> = vec![
|
||||
(
|
||||
"render.optics.bind",
|
||||
Box::new(|_| json!({"name": "Studio", "body_index": 0, "body_name": "Lens", "expected_revision": 999_999, "optics": {}})),
|
||||
),
|
||||
(
|
||||
"render.optics.bind",
|
||||
Box::new(|r| json!({"name": "Studio", "body_index": 0, "body_name": "Lens", "expected_revision": r, "optics": {"ior": 0.5}})),
|
||||
),
|
||||
(
|
||||
"render.optics.bind",
|
||||
Box::new(|r| json!({"name": "Studio", "body_index": 0, "body_name": "Lens", "expected_revision": r, "optics": {"thickness_mm": 1}})),
|
||||
),
|
||||
("render.optics.bind", Box::new(|r| json!({"name": "Studio", "body_index": 3, "body_name": "Lens", "expected_revision": r, "optics": {}}))),
|
||||
("render.optics.bind", Box::new(|r| json!({"name": "Studio", "body_index": 0, "body_name": "Nope", "expected_revision": r, "optics": {}}))),
|
||||
("render.optics.bind", Box::new(|r| json!({"name": "Raster", "body_index": 0, "body_name": "Lens", "expected_revision": r, "optics": {}}))),
|
||||
(
|
||||
"render.optics.bind",
|
||||
Box::new(|r| json!({"name": "Studio", "set": 5, "body_index": 0, "body_name": "Lens", "expected_revision": r, "optics": {}})),
|
||||
),
|
||||
(
|
||||
"render.optics.unbind",
|
||||
Box::new(
|
||||
|r| json!({"name": "Nope", "target": {"definition": "d", "feature": 1, "component": 0, "occurrence_path": []}, "expected_revision": r}),
|
||||
),
|
||||
),
|
||||
(
|
||||
"render.optics.unbind",
|
||||
Box::new(
|
||||
|_| json!({"name": "Studio", "target": {"definition": "d", "feature": 1, "component": 0, "occurrence_path": []}, "expected_revision": 999_999}),
|
||||
),
|
||||
),
|
||||
("render.optics.unbind", Box::new(move |r| json!({"name": "Studio", "target": stranger, "expected_revision": r}))),
|
||||
(
|
||||
"render.library.surface.unbind",
|
||||
Box::new(|_| json!({"name": "Studio", "target": {"definition": "d", "feature": 0, "component": 0, "occurrence_path": []}})),
|
||||
),
|
||||
(
|
||||
"render.library.surface.unbind",
|
||||
Box::new(|_| json!({"name": " Studio", "target": {"definition": "d", "feature": 1, "component": 0, "occurrence_path": []}})),
|
||||
),
|
||||
(
|
||||
"render.library.surface.unbind",
|
||||
Box::new(|_| json!({"name": "Studio", "set": 5, "target": {"definition": "d", "feature": 1, "component": 0, "occurrence_path": []}})),
|
||||
),
|
||||
// Without the bundled render library, both refuse before reading anything else.
|
||||
("render.library.surface.bind", Box::new(|_| json!({"name": "Studio", "surface": {"id": "MAT-001", "body_index": 0, "body_name": "Lens"}}))),
|
||||
("render.library.surface.bind", Box::new(|_| json!({"name": "Raster", "surface": {"id": "SURF-001", "body_index": 0, "body_name": "Lens"}}))),
|
||||
];
|
||||
for (id, p) in &cases {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = with_a_studio(level);
|
||||
let p = p(s.revision);
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {} should fail today", p(0));
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {}", p(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// #11741: binding and unbinding reply as their output schemas say, in Warn without warnings.
|
||||
/// With the bundled render library, a surface binding is saved and removed as well. Like the
|
||||
/// handlers, warn and strict refuse a misspelt key.
|
||||
#[test]
|
||||
fn binding_commands_reply_with_their_schemas() {
|
||||
let (optics_saved, unbound, surface_saved) =
|
||||
(output_schema_for::<OpticsBindingSaved>(), output_schema_for::<OpticsUnbound>(), output_schema_for::<SurfaceBindingSaved>());
|
||||
let mut s = with_a_studio(ParamCheck::Warn);
|
||||
let run = |s: &mut Session, schema: &schemars::Schema, id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let revision = s.revision;
|
||||
let r = run(&mut s, &optics_saved, "render.optics.bind", optics_on_the_lens(revision));
|
||||
assert!(r["setup"]["optics"].is_array(), "{r}");
|
||||
let target = lens_target(&s);
|
||||
let p = json!({"name": "Studio", "target": target, "expected_revision": s.revision});
|
||||
assert_eq!(run(&mut s, &unbound, "render.optics.unbind", p)["removed"], true);
|
||||
let p = json!({"name": "Studio", "target": target, "expected_revision": s.revision});
|
||||
assert_eq!(run(&mut s, &unbound, "render.optics.unbind", p)["removed"], false, "nothing left to remove");
|
||||
// Removing a surface binding the body doesn't have saves the setup as it is.
|
||||
run(&mut s, &surface_saved, "render.library.surface.unbind", json!({"name": "Studio", "target": target, "set": {}}));
|
||||
if cfg!(feature = "bundled-render-library") {
|
||||
let surface = json!({"id": "SURF-001", "body_index": 0, "body_name": "Lens", "plane": "xy", "scale": 2, "origin": [0, 0, 0]});
|
||||
let r = run(&mut s, &surface_saved, "render.library.surface.bind", json!({"name": "Studio", "surface": surface, "replace": false}));
|
||||
assert_eq!(r["mapping_space"], "definition_local_mm", "{r}");
|
||||
run(&mut s, &surface_saved, "render.library.surface.unbind", json!({"name": "Studio", "target": target}));
|
||||
}
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = with_a_studio(level);
|
||||
let mut p = optics_on_the_lens(s.revision);
|
||||
// Rename the key, so the call has a misspelt `optic` and no `optics` at all.
|
||||
if let Some(keys) = p.as_object_mut()
|
||||
&& let Some(optics) = keys.remove("optics")
|
||||
{
|
||||
keys.insert("optic".into(), optics);
|
||||
}
|
||||
let error = s.execute("render.optics.bind", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`optic`"), "{level:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `set` names the render setup's fields: `patched_settings` reads the patched setup as a
|
||||
/// `RenderSetup`, which refuses any other key, so the schema refuses a misspelt one too (and
|
||||
/// more than 13 at once).
|
||||
#[test]
|
||||
fn the_set_schema_names_the_setup_fields() {
|
||||
let root = input_schema_for::<SurfaceBindParams>();
|
||||
let root = root.as_value();
|
||||
let set = &root["properties"]["set"];
|
||||
for (patch, takes) in
|
||||
[(json!({}), true), (json!({"width": 640, "height": 480, "job": {"method": "gpu_path"}}), true), (json!({"widht": 640}), false)]
|
||||
{
|
||||
assert_eq!(conforms(root, set, &patch), takes, "{patch}");
|
||||
}
|
||||
// `conforms` doesn't read `maxProperties`, so the limit is pinned here.
|
||||
assert_eq!((set["maxProperties"].as_u64(), set["properties"].as_object().map(|p| p.len())), (Some(13), Some(super::SETUP_FIELDS.len())), "{set}");
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
//! Contracts and reply types for the render camera commands in `cmd/render.rs` (#12288): Set,
|
||||
//! Inspect and Frame Render Camera, and Apply Render Camera. They change only the camera in the
|
||||
//! view, never the design.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a saved setup exists, and whether the
|
||||
//! product can be framed, stay the handlers' to check.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::not_blank_pattern;
|
||||
|
||||
/// The largest size a camera's target coordinates and distance may have, in mm, as a saved
|
||||
/// render setup keeps them (`RenderSetup::validate`).
|
||||
const MAX_LENGTH_MM: f64 = 1e9;
|
||||
/// The largest size a camera's yaw, pitch and roll may have, in radians, likewise.
|
||||
const MAX_ANGLE: f64 = 1e6;
|
||||
/// The picture's width over its height that Frame Render Subject takes: the range a preview
|
||||
/// frame can show (`frame_rect` in the Render workspace).
|
||||
const MIN_ASPECT: f64 = 1e-6;
|
||||
const MAX_ASPECT: f64 = 1e6;
|
||||
|
||||
/// A render camera, as a saved render setup keeps it: strict about its keys, with finite
|
||||
/// lengths and angles within [`MAX_LENGTH_MM`] and [`MAX_ANGLE`], a positive distance and a
|
||||
/// field of view in [0, π).
|
||||
///
|
||||
/// It is not the view commands' `types::CameraParam`: those read a camera more loosely and
|
||||
/// refuse an explicit `up`, while a render camera keeps both `roll` and `up`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RenderCamera(pub solidcraft_render::Camera);
|
||||
|
||||
impl JsonSchema for RenderCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RenderCamera".into()
|
||||
}
|
||||
/// The size bounds are for a camera given (the input schema) only. A reply gives back the
|
||||
/// camera in the view, which Frame Render Subject may have moved beyond them (a picture far
|
||||
/// taller than wide moves it back that much) and the view commands don't bound, so the
|
||||
/// output schema has the camera's shape alone.
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let given = generator.contract().is_deserialize();
|
||||
// `schema` with a largest size of `limit` either way, in the input schema only.
|
||||
let sized = |mut schema: Value, limit: f64| {
|
||||
if given {
|
||||
schema["minimum"] = json!(-limit);
|
||||
schema["maximum"] = json!(limit);
|
||||
}
|
||||
schema
|
||||
};
|
||||
let angle = |description: &str| sized(json!({"description": description, "type": "number"}), MAX_ANGLE);
|
||||
let coordinate = sized(json!({"type": "number"}), MAX_LENGTH_MM);
|
||||
let mut roll = angle("Turn about the viewing direction; 0 keeps the up axis upright. Default: 0.");
|
||||
roll["default"] = json!(0.0);
|
||||
let mut distance = json!({"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0});
|
||||
if given {
|
||||
distance["maximum"] = json!(MAX_LENGTH_MM);
|
||||
}
|
||||
let bounds = if given { " Lengths are at most 1e9 mm in size, and angles at most 1e6 radians." } else { "" };
|
||||
json_schema!({
|
||||
"description": format!("A render camera orbiting a target point. Lengths are in mm and angles in radians, measured in the frame of the up axis.{bounds}"),
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"description": "The point the camera looks at, in mm: [x, y, z].",
|
||||
"type": "array",
|
||||
"items": coordinate,
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"yaw": angle("Turn about the up axis; 0 looks along +Y, from the front."),
|
||||
"pitch": angle("Elevation above the ground plane: π/2 looks straight down."),
|
||||
"roll": roll,
|
||||
"distance": distance,
|
||||
"fov": {"description": "Vertical field of view; 0 is orthographic.", "type": "number", "minimum": 0, "exclusiveMaximum": std::f64::consts::PI},
|
||||
"up": {"description": "The world's up axis. Default: z.", "enum": ["z", "y"], "default": "z"}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance", "fov"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `serde(default)` makes schemars publish `"default": null` on a key; drop it from a key the
|
||||
/// schema requires.
|
||||
fn drop_default(schema: &mut Schema) {
|
||||
schema.remove("default");
|
||||
}
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it with its own message when it's left out.
|
||||
/// (`#[schemars(required)]` doesn't do this for a key with `serde(default)`.)
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push(key.into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
schema.insert("required".into(), Value::Array(vec![key.into()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn camera_required(schema: &mut Schema) {
|
||||
require(schema, "camera");
|
||||
}
|
||||
|
||||
fn name_required(schema: &mut Schema) {
|
||||
require(schema, "name");
|
||||
}
|
||||
|
||||
/// Set the camera in the view from a complete camera snapshot. Nothing in the design changes,
|
||||
/// and there is no undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = camera_required)]
|
||||
pub struct SetParams {
|
||||
/// The camera: `target`, `yaw`, `pitch`, `distance` and `fov`, and optionally `roll` and
|
||||
/// `up`.
|
||||
// Optional in the type, required in the schema: left out, the handler refuses the call
|
||||
// with its own message.
|
||||
#[serde(default, deserialize_with = "render_camera")]
|
||||
#[schemars(with = "RenderCamera", transform = drop_default)]
|
||||
pub camera: Option<RenderCamera>,
|
||||
}
|
||||
|
||||
/// A camera as `render.camera.set` reads it: a render setup holding only that camera, decoded
|
||||
/// and checked as a saved one is, with the same messages.
|
||||
fn render_camera<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RenderCamera>, D::Error> {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"camera": Value::deserialize(d)?})).map_err(|e| D::Error::custom(whole(e)))?;
|
||||
setup.validate().map_err(|e| D::Error::custom(whole(e)))?;
|
||||
Ok(Some(RenderCamera(setup.camera)))
|
||||
}
|
||||
|
||||
/// The camera set in the view, if any. No parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct GetParams {}
|
||||
|
||||
/// Fit the camera in the view to the visible product, keeping its direction: for a picture
|
||||
/// taller than wide it moves back, so the picture's sides hold the product too.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FitParams {
|
||||
// `skip_serializing_if` keeps schemars from publishing `"default": null`, which the reader
|
||||
// refuses, over the schema's own default of 1.
|
||||
#[serde(default, deserialize_with = "aspect", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "aspect_schema")]
|
||||
pub aspect: Option<f64>,
|
||||
}
|
||||
|
||||
/// `aspect`, with `render.camera.fit`'s message for anything outside its range, `null`
|
||||
/// included.
|
||||
fn aspect<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)?.as_f64() {
|
||||
Some(a) if (MIN_ASPECT..=MAX_ASPECT).contains(&a) => Ok(Some(a)),
|
||||
_ => Err(D::Error::custom(whole("aspect must be a number from 0.000001 to 1000000: the picture's width over its height"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn aspect_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The picture's width over its height: 0.000001 to 1000000. Default: 1.",
|
||||
"type": "number",
|
||||
"minimum": MIN_ASPECT,
|
||||
"maximum": MAX_ASPECT,
|
||||
"default": 1.0
|
||||
})
|
||||
}
|
||||
|
||||
/// Put a saved render setup's camera, with its roll and up axis, in the view. Nothing in the
|
||||
/// design changes, and there is no undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = name_required)]
|
||||
pub struct ApplyParams {
|
||||
/// The saved render setup, by name: trimmed, at most 128 bytes, with no control characters.
|
||||
// Optional in the type, required in the schema: left out (or `null`), the handler refuses
|
||||
// the call with its own message.
|
||||
#[serde(default, deserialize_with = "setup_name")]
|
||||
#[schemars(schema_with = "setup_name_schema", transform = drop_default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A setup's name as `render.settings.apply` reads it: `null` is left out, other text than a
|
||||
/// string gets serde's message, and a string must be a valid setup name.
|
||||
fn setup_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
let Some(name) = Option::<String>::deserialize(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
RenderSettings::validate_name(&name).map_err(|e| D::Error::custom(whole(e)))?;
|
||||
Ok(Some(name))
|
||||
}
|
||||
|
||||
/// The pattern says "not blank"; trimmed, at most 128 bytes and free of control characters,
|
||||
/// which JSON Schema can't count or class portably, are in the field's description and
|
||||
/// checked by [`setup_name`].
|
||||
fn setup_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": not_blank_pattern()
|
||||
})
|
||||
}
|
||||
|
||||
/// What `render.camera.set` returns: the camera now in the view.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CameraSet {
|
||||
pub camera: RenderCamera,
|
||||
/// Always false: the design doesn't change.
|
||||
pub document_changed: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.camera.get` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CameraInfo {
|
||||
/// The camera set in the view, or `null` when none is.
|
||||
pub camera: Option<RenderCamera>,
|
||||
/// Whether a camera is set in the view.
|
||||
pub explicit_view: bool,
|
||||
/// Always false: the design doesn't change.
|
||||
pub document_changed: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.camera.fit` returns: the fitted camera, now in the view.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CameraFitted {
|
||||
pub camera: RenderCamera,
|
||||
/// Whether nothing visible was there to frame. The camera then isn't moved: it stays the
|
||||
/// one set in the view, or the default camera when none was.
|
||||
pub empty: bool,
|
||||
/// Always false: the design doesn't change.
|
||||
pub document_changed: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.settings.apply` returns: the setup and its camera, now in the view.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CameraApplied {
|
||||
pub name: String,
|
||||
pub camera: RenderCamera,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_camera_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,195 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ApplyParams, CameraApplied, CameraFitted, CameraInfo, CameraSet, FitParams, GetParams, SetParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// A render camera that isn't the default one, as `render.camera.set` takes it.
|
||||
fn camera() -> Value {
|
||||
json!(solidcraft_render::Camera { yaw: 0.2, pitch: 0.3, roll: 0.1, ..Default::default() })
|
||||
}
|
||||
|
||||
/// `camera()` with `key` set to `value`, or taken out when `value` is `None`.
|
||||
fn camera_with(key: &str, value: Option<Value>) -> Value {
|
||||
let mut c = camera();
|
||||
if let Some(fields) = c.as_object_mut() {
|
||||
match value {
|
||||
Some(v) => fields.insert(key.into(), v),
|
||||
None => fields.remove(key),
|
||||
};
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<SetParams>(), &["camera"]);
|
||||
assert_keys(input_schema_for::<GetParams>(), &[]);
|
||||
assert_keys(input_schema_for::<FitParams>(), &["aspect"]);
|
||||
assert_keys(input_schema_for::<ApplyParams>(), &["name"]);
|
||||
}
|
||||
|
||||
/// #12288: calls that fail today get the same message in strict. (Inspect Render Camera fails
|
||||
/// only on an unknown key, which strict refuses in the check's own words, as for every
|
||||
/// converted command.)
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let mut cases = vec![
|
||||
("render.camera.set", json!({})),
|
||||
("render.camera.set", json!({"camera": null})),
|
||||
("render.camera.set", json!({"camera": []})),
|
||||
("render.camera.set", json!({"camera": camera_with("yaw", None)})),
|
||||
("render.camera.set", json!({"camera": camera_with("fov_degrees", Some(json!(45)))})),
|
||||
("render.camera.set", json!({"camera": camera_with("target", Some(json!([0, 0])))})),
|
||||
];
|
||||
for (key, value) in [
|
||||
("roll", json!(null)),
|
||||
("roll", json!("invalid")),
|
||||
("roll", json!(1e7)),
|
||||
("up", json!("invalid")),
|
||||
("up", json!(null)),
|
||||
("distance", json!(0)),
|
||||
("distance", json!(1e10)),
|
||||
("fov", json!(-1)),
|
||||
("fov", json!(4)),
|
||||
("target", json!([1e10, 0, 0])),
|
||||
] {
|
||||
cases.push(("render.camera.set", json!({"camera": camera_with(key, Some(value))})));
|
||||
}
|
||||
for aspect in [json!(0), json!(-1), json!(1e-320), json!(1e-7), json!(1e7), json!("tall"), json!(null)] {
|
||||
cases.push(("render.camera.fit", json!({"aspect": aspect})));
|
||||
}
|
||||
for name in [json!(null), json!(5), json!(""), json!(" Studio"), json!("Studio\u{7}"), json!("x".repeat(129)), json!("Nope")] {
|
||||
cases.push(("render.settings.apply", json!({"name": name})));
|
||||
}
|
||||
cases.push(("render.settings.apply", json!({})));
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// #12288: the camera's schema takes exactly what Set Render Camera takes (a saved setup's
|
||||
/// camera, its keys and bounds), and the aspect's exactly what Frame Render Subject takes: each
|
||||
/// case is checked against the handler too.
|
||||
#[test]
|
||||
fn the_camera_and_aspect_schemas_take_what_the_handlers_take() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 15, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let set = input_schema_for::<SetParams>();
|
||||
let set = set.as_value();
|
||||
let mut calls = vec![
|
||||
json!({"camera": camera()}),
|
||||
json!({}),
|
||||
json!({"camera": null}),
|
||||
json!({"camera": camera(), "width": 1}),
|
||||
json!({"camera": camera_with("roll", None)}),
|
||||
json!({"camera": camera_with("up", None)}),
|
||||
json!({"camera": camera_with("yaw", None)}),
|
||||
json!({"camera": camera_with("fov_degrees", Some(json!(45)))}),
|
||||
];
|
||||
for (key, value) in [
|
||||
("roll", json!(1e6)),
|
||||
("roll", json!(1_000_001.0)),
|
||||
("roll", json!(null)),
|
||||
("yaw", json!(-1e6)),
|
||||
("pitch", json!(-2e6)),
|
||||
("up", json!("y")),
|
||||
("up", json!("x")),
|
||||
("distance", json!(1e-9)),
|
||||
("distance", json!(1e9)),
|
||||
("distance", json!(1_000_000_001.0)),
|
||||
("distance", json!(0)),
|
||||
("fov", json!(0)),
|
||||
("fov", json!(3.0)),
|
||||
("fov", json!(std::f64::consts::PI)),
|
||||
("target", json!([1e9, -1e9, 0])),
|
||||
("target", json!([1_000_000_001.0, 0, 0])),
|
||||
("target", json!([0, 0, 0, 0])),
|
||||
("target", json!("origin")),
|
||||
] {
|
||||
calls.push(json!({"camera": camera_with(key, Some(value))}));
|
||||
}
|
||||
for p in calls {
|
||||
let takes = s.execute("render.camera.set", &p).is_ok();
|
||||
assert_eq!(conforms(set, set, &p), takes, "{p}");
|
||||
}
|
||||
let fit = input_schema_for::<FitParams>();
|
||||
let fit = fit.as_value();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"aspect": 1}),
|
||||
json!({"aspect": 0.5}),
|
||||
json!({"aspect": 1e-6}),
|
||||
json!({"aspect": 1e6}),
|
||||
json!({"aspect": 1e-7}),
|
||||
json!({"aspect": 1e7}),
|
||||
json!({"aspect": 0}),
|
||||
json!({"aspect": "tall"}),
|
||||
json!({"aspect": null}),
|
||||
json!({"scale": 0}),
|
||||
] {
|
||||
let takes = s.execute("render.camera.fit", &p).is_ok();
|
||||
assert_eq!(conforms(fit, fit, &p), takes, "{p}");
|
||||
}
|
||||
// Whether the setup exists is the handler's to check, so these are the schema's alone.
|
||||
let apply = input_schema_for::<ApplyParams>();
|
||||
let apply = apply.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"name": "Studio"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"name": null}), false),
|
||||
(json!({"name": 5}), false),
|
||||
(json!({"name": ""}), false),
|
||||
(json!({"name": "x".repeat(129)}), false),
|
||||
(json!({"name": "Studio", "camera": camera()}), false),
|
||||
] {
|
||||
assert_eq!(conforms(apply, apply, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12288: the four commands reply as their output schemas say, in Warn without warnings:
|
||||
/// Inspect before and after a camera is set, Frame with nothing to frame and then a box (wide
|
||||
/// and tall), Set, and Apply of a saved setup's camera. The handlers already refuse an unknown
|
||||
/// key, so a misspelt one fails in every mode; strict says so in the check's words.
|
||||
#[test]
|
||||
fn render_camera_commands_reply_with_their_schemas() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let r = run(&mut s, "render.camera.get", json!({}), output_schema_for::<CameraInfo>());
|
||||
assert_eq!((&r["camera"], &r["explicit_view"]), (&json!(null), &json!(false)), "{r}");
|
||||
let r = run(&mut s, "render.camera.fit", json!({}), output_schema_for::<CameraFitted>());
|
||||
assert_eq!(r["empty"], true, "nothing to frame: {r}");
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 15, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = run(&mut s, "render.camera.fit", json!({"aspect": 0.5}), output_schema_for::<CameraFitted>());
|
||||
assert_eq!(r["empty"], false, "{r}");
|
||||
let r = run(&mut s, "render.camera.set", json!({"camera": camera()}), output_schema_for::<CameraSet>());
|
||||
assert_eq!(r["camera"], camera(), "{r}");
|
||||
let r = run(&mut s, "render.camera.get", json!({}), output_schema_for::<CameraInfo>());
|
||||
assert_eq!((&r["camera"], &r["explicit_view"]), (&camera(), &json!(true)), "{r}");
|
||||
let saved = camera_with("up", Some(json!("y")));
|
||||
s.execute("render.settings.set", &json!({"name": "Studio", "set": {"camera": saved}})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = run(&mut s, "render.settings.apply", json!({"name": "Studio"}), output_schema_for::<CameraApplied>());
|
||||
assert_eq!((&r["name"], &r["camera"]), (&json!("Studio"), &saved), "{r}");
|
||||
// A picture far taller than wide moves the camera back beyond what a given camera may be:
|
||||
// the reply still matches its schema, which has no size bounds, and the input schema
|
||||
// refuses that camera, as Set Render Camera does.
|
||||
s.execute("solid.box", &json!({"length": 5000, "width": 5000, "height": 5000})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let far = run(&mut s, "render.camera.fit", json!({"aspect": 1e-6}), output_schema_for::<CameraFitted>());
|
||||
assert!(far["camera"]["distance"].as_f64().is_some_and(|d| d > 1e9), "{far}");
|
||||
let set = input_schema_for::<SetParams>();
|
||||
let given = json!({"camera": far["camera"]});
|
||||
assert!(!conforms(set.as_value(), set.as_value(), &given), "{given}");
|
||||
assert!(s.execute("render.camera.set", &given).is_err(), "{given}");
|
||||
let misspelt = json!({"aspekt": 2});
|
||||
for level in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
|
||||
s.param_check = level;
|
||||
let error = s.execute("render.camera.fit", &misspelt).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let expected = if level == ParamCheck::Strict { "`aspekt`" } else { "expected an empty parameter object or {aspect}" };
|
||||
assert!(error.contains(expected), "{level:?}: {error}");
|
||||
}
|
||||
}
|
||||
@@ -322,8 +322,6 @@ pub struct RenderTarget {
|
||||
pub ordinal: usize,
|
||||
/// Its name, with its component's path when it is in one.
|
||||
pub name: String,
|
||||
/// Its saved definition, feature and occurrence identity; `null` when no stable target exists.
|
||||
pub stable_target: Option<StableRenderTarget>,
|
||||
/// Its colour: red, green and blue, 0 to 255.
|
||||
pub color: [u8; 3],
|
||||
/// How opaque it is: 1 is solid.
|
||||
@@ -332,30 +330,6 @@ pub struct RenderTarget {
|
||||
pub pbr: Option<Pbr>,
|
||||
}
|
||||
|
||||
/// A stable body identity accepted by saved surface, optical and clear-coat bindings.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StableRenderTarget {
|
||||
/// The saved body definition's name.
|
||||
pub definition: String,
|
||||
/// The feature that produced the body.
|
||||
pub feature: u64,
|
||||
/// The owning component, or 0 for the root definition.
|
||||
pub component: u64,
|
||||
/// The ordered occurrence ids from the root; empty for a root definition.
|
||||
pub occurrence_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl From<&solidcraft_doc::render_surface::RenderBodyTarget> for StableRenderTarget {
|
||||
fn from(target: &solidcraft_doc::render_surface::RenderBodyTarget) -> Self {
|
||||
Self {
|
||||
definition: target.definition.clone(),
|
||||
feature: target.feature,
|
||||
component: target.component,
|
||||
occurrence_path: target.occurrence_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A body's PBR look.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Pbr {
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
LibraryDescribeParams, LibraryDescription, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryTargets,
|
||||
LibraryTargetsParams, RenderTarget, StableRenderTarget,
|
||||
LibraryTargetsParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -124,7 +124,7 @@ fn render_library_commands_reply_with_their_schemas() {
|
||||
let empty = run(&mut s, "render.library.targets", json!({}));
|
||||
matches(&targets, &empty);
|
||||
assert!(empty["bounds"].is_null(), "{empty}");
|
||||
let created = run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5, "body_name": "Target"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5, "body_name": "Target"}));
|
||||
run(
|
||||
&mut s,
|
||||
"appearance.assign",
|
||||
@@ -134,35 +134,6 @@ fn render_library_commands_reply_with_their_schemas() {
|
||||
matches(&targets, &r);
|
||||
assert_eq!((r["total"].as_u64(), r["items"][0]["name"].as_str()), (Some(1), Some("Target")), "{r}");
|
||||
assert!(r["items"][0]["pbr"].is_object() && r["bounds"].is_object(), "{r}");
|
||||
assert_eq!(
|
||||
r["items"][0]["stable_target"],
|
||||
json!({"definition":"Target","feature":created["feature"],"component":0,"occurrence_path":[]}),
|
||||
"the typed reply must preserve the saved root-body identity"
|
||||
);
|
||||
let mut without_stable = r.clone();
|
||||
without_stable["items"][0] = serde_json::to_value(RenderTarget {
|
||||
ordinal: 0,
|
||||
name: "Unbound".into(),
|
||||
stable_target: None,
|
||||
color: [180, 120, 70],
|
||||
opacity: 1.0,
|
||||
pbr: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(without_stable["items"][0].get("stable_target"), Some(&Value::Null), "an unavailable identity stays an explicit null");
|
||||
matches(&targets, &without_stable);
|
||||
let authority =
|
||||
solidcraft_doc::render_surface::RenderBodyTarget { definition: "Part".into(), feature: 7, component: 3, occurrence_path: vec![11, 3, 6] };
|
||||
authority.validate().unwrap();
|
||||
let mut nested = r.clone();
|
||||
nested["items"][0]["stable_target"] = serde_json::to_value(StableRenderTarget::from(&authority)).unwrap();
|
||||
matches(&targets, &nested);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<solidcraft_doc::render_surface::RenderBodyTarget>(nested["items"][0]["stable_target"].clone()).unwrap(),
|
||||
authority
|
||||
);
|
||||
nested["items"][0]["stable_target"]["occurrence_path"] = json!([11, "not an occurrence id", 6]);
|
||||
assert!(!conforms(targets.as_value(), targets.as_value(), &nested), "the reply schema must describe the stable identity's actual fields");
|
||||
if cfg!(feature = "bundled-render-library") {
|
||||
matches(&preview, &run(&mut s, "render.library.preview", json!({"id": "SURF-001"})));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct ListResult {
|
||||
pub lights: Vec<Light>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Light {
|
||||
/// Family and zero-based source slot: directional:N, point:N, spot:N or area:N.
|
||||
/// Point and spot share their punctual-list index. Stable across numerical edits,
|
||||
@@ -41,13 +41,13 @@ pub struct Light {
|
||||
pub geometry: Geometry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Geometry {
|
||||
Directional {
|
||||
/// World direction towards the source, as stored; need not be unit length.
|
||||
direction: [f64; 3],
|
||||
/// Present for a finite-disk Sun, in radians. CPU requires explicit Path transport.
|
||||
/// Present for the GPU finite-disk sun only, in radians.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
angular_radius_rad: Option<f64>,
|
||||
},
|
||||
@@ -134,11 +134,6 @@ pub struct LightPatch {
|
||||
/// World-space fields, lowered to the existing renderer parameters.
|
||||
pub position_mm: Option<[f64; 3]>,
|
||||
pub direction: Option<[f64; 3]>,
|
||||
/// Spot-only world emission direction FROM the light. Conflicts with aim_at.
|
||||
pub emitted_axis: Option<[f64; 3]>,
|
||||
/// Spot half cones in radians; require 0 <= inner < outer <= pi/2.
|
||||
pub inner_half_angle_radians: Option<f64>,
|
||||
pub outer_half_angle_radians: Option<f64>,
|
||||
pub edge_u_mm: Option<[f64; 3]>,
|
||||
pub edge_v_mm: Option<[f64; 3]>,
|
||||
pub radiance: Option<[f64; 3]>,
|
||||
@@ -159,21 +154,9 @@ pub struct EditParams {
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// A surviving source's address before and after this exact structural edit.
|
||||
/// `before` verifies the mapping belongs to the caller's draft, not a different
|
||||
/// settings override. New or duplicated sources never inherit an old identity.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LightIdRemap {
|
||||
pub before: Light,
|
||||
pub after_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EditResult {
|
||||
pub id: Option<String>,
|
||||
#[schemars(length(max = 20))]
|
||||
pub id_remap: Vec<LightIdRemap>,
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ fn no_change() -> Value {
|
||||
}
|
||||
|
||||
/// `set`: changes to the setup, as `render.settings.set` takes them. `patched_settings` in
|
||||
/// `cmd/render.rs` refuses anything but an object of at most 15 fields, with its own message.
|
||||
/// `cmd/render.rs` refuses anything but an object of at most 13 fields, with its own message.
|
||||
/// The fields of a render setup, as `render.settings.get` gives them. A `set` patch may give up
|
||||
/// to 15 of them; `patched_settings` reads the patched setup as a `RenderSetup`, which refuses any
|
||||
/// to 13 of them; `patched_settings` reads the patched setup as a `RenderSetup`, which refuses any
|
||||
/// other key.
|
||||
const SETUP_FIELDS: &[&str] = &[
|
||||
"backend",
|
||||
@@ -31,7 +31,6 @@ const SETUP_FIELDS: &[&str] = &[
|
||||
"job",
|
||||
"surfaces",
|
||||
"optics",
|
||||
"coats",
|
||||
"stage",
|
||||
];
|
||||
|
||||
@@ -42,11 +41,11 @@ fn setup_properties() -> serde_json::Map<String, Value> {
|
||||
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Changes to the setup before the studio is fitted, as `render.settings.set` takes them: at most 15 of the setup's fields. The setup they make must render with the CPU or GPU path tracer. Default: none.",
|
||||
"description": "Changes to the setup before the studio is fitted, as `render.settings.set` takes them: at most 13 of the setup's fields. The setup they make must render with the GPU path tracer. Default: none.",
|
||||
"type": "object",
|
||||
"properties": setup_properties(),
|
||||
"additionalProperties": false,
|
||||
"maxProperties": 15,
|
||||
"maxProperties": 13,
|
||||
"default": {}
|
||||
})
|
||||
}
|
||||
@@ -91,84 +90,16 @@ pub struct QualityParams {
|
||||
/// The quality profile: `Q-` and its number, as `render.library.list` with `kind: quality`
|
||||
/// gives it.
|
||||
pub id: String,
|
||||
/// `fixed_samples_png` for GPU or `cpu_progressive_png` for CPU cost budgets.
|
||||
/// How to use it: only `fixed_samples_png`, a fixed budget for a PNG still.
|
||||
#[schemars(schema_with = "variant_schema")]
|
||||
pub variant: String,
|
||||
/// Agree that the budget leaves out what the profile asks for beyond a fixed budget (see
|
||||
/// `omitted` in the reply). Required as true.
|
||||
pub acknowledge_omissions: bool,
|
||||
/// Optional CPU budget/adaptive context; refused for the GPU variant.
|
||||
#[serde(default)]
|
||||
pub cpu: Option<CpuQuality>,
|
||||
}
|
||||
|
||||
fn variant_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "enum": ["fixed_samples_png", "cpu_progressive_png"]})
|
||||
}
|
||||
|
||||
/// CPU quality context, deserialized with the renderer's original field types.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuQuality {
|
||||
#[schemars(schema_with = "budget_schema")]
|
||||
pub minimum_budget: solidcraft_render::job::CpuBudget,
|
||||
#[schemars(schema_with = "adaptive_schema")]
|
||||
pub adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
}
|
||||
fn budget_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type":"string","enum":["compact","studio","workstation"]})
|
||||
}
|
||||
fn adaptive_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf":[{"type":"null"},{"type":"object","additionalProperties":false,
|
||||
"properties":{"pilot_samples":{"type":"integer","minimum":0,"maximum":4294967295u64},
|
||||
"min_samples":{"type":"integer","minimum":0,"maximum":4294967295u64},
|
||||
"relative_error":{"type":"number"},"absolute_error":{"type":"number"},"coverage_error":{"type":"number"}}}]})
|
||||
}
|
||||
|
||||
/// Both quality command projections; each serialized branch retains its existing fields.
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum QualityResult {
|
||||
Gpu(QualityPlan),
|
||||
Cpu(CpuQualityPlan),
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CpuQualityPlan {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub variant: String,
|
||||
pub status: String,
|
||||
pub revision: u64,
|
||||
pub document_changed: bool,
|
||||
pub source_unchanged: bool,
|
||||
pub settings: QualitySize,
|
||||
pub cpu_options: CpuQualityOptions,
|
||||
pub cpu_context: CpuQualityContext,
|
||||
pub requires_progressive: bool,
|
||||
pub changed_fields: Vec<String>,
|
||||
pub preserves: Vec<String>,
|
||||
pub omitted: Vec<String>,
|
||||
pub source: Map<String, Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CpuQualityOptions {
|
||||
pub progressive: CpuProgressive,
|
||||
#[schemars(schema_with = "budget_schema")]
|
||||
pub budget: solidcraft_render::job::CpuBudget,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CpuProgressive {
|
||||
pub samples_per_pixel: u32,
|
||||
pub seed: u32,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CpuQualityContext {
|
||||
#[schemars(schema_with = "budget_schema")]
|
||||
pub minimum_budget: solidcraft_render::job::CpuBudget,
|
||||
#[schemars(schema_with = "adaptive_schema")]
|
||||
pub adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
json_schema!({"type": "string", "enum": ["fixed_samples_png"]})
|
||||
}
|
||||
|
||||
/// What `render.preset.resolve` and `render.preset.apply` return: the fitted studio.
|
||||
@@ -195,7 +126,7 @@ pub struct StudioComposition {
|
||||
/// Whether the design changed: always false for resolve; for apply, false when the setup
|
||||
/// already was the studio.
|
||||
pub document_changed: bool,
|
||||
/// `cpu-raytrace` or `gpu-path`, matching the selected setup executor.
|
||||
/// Always `gpu-path`: the studio renders with the GPU path tracer.
|
||||
pub executor: String,
|
||||
/// Always true: applying needs `replace_lighting_and_environment`.
|
||||
pub requires_lighting_consent: bool,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{PresetParams, QualityParams, QualityPlan, QualityResult, StudioComposition};
|
||||
use super::{PresetParams, QualityParams, QualityPlan, StudioComposition};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -26,7 +26,7 @@ fn budget(id: &str) -> Value {
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<PresetParams>(), &["expected_revision", "id", "name", "replace_lighting_and_environment", "replace_stage", "set"]);
|
||||
assert_keys(input_schema_for::<QualityParams>(), &["acknowledge_omissions", "cpu", "id", "variant"]);
|
||||
assert_keys(input_schema_for::<QualityParams>(), &["acknowledge_omissions", "id", "variant"]);
|
||||
}
|
||||
|
||||
/// The handlers refuse these calls after reading them; strict refuses them with their message.
|
||||
@@ -100,7 +100,7 @@ fn preset_commands_reply_with_their_schemas() {
|
||||
|
||||
/// `set` names the render setup's fields: `patched_settings` reads the patched setup as a
|
||||
/// `RenderSetup`, which refuses any other key, so the schema refuses a misspelt one too (and
|
||||
/// more than 15 at once).
|
||||
/// more than 13 at once).
|
||||
#[test]
|
||||
fn the_set_schema_names_the_setup_fields() {
|
||||
let root = input_schema_for::<PresetParams>();
|
||||
@@ -112,36 +112,5 @@ fn the_set_schema_names_the_setup_fields() {
|
||||
assert_eq!(conforms(root, set, &patch), takes, "{patch}");
|
||||
}
|
||||
// `conforms` doesn't read `maxProperties`, so the limit is pinned here.
|
||||
assert_eq!((set["maxProperties"].as_u64(), set["properties"].as_object().map(|p| p.len())), (Some(15), Some(super::SETUP_FIELDS.len())), "{set}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_quality_and_studio_contracts_preserve_both_executors() {
|
||||
let plan_schema = output_schema_for::<QualityResult>();
|
||||
let composition_schema = output_schema_for::<StudioComposition>();
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = with_a_shot(level);
|
||||
for id in ["Q-001", "Q-002", "Q-003"] {
|
||||
let request = json!({"id":id,"variant":"cpu_progressive_png","acknowledge_omissions":true,
|
||||
"cpu":{"minimum_budget":"studio"}});
|
||||
let before = s.doc.clone();
|
||||
let reply = s.execute("render.quality.prepare", &request).unwrap();
|
||||
let schema = plan_schema.as_value();
|
||||
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{reply}");
|
||||
assert_eq!(reply["variant"], "cpu_progressive_png");
|
||||
assert_eq!(reply["cpu_context"]["minimum_budget"], "studio");
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
if cfg!(feature = "bundled-render-library") {
|
||||
s.execute("render.settings.set", &json!({"name":"Cpu","set":{"job":{"method":"cpu_raytrace"}}})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let reply = s.execute("render.preset.resolve", &json!({"id":"NATIVE-STUDIO-001","name":"Cpu","set":{"coats":[]}})).unwrap();
|
||||
let schema = composition_schema.as_value();
|
||||
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
|
||||
assert_eq!(reply["executor"], "cpu-raytrace");
|
||||
assert!(reply.get("param_warnings").is_none(), "{reply}");
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
}
|
||||
assert_eq!((set["maxProperties"].as_u64(), set["properties"].as_object().map(|p| p.len())), (Some(13), Some(super::SETUP_FIELDS.len())), "{set}");
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ plastic.rest
|
||||
plastic.snap_fit
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.camera.fit
|
||||
render.camera.get
|
||||
render.camera.set
|
||||
render.cancel
|
||||
render.effects.describe
|
||||
render.export
|
||||
@@ -99,11 +102,16 @@ render.library.environment.apply
|
||||
render.library.physical.prepare
|
||||
render.library.studio.apply
|
||||
render.library.studio.prepare
|
||||
render.library.surface.bind
|
||||
render.library.surface.prepare
|
||||
render.library.surface.unbind
|
||||
render.optics.bind
|
||||
render.optics.unbind
|
||||
render.preflight
|
||||
render.result.pin
|
||||
render.result.regrade
|
||||
render.scene
|
||||
render.settings.apply
|
||||
render.settings.get
|
||||
render.settings.set
|
||||
render.start
|
||||
@@ -277,8 +285,13 @@ timeline.roll_to
|
||||
timeline.rollback
|
||||
timeline.suppress
|
||||
timeline.ungroup
|
||||
view.ambient_occlusion
|
||||
view.apply
|
||||
view.delete
|
||||
view.graphics_preset
|
||||
view.history_layout
|
||||
view.layout
|
||||
view.list
|
||||
view.navigate
|
||||
view.rename
|
||||
view.save
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
//! Render shell requests. The UI applies validated echoes without design edits.
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::check::whole;
|
||||
use solidcore_record::{Vec2, Vec3};
|
||||
use solidcraft_geom::Vec3Geom;
|
||||
|
||||
use super::types::{CameraParam, present};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
@@ -38,14 +34,6 @@ pub struct RenderFocus {
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
/// Pause or resume the current document's live Render preview without saving a setup.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderLive {
|
||||
/// True pauses refinement; false resumes it using the current visual settings.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderBrowser {
|
||||
@@ -320,468 +308,6 @@ fn nullable<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D
|
||||
Option::<String>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// A key's value as its handler reads it: `read` takes the raw JSON value, and anything it
|
||||
/// refuses gets the handler's own message, whole (no key put in front).
|
||||
fn read_with<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option<T>) -> Result<Option<T>, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
read(&value).map(Some).ok_or_else(|| serde::de::Error::custom(whole(message)))
|
||||
}
|
||||
|
||||
/// A value `T` deserializes from, or the handler's message.
|
||||
fn read_as<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(d: D, message: &str) -> Result<Option<T>, D::Error> {
|
||||
read_with(d, message, |v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// A deserializer for a key, with the handler's message for a value it refuses.
|
||||
macro_rules! reads {
|
||||
($name:ident -> $ty:ty, $message:literal) => {
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
|
||||
read_as(d, $message)
|
||||
}
|
||||
};
|
||||
($name:ident -> $ty:ty, $message:literal, $read:expr) => {
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
|
||||
read_with(d, $message, $read)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A key the handlers refuse when it is left out, with their message: optional in the type, so
|
||||
/// the check leaves the refusal to the handler, and required in the schema.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
super::motion::require(schema, key);
|
||||
}
|
||||
|
||||
fn mode_required(schema: &mut Schema) {
|
||||
require(schema, "mode");
|
||||
}
|
||||
|
||||
fn preset_required(schema: &mut Schema) {
|
||||
require(schema, "preset");
|
||||
}
|
||||
|
||||
fn enabled_required(schema: &mut Schema) {
|
||||
require(schema, "enabled");
|
||||
}
|
||||
|
||||
fn camera_required(schema: &mut Schema) {
|
||||
require(schema, "camera");
|
||||
}
|
||||
|
||||
/// How the workspace is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LayoutMode {
|
||||
Floating,
|
||||
Compact,
|
||||
}
|
||||
|
||||
reads!(layout_mode -> LayoutMode, "mode must be floating or compact");
|
||||
|
||||
/// `view.layout`: the workspace layout, floating or compact. A display preference the app keeps;
|
||||
/// the design, the camera and the active tool are not changed, and it never opens a dialog.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = mode_required)]
|
||||
pub struct LayoutParams {
|
||||
// Optional in the type and required in the schema (`mode_required`): left out, the handler
|
||||
// refuses it with its own message.
|
||||
/// `floating` or `compact`.
|
||||
#[serde(default, deserialize_with = "layout_mode", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "LayoutMode")]
|
||||
pub mode: Option<LayoutMode>,
|
||||
}
|
||||
|
||||
/// What `view.layout` returns: the layout asked for.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct LayoutResult {
|
||||
pub mode: LayoutMode,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where the history sits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HistoryDock {
|
||||
Bottom,
|
||||
Left,
|
||||
UnderBrowser,
|
||||
/// The history in the browser, with no timeline.
|
||||
Browser,
|
||||
}
|
||||
|
||||
reads!(history_width -> f64, "width must be finite and within 1..1000000", |v| v.as_f64().filter(|w| w.is_finite() && (1.0..=1_000_000.0).contains(w)));
|
||||
reads!(history_dock -> HistoryDock, "dock must be bottom, left, under_browser or browser");
|
||||
reads!(history_collapsed -> bool, "collapsed must be a boolean");
|
||||
|
||||
/// `view.history_layout`: the history's width, dock and whether it is collapsed; each given
|
||||
/// key is checked and echoed, and a key left out stays as it is. A display preference the app
|
||||
/// keeps; the design is not changed and it never opens a dialog.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct HistoryLayoutParams {
|
||||
/// The history's width, 1 to 1000000.
|
||||
#[serde(default, deserialize_with = "history_width", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = 1.0, max = 1000000.0))]
|
||||
pub width: Option<f64>,
|
||||
/// Where the history sits.
|
||||
#[serde(default, deserialize_with = "history_dock", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "HistoryDock")]
|
||||
pub dock: Option<HistoryDock>,
|
||||
/// True collapses the history, false opens it.
|
||||
#[serde(default, deserialize_with = "history_collapsed", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub collapsed: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `view.history_layout` returns: the keys given, checked.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct HistoryLayoutResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dock: Option<HistoryDock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub collapsed: Option<bool>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A graphics preset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GraphicsPreset {
|
||||
Performance,
|
||||
Quality,
|
||||
}
|
||||
|
||||
/// The tessellation a graphics preset asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PresetQuality {
|
||||
Fine,
|
||||
Normal,
|
||||
}
|
||||
|
||||
reads!(graphics_preset_value -> GraphicsPreset, "preset must be performance or quality");
|
||||
|
||||
/// `view.graphics_preset`: one preset for ambient occlusion, tessellation and supersampling.
|
||||
/// Quality: ambient occlusion on, Fine, adaptive 2x supersampling. Performance: no ambient
|
||||
/// occlusion, Normal, no supersampling. MSAA stays independent. A display setting the app keeps;
|
||||
/// the design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = preset_required)]
|
||||
pub struct GraphicsPresetParams {
|
||||
// Optional in the type and required in the schema (`preset_required`), as `mode` above.
|
||||
/// `performance` or `quality`.
|
||||
#[serde(default, deserialize_with = "graphics_preset_value", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "GraphicsPreset")]
|
||||
pub preset: Option<GraphicsPreset>,
|
||||
}
|
||||
|
||||
/// What `view.graphics_preset` returns: the preset and the settings it stands for.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct GraphicsPresetResult {
|
||||
pub preset: GraphicsPreset,
|
||||
/// Ambient occlusion on or off.
|
||||
pub enabled: bool,
|
||||
pub display_quality: PresetQuality,
|
||||
/// The supersampling factor: 2 for Quality, 1 for Performance.
|
||||
pub supersampling: f64,
|
||||
pub supersampling_adaptive: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
reads!(ambient_occlusion_enabled -> bool, "enabled must be a boolean");
|
||||
|
||||
/// `view.ambient_occlusion`: soft contact shading in the GPU viewport, on or off. A display
|
||||
/// setting the app keeps; the design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = enabled_required)]
|
||||
pub struct AmbientOcclusionParams {
|
||||
// Optional in the type and required in the schema (`enabled_required`), as `mode` above.
|
||||
/// True turns it on, false off.
|
||||
#[serde(default, deserialize_with = "ambient_occlusion_enabled", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `view.ambient_occlusion` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AmbientOcclusionResult {
|
||||
/// Whether it is on now.
|
||||
pub enabled: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a navigation turns the camera by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NavigateAction {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
Clockwise,
|
||||
Counterclockwise,
|
||||
/// The isometric view, framed on the whole model.
|
||||
Home,
|
||||
/// Frame the whole model.
|
||||
Fit,
|
||||
}
|
||||
|
||||
/// A direct manipulation of the camera.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NavigateMotion {
|
||||
Pan,
|
||||
Orbit,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
/// A point of finite numbers, `[x, y]` or `[x, y, z]` (or an object with those keys), each
|
||||
/// within `lo..=hi`.
|
||||
fn bounded_point(keys: &[&str], lo: f64, hi: f64) -> Schema {
|
||||
let n = keys.len();
|
||||
// `params::finite` takes numbers below 1e9 in size; the others' bounds are inclusive.
|
||||
let number = if hi >= 1e9 {
|
||||
json!({"type": "number", "exclusiveMinimum": lo, "exclusiveMaximum": hi})
|
||||
} else {
|
||||
json!({"type": "number", "minimum": lo, "maximum": hi})
|
||||
};
|
||||
let properties: serde_json::Map<String, Value> = keys.iter().map(|k| ((*k).to_string(), number.clone())).collect();
|
||||
json_schema!({
|
||||
"anyOf": [
|
||||
{"type": "array", "items": number, "minItems": n, "maxItems": n},
|
||||
{"type": "object", "properties": properties, "required": keys}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn degrees_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "Degrees, above 0 and at most 180.", "type": "number", "exclusiveMinimum": 0, "maximum": 180})
|
||||
}
|
||||
|
||||
fn point3_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let mut s = bounded_point(&["x", "y", "z"], -1e9, 1e9);
|
||||
s.insert("description".into(), "A point [x, y, z] or {\"x\", \"y\", \"z\"}, of numbers.".into());
|
||||
s
|
||||
}
|
||||
|
||||
fn delta_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let mut s = bounded_point(&["x", "y"], -1e4, 1e4);
|
||||
s.insert("description".into(), "Pixels [dx, dy] (or {\"x\", \"y\"}), each within ±10000.".into());
|
||||
s
|
||||
}
|
||||
|
||||
fn viewport_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let mut s = bounded_point(&["x", "y"], 1.0, 1e6);
|
||||
s.insert("description".into(), "The viewport's size in pixels [width, height] (or {\"x\", \"y\"}), each 1 to 1000000.".into());
|
||||
s
|
||||
}
|
||||
|
||||
/// The camera `view.navigate` turns: as the other view commands read it, and also with yaw and
|
||||
/// roll within ±1e6 and pitch within ±π/2 (else "invalid camera"). Read before anything else.
|
||||
fn navigate_camera<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CameraParam>, D::Error> {
|
||||
read_with(d, "invalid camera", |v| {
|
||||
crate::cmd::camera_form(v)
|
||||
.filter(|c| c.yaw.abs() <= 1e6 && c.roll.abs() <= 1e6 && c.pitch.abs() <= std::f64::consts::FRAC_PI_2)
|
||||
.map(CameraParam)
|
||||
})
|
||||
}
|
||||
|
||||
reads!(navigate_fit -> bool, "fit must be a boolean");
|
||||
reads!(
|
||||
navigate_direction -> Vec3,
|
||||
"direction must be a finite nonzero vector of length at most 2",
|
||||
|v| crate::params::vec3(v).filter(|v| v.is_finite() && v.len() <= 2.0 && v.normalized().is_some())
|
||||
);
|
||||
reads!(navigate_action -> NavigateAction, "unknown navigation action");
|
||||
reads!(
|
||||
navigate_degrees -> f64,
|
||||
"degrees must be a number above 0 and at most 180",
|
||||
|v| v.as_f64().filter(|d| d.is_finite() && *d > 0.0 && *d <= 180.0)
|
||||
);
|
||||
reads!(navigate_motion -> NavigateMotion, "unknown navigation motion");
|
||||
reads!(navigate_free_orbit -> bool, "free_orbit must be a boolean");
|
||||
/// A pan or orbit's `delta` and an orbit's `viewport` are read in two steps, as the handler
|
||||
/// does: a point, then its bounds, each with its own message.
|
||||
fn bounded_point_key<'de, D: Deserializer<'de>>(d: D, key: &str, in_bounds: fn(Vec2) -> bool, out_of_bounds: &str) -> Result<Option<Vec2>, D::Error> {
|
||||
let point = read_with(d, &format!("`{key}` must be a point [x, y]"), crate::params::vec2)?;
|
||||
match point {
|
||||
Some(p) if !in_bounds(p) => Err(serde::de::Error::custom(whole(out_of_bounds))),
|
||||
point => Ok(point),
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_delta<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec2>, D::Error> {
|
||||
bounded_point_key(d, "delta", |p| p.x.abs() <= 1e4 && p.y.abs() <= 1e4, "motion deltas must be within ±10000 pixels")
|
||||
}
|
||||
|
||||
fn navigate_viewport<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec2>, D::Error> {
|
||||
bounded_point_key(d, "viewport", |p| (1.0..=1e6).contains(&p.x) && (1.0..=1e6).contains(&p.y), "viewport dimensions must be in 1..1000000")
|
||||
}
|
||||
reads!(navigate_pivot -> Vec3, "`pivot` must be a point [x, y, z]", crate::params::vec3);
|
||||
reads!(navigate_anchor -> Vec3, "`anchor` must be a point [x, y, z]", crate::params::vec3);
|
||||
reads!(
|
||||
navigate_factor -> f64,
|
||||
"factor must be in 0.05..20",
|
||||
|v| v.as_f64().filter(|f| f.is_finite() && f.abs() < 1e9 && (0.05..=20.0).contains(f))
|
||||
);
|
||||
|
||||
/// `view.navigate`: turn, pan, orbit or zoom a camera without changing the design or adding to
|
||||
/// undo. `camera` is always needed, and then one of three: `direction` (look from it), `action`
|
||||
/// (a turn, a roll, home or fit) or `motion` (direct manipulation, which wins when given and
|
||||
/// does not take `fit`). Keys of the other forms are not used.
|
||||
// Deserialize is written out below rather than derived: the handler reads only the keys of the
|
||||
// form it takes, so a key of another form is neither read nor checked.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = camera_required)]
|
||||
pub struct NavigateParams {
|
||||
// Optional in the type and required in the schema (`camera_required`), as `mode` above.
|
||||
/// The camera to move, as `ui.inspect` gives it. Its yaw and roll must also be within ±1e6
|
||||
/// and its pitch within ±π/2.
|
||||
#[serde(default, deserialize_with = "navigate_camera", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "CameraParam")]
|
||||
pub camera: Option<CameraParam>,
|
||||
/// Also frame the whole model from the new direction, as home does. Not with `motion`.
|
||||
/// Default: false.
|
||||
#[serde(default, deserialize_with = "navigate_fit", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub fit: Option<bool>,
|
||||
/// Look from this direction, in the camera's up-axis frame: a finite nonzero vector of
|
||||
/// length at most 2.
|
||||
#[serde(default, deserialize_with = "navigate_direction", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "point3_schema")]
|
||||
pub direction: Option<Vec3>,
|
||||
/// A turn without `direction`: left, right, up or down turn the view `degrees`, clockwise and
|
||||
/// counterclockwise roll it a quarter turn, home shows the isometric view framed, fit frames
|
||||
/// the model.
|
||||
#[serde(default, deserialize_with = "navigate_action", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "NavigateAction")]
|
||||
pub action: Option<NavigateAction>,
|
||||
/// How far left, right, up or down turn the view, above 0 and at most 180. Default: 90.
|
||||
#[serde(default, deserialize_with = "navigate_degrees", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "degrees_schema")]
|
||||
pub degrees: Option<f64>,
|
||||
/// Direct manipulation: pan and orbit need `delta`, pan and an orbit about a `pivot` need
|
||||
/// `viewport`, zoom needs `factor`.
|
||||
#[serde(default, deserialize_with = "navigate_motion", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "NavigateMotion")]
|
||||
pub motion: Option<NavigateMotion>,
|
||||
/// Pan or orbit by this many pixels.
|
||||
#[serde(default, deserialize_with = "navigate_delta", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "delta_schema")]
|
||||
pub delta: Option<Vec2>,
|
||||
/// Orbit through the poles. Default: false.
|
||||
#[serde(default, deserialize_with = "navigate_free_orbit", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub free_orbit: Option<bool>,
|
||||
/// The viewport's size in pixels, for a pan or an orbit about a pivot.
|
||||
#[serde(default, deserialize_with = "navigate_viewport", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "viewport_schema")]
|
||||
pub viewport: Option<Vec2>,
|
||||
/// Orbit about this point (mm) rather than the camera's target.
|
||||
#[serde(default, deserialize_with = "navigate_pivot", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "point3_schema")]
|
||||
pub pivot: Option<Vec3>,
|
||||
/// The zoom factor, 0.05 to 20.
|
||||
#[serde(default, deserialize_with = "navigate_factor", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = 0.05, max = 20.0))]
|
||||
pub factor: Option<f64>,
|
||||
/// Zoom towards this point (mm). Default: the camera's target.
|
||||
#[serde(default, deserialize_with = "navigate_anchor", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "point3_schema")]
|
||||
pub anchor: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NavigateParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let p = serde_json::Map::<String, Value>::deserialize(d)?;
|
||||
navigate_form(&p).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A key's value through its reader, or `None` when the key is left out.
|
||||
fn navigate_key<'a, T>(
|
||||
p: &'a serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
read: impl FnOnce(&'a Value) -> Result<Option<T>, serde_json::Error>,
|
||||
) -> Result<Option<T>, serde_json::Error> {
|
||||
p.get(key).map_or(Ok(None), read)
|
||||
}
|
||||
|
||||
/// The keys `view.navigate`'s handler reads, in its order: the camera and `fit`, then those of
|
||||
/// a motion (each motion its own), of a direction, or of an action.
|
||||
fn navigate_form(p: &serde_json::Map<String, Value>) -> Result<NavigateParams, serde_json::Error> {
|
||||
let mut form = NavigateParams {
|
||||
camera: navigate_key(p, "camera", navigate_camera)?,
|
||||
fit: navigate_key(p, "fit", navigate_fit)?,
|
||||
direction: None,
|
||||
action: None,
|
||||
degrees: None,
|
||||
motion: None,
|
||||
delta: None,
|
||||
free_orbit: None,
|
||||
viewport: None,
|
||||
pivot: None,
|
||||
factor: None,
|
||||
anchor: None,
|
||||
};
|
||||
if p.contains_key("motion") {
|
||||
form.free_orbit = navigate_key(p, "free_orbit", navigate_free_orbit)?;
|
||||
form.motion = navigate_key(p, "motion", navigate_motion)?;
|
||||
match form.motion {
|
||||
Some(NavigateMotion::Pan) => {
|
||||
form.delta = navigate_key(p, "delta", navigate_delta)?;
|
||||
form.viewport = navigate_key(p, "viewport", navigate_viewport)?;
|
||||
}
|
||||
Some(NavigateMotion::Orbit) => {
|
||||
form.delta = navigate_key(p, "delta", navigate_delta)?;
|
||||
// An orbit needs the viewport only about a pivot.
|
||||
if p.contains_key("pivot") {
|
||||
form.pivot = navigate_key(p, "pivot", navigate_pivot)?;
|
||||
form.viewport = navigate_key(p, "viewport", navigate_viewport)?;
|
||||
}
|
||||
}
|
||||
Some(NavigateMotion::Zoom) => {
|
||||
form.factor = navigate_key(p, "factor", navigate_factor)?;
|
||||
form.anchor = navigate_key(p, "anchor", navigate_anchor)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
} else if p.contains_key("direction") {
|
||||
form.direction = navigate_key(p, "direction", navigate_direction)?;
|
||||
} else {
|
||||
form.degrees = navigate_key(p, "degrees", navigate_degrees)?;
|
||||
form.action = navigate_key(p, "action", navigate_action)?;
|
||||
}
|
||||
Ok(form)
|
||||
}
|
||||
|
||||
/// What `view.navigate` returns: the camera after the move, and whether the window should also
|
||||
/// frame the whole model. The window applies both; the design is not changed.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NavigateResult {
|
||||
/// The camera now.
|
||||
#[schemars(with = "CameraParam")]
|
||||
pub camera: solidcraft_render::Camera,
|
||||
/// Frame the whole model from the new direction.
|
||||
pub fit: bool,
|
||||
/// True for a `motion`; absent for a turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub continuous: Option<bool>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view_navigation_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
AmbientOcclusionParams, AmbientOcclusionResult, AngleReadoutParams, CameraReply, GraphicsPresetParams, GraphicsPresetResult, HistoryLayoutParams,
|
||||
HistoryLayoutResult, LayoutParams, LayoutResult, NavigateParams, NavigateResult, NormalToParams, StandardViewParams, TabStep, TabStepParams,
|
||||
};
|
||||
use super::{AngleReadoutParams, CameraReply, NormalToParams, StandardViewParams, TabStep, TabStepParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::types::CameraParam;
|
||||
@@ -14,14 +11,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<AngleReadoutParams>(), &["enabled"]);
|
||||
assert_keys(input_schema_for::<StandardViewParams>(), &["camera"]);
|
||||
assert_keys(input_schema_for::<NormalToParams>(), &["camera"]);
|
||||
assert_keys(input_schema_for::<LayoutParams>(), &["mode"]);
|
||||
assert_keys(input_schema_for::<HistoryLayoutParams>(), &["collapsed", "dock", "width"]);
|
||||
assert_keys(input_schema_for::<GraphicsPresetParams>(), &["preset"]);
|
||||
assert_keys(input_schema_for::<AmbientOcclusionParams>(), &["enabled"]);
|
||||
assert_keys(
|
||||
input_schema_for::<NavigateParams>(),
|
||||
&["action", "anchor", "camera", "degrees", "delta", "direction", "factor", "fit", "free_orbit", "motion", "pivot", "viewport"],
|
||||
);
|
||||
}
|
||||
|
||||
const STANDARD_VIEWS: [&str; 7] = ["view.front", "view.back", "view.left", "view.right", "view.top", "view.bottom", "view.isometric"];
|
||||
@@ -314,333 +303,3 @@ fn navigator_checks_and_echoes_its_settings_and_refuses_a_missing_body() {
|
||||
let spec = crate::find_command("view.navigator").unwrap();
|
||||
assert!(!spec.undoable && spec.contract.is_some() && spec.output.is_some());
|
||||
}
|
||||
|
||||
const LAYOUT_COMMANDS: [&str; 5] = ["view.layout", "view.history_layout", "view.graphics_preset", "view.ambient_occlusion", "view.navigate"];
|
||||
|
||||
/// A camera for `view.navigate`.
|
||||
fn nav_camera() -> Value {
|
||||
camera(json!({"target": [0, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 100, "fov": 0.0}))
|
||||
}
|
||||
|
||||
/// A `view.navigate` call: the camera and `extra`'s keys.
|
||||
fn nav(extra: Value) -> Value {
|
||||
let mut p = json!({"camera": nav_camera()});
|
||||
if let (Some(o), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(extra.clone());
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Each command publishes its input and output schemas with a contract, is display only, and its
|
||||
/// required keys are the ones its handler refuses a call without.
|
||||
#[test]
|
||||
fn the_commands_publish_contracts_and_schemas() {
|
||||
let mut s = Session::default();
|
||||
for id in LAYOUT_COMMANDS {
|
||||
let spec = crate::find_command(id).unwrap();
|
||||
assert!(spec.contract.is_some() && spec.output.is_some() && !spec.undoable, "{id}");
|
||||
let d = s.execute("engine.describe", &json!({"command": id})).unwrap();
|
||||
assert_eq!(d["input"]["additionalProperties"], false, "{id}");
|
||||
assert_eq!(d["output"]["type"], "object", "{id}");
|
||||
}
|
||||
let mut required = |id: &str| {
|
||||
let d = s.execute("engine.describe", &json!({"command": id})).unwrap();
|
||||
d["input"]["required"].clone()
|
||||
};
|
||||
assert_eq!(required("view.layout"), json!(["mode"]));
|
||||
assert_eq!(required("view.graphics_preset"), json!(["preset"]));
|
||||
assert_eq!(required("view.ambient_occlusion"), json!(["enabled"]));
|
||||
assert_eq!(required("view.navigate"), json!(["camera"]));
|
||||
assert_eq!(required("view.history_layout"), Value::Null);
|
||||
let d = Session::default().execute("engine.describe", &json!({"command": "view.history_layout"})).unwrap();
|
||||
assert_eq!(d["input"]["properties"]["width"]["minimum"], 1.0);
|
||||
assert_eq!(d["input"]["properties"]["width"]["maximum"], 1000000.0);
|
||||
let d = Session::default().execute("engine.describe", &json!({"command": "view.navigate"})).unwrap();
|
||||
assert_eq!(d["input"]["properties"]["degrees"]["exclusiveMinimum"], 0.0);
|
||||
assert_eq!(d["input"]["properties"]["degrees"]["maximum"], 180.0);
|
||||
assert_eq!(d["input"]["properties"]["factor"]["minimum"], 0.05);
|
||||
assert_eq!(d["input"]["properties"]["pivot"]["anyOf"][0]["items"]["exclusiveMaximum"], 1e9);
|
||||
let d = Session::default().execute("engine.describe", &json!({"command": "view.history_layout"})).unwrap();
|
||||
let dock = serde_json::to_string(&d["input"]).unwrap();
|
||||
for name in ["bottom", "left", "under_browser", "browser"] {
|
||||
assert!(dock.contains(&format!("\"{name}\"")), "dock {name}: {dock}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The replies are the JSON the handlers returned before the types: exactly these values.
|
||||
#[test]
|
||||
fn replies_are_todays_json() {
|
||||
let mut s = Session::default();
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(run("view.layout", json!({"mode": "compact"})), json!({"mode": "compact"}));
|
||||
assert_eq!(run("view.layout", json!({"mode": "floating"})), json!({"mode": "floating"}));
|
||||
assert_eq!(run("view.history_layout", json!({})), json!({}));
|
||||
assert_eq!(
|
||||
run("view.history_layout", json!({"width": 320, "dock": "under_browser", "collapsed": false})),
|
||||
json!({"width": 320.0, "dock": "under_browser", "collapsed": false})
|
||||
);
|
||||
assert_eq!(run("view.history_layout", json!({"dock": "browser"})), json!({"dock": "browser"}));
|
||||
assert_eq!(
|
||||
run("view.graphics_preset", json!({"preset": "quality"})),
|
||||
json!({"preset": "quality", "enabled": true, "display_quality": "fine", "supersampling": 2.0, "supersampling_adaptive": true})
|
||||
);
|
||||
assert_eq!(
|
||||
run("view.graphics_preset", json!({"preset": "performance"})),
|
||||
json!({"preset": "performance", "enabled": false, "display_quality": "normal", "supersampling": 1.0, "supersampling_adaptive": true})
|
||||
);
|
||||
assert_eq!(run("view.ambient_occlusion", json!({"enabled": false})), json!({"enabled": false}));
|
||||
let cam: solidcraft_render::Camera = serde_json::from_value(nav_camera()).unwrap();
|
||||
let turned = run("view.navigate", json!({"camera": nav_camera(), "action": "fit"}));
|
||||
assert_eq!(turned, json!({"camera": cam, "fit": true}));
|
||||
let moved = run("view.navigate", json!({"camera": nav_camera(), "motion": "zoom", "factor": 2}));
|
||||
assert_eq!(moved["fit"], false);
|
||||
assert_eq!(moved["continuous"], true);
|
||||
assert_eq!(moved.as_object().map(|o| o.len()), Some(3));
|
||||
}
|
||||
|
||||
/// Real replies of each command, in Warn, match their output schema; a misspelt key's warning
|
||||
/// still matches.
|
||||
#[test]
|
||||
fn replies_match_the_output_schemas() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let calls: Vec<(&str, Value, schemars::Schema)> = vec![
|
||||
("view.layout", json!({"mode": "compact"}), output_schema_for::<LayoutResult>()),
|
||||
("view.layout", json!({"mode": "floating"}), output_schema_for::<LayoutResult>()),
|
||||
("view.history_layout", json!({}), output_schema_for::<HistoryLayoutResult>()),
|
||||
("view.history_layout", json!({"width": 250.5, "dock": "left", "collapsed": true}), output_schema_for::<HistoryLayoutResult>()),
|
||||
("view.graphics_preset", json!({"preset": "quality"}), output_schema_for::<GraphicsPresetResult>()),
|
||||
("view.graphics_preset", json!({"preset": "performance"}), output_schema_for::<GraphicsPresetResult>()),
|
||||
("view.ambient_occlusion", json!({"enabled": true}), output_schema_for::<AmbientOcclusionResult>()),
|
||||
("view.navigate", nav(json!({"direction": [1, 1, 1]})), output_schema_for::<NavigateResult>()),
|
||||
("view.navigate", nav(json!({"action": "left", "degrees": 15})), output_schema_for::<NavigateResult>()),
|
||||
("view.navigate", nav(json!({"action": "home"})), output_schema_for::<NavigateResult>()),
|
||||
("view.navigate", nav(json!({"action": "clockwise", "fit": true})), output_schema_for::<NavigateResult>()),
|
||||
("view.navigate", nav(json!({"motion": "pan", "delta": [10, -5], "viewport": [800, 600]})), output_schema_for::<NavigateResult>()),
|
||||
(
|
||||
"view.navigate",
|
||||
nav(json!({"motion": "orbit", "delta": [10, -5], "pivot": [1, 2, 3], "viewport": [800, 600]})),
|
||||
output_schema_for::<NavigateResult>(),
|
||||
),
|
||||
("view.navigate", nav(json!({"motion": "orbit", "delta": [10, -5], "free_orbit": true})), output_schema_for::<NavigateResult>()),
|
||||
("view.navigate", nav(json!({"motion": "zoom", "factor": 0.5, "anchor": [1, 2, 3]})), output_schema_for::<NavigateResult>()),
|
||||
];
|
||||
for (id, p, schema) in calls {
|
||||
let reply = run(id, p.clone());
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &reply), "{id} {p}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{id} {p}: {reply}");
|
||||
let again = run(id, {
|
||||
let mut q = p.clone();
|
||||
q["zoomm"] = json!(1);
|
||||
q
|
||||
});
|
||||
assert!(again["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{id}: {again}");
|
||||
assert!(conforms(schema, schema, &again), "{id}: {again} doesn't match {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict refuses with the handler's own message whatever the handler refuses.
|
||||
#[test]
|
||||
fn view_layout_commands_keep_todays_errors() {
|
||||
let mut cases: Vec<(&str, Value)> = vec![
|
||||
("view.layout", json!({})),
|
||||
("view.layout", json!({"mode": "grid"})),
|
||||
("view.layout", json!({"mode": 1})),
|
||||
("view.layout", json!({"mode": null})),
|
||||
("view.history_layout", json!({"width": 0})),
|
||||
("view.history_layout", json!({"width": 1000001})),
|
||||
("view.history_layout", json!({"width": "300"})),
|
||||
("view.history_layout", json!({"width": null})),
|
||||
("view.history_layout", json!({"dock": "top"})),
|
||||
("view.history_layout", json!({"dock": 3})),
|
||||
("view.history_layout", json!({"collapsed": "yes"})),
|
||||
("view.history_layout", json!({"collapsed": null})),
|
||||
("view.graphics_preset", json!({})),
|
||||
("view.graphics_preset", json!({"preset": "fast"})),
|
||||
("view.graphics_preset", json!({"preset": true})),
|
||||
("view.graphics_preset", json!({"preset": null})),
|
||||
("view.ambient_occlusion", json!({})),
|
||||
("view.ambient_occlusion", json!({"enabled": 1})),
|
||||
("view.ambient_occlusion", json!({"enabled": "true"})),
|
||||
("view.ambient_occlusion", json!({"enabled": null})),
|
||||
("view.navigate", json!({})),
|
||||
("view.navigate", json!({"camera": null, "action": "fit"})),
|
||||
("view.navigate", json!({"camera": 5, "action": "fit"})),
|
||||
("view.navigate", json!({"action": "fit"})),
|
||||
];
|
||||
for c in [
|
||||
camera(json!({"yaw": 2e6})),
|
||||
camera(json!({"roll": -2e6})),
|
||||
camera(json!({"pitch": 2.0})),
|
||||
camera(json!({"distance": 0})),
|
||||
camera(json!({"fov": 4})),
|
||||
] {
|
||||
cases.push(("view.navigate", json!({"camera": c, "action": "fit"})));
|
||||
}
|
||||
for extra in [
|
||||
json!({"action": "fit", "fit": 1}),
|
||||
json!({"action": "fit", "fit": null}),
|
||||
json!({"direction": [0, 0, 0]}),
|
||||
json!({"direction": [3, 0, 0]}),
|
||||
json!({"direction": "front"}),
|
||||
json!({"direction": null}),
|
||||
json!({"action": "spin"}),
|
||||
json!({"action": 4}),
|
||||
json!({"action": null}),
|
||||
json!({"action": "left", "degrees": 0}),
|
||||
json!({"action": "left", "degrees": 181}),
|
||||
json!({"action": "left", "degrees": "90"}),
|
||||
json!({"motion": "slide"}),
|
||||
json!({"motion": null}),
|
||||
json!({"motion": "zoom", "fit": true, "factor": 2}),
|
||||
json!({"motion": "zoom", "factor": 0.01}),
|
||||
json!({"motion": "zoom", "factor": 21}),
|
||||
json!({"motion": "zoom", "factor": 2, "anchor": [1, 2]}),
|
||||
json!({"motion": "zoom", "factor": 2, "anchor": null}),
|
||||
json!({"motion": "pan", "delta": [20000, 0], "viewport": [800, 600]}),
|
||||
json!({"motion": "pan", "delta": [1], "viewport": [800, 600]}),
|
||||
json!({"motion": "pan", "viewport": [800, 600]}),
|
||||
json!({"motion": "pan", "delta": [1, 1], "viewport": [0, 600]}),
|
||||
json!({"motion": "pan", "delta": [1, 1], "viewport": [800, 2000000]}),
|
||||
json!({"motion": "pan", "delta": [1, 1]}),
|
||||
json!({"motion": "orbit", "delta": [1, 1], "free_orbit": "yes"}),
|
||||
json!({"motion": "orbit", "delta": [1, 1], "pivot": [1, 2]}),
|
||||
json!({"motion": "orbit", "delta": [1, 1], "pivot": null}),
|
||||
] {
|
||||
cases.push(("view.navigate", nav(extra)));
|
||||
}
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// A value of a key is accepted by the type exactly when the handler accepts it (the other keys
|
||||
/// being fine): the schemas never say yes where today's reader says no, or the other way round.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_its_handler_does() {
|
||||
let accepts = |id: &str, p: &Value| Session::default().execute(id, p).is_ok();
|
||||
let check = |id: &str, base: Value, key: &str, values: Vec<Value>| {
|
||||
for v in values {
|
||||
let mut p = base.clone();
|
||||
p[key] = v.clone();
|
||||
let ok = accepts(id, &p);
|
||||
let typed = match id {
|
||||
"view.layout" => serde_json::from_value::<LayoutParams>(p.clone()).is_ok(),
|
||||
"view.history_layout" => serde_json::from_value::<HistoryLayoutParams>(p.clone()).is_ok(),
|
||||
"view.graphics_preset" => serde_json::from_value::<GraphicsPresetParams>(p.clone()).is_ok(),
|
||||
"view.ambient_occlusion" => serde_json::from_value::<AmbientOcclusionParams>(p.clone()).is_ok(),
|
||||
_ => serde_json::from_value::<NavigateParams>(p.clone()).is_ok(),
|
||||
};
|
||||
assert_eq!(typed, ok, "{id} {p}");
|
||||
}
|
||||
};
|
||||
let junk = || vec![json!(null), json!(true), json!(0), json!(1.5), json!("x"), json!([]), json!({})];
|
||||
let with = |mut v: Vec<Value>| {
|
||||
v.extend(junk());
|
||||
v
|
||||
};
|
||||
check("view.layout", json!({}), "mode", with(vec![json!("floating"), json!("compact"), json!("Compact")]));
|
||||
check("view.history_layout", json!({}), "width", with(vec![json!(1), json!(1e6), json!(0.5), json!(1000001), json!(-4), json!(300)]));
|
||||
check(
|
||||
"view.history_layout",
|
||||
json!({}),
|
||||
"dock",
|
||||
with(vec![json!("bottom"), json!("left"), json!("under_browser"), json!("browser"), json!("Left")]),
|
||||
);
|
||||
check("view.history_layout", json!({}), "collapsed", with(vec![json!(true), json!(false)]));
|
||||
check("view.graphics_preset", json!({}), "preset", with(vec![json!("quality"), json!("performance"), json!("Quality")]));
|
||||
check("view.ambient_occlusion", json!({}), "enabled", with(vec![json!(true), json!(false)]));
|
||||
let base = json!({"camera": nav_camera(), "action": "fit"});
|
||||
let all_actions: Vec<Value> =
|
||||
["left", "right", "up", "down", "clockwise", "counterclockwise", "home", "fit", "spin"].iter().map(|a| json!(a)).collect();
|
||||
check("view.navigate", json!({"camera": nav_camera()}), "action", with(all_actions));
|
||||
check("view.navigate", base.clone(), "fit", with(vec![json!(true), json!(false)]));
|
||||
check(
|
||||
"view.navigate",
|
||||
base.clone(),
|
||||
"direction",
|
||||
with(vec![
|
||||
json!([1, 0, 0]),
|
||||
json!([0, 0, 1]),
|
||||
json!([2, 0, 0]),
|
||||
json!([2.1, 0, 0]),
|
||||
json!([0, 0, 0]),
|
||||
json!({"x": 0, "y": 1, "z": 0}),
|
||||
json!([1, 2]),
|
||||
json!([1e10, 0, 0]),
|
||||
]),
|
||||
);
|
||||
check(
|
||||
"view.navigate",
|
||||
json!({"camera": nav_camera(), "action": "left"}),
|
||||
"degrees",
|
||||
with(vec![json!(1), json!(180), json!(90), json!(90.0), json!(0), json!(-1), json!(181), json!(0.001)]),
|
||||
);
|
||||
let all_keys = json!({"camera": nav_camera(), "delta": [1, 1], "viewport": [800, 600], "factor": 2});
|
||||
check("view.navigate", all_keys, "motion", with(vec![json!("zoom"), json!("pan"), json!("orbit")]));
|
||||
let pan = json!({"camera": nav_camera(), "motion": "pan", "viewport": [800, 600]});
|
||||
check(
|
||||
"view.navigate",
|
||||
pan.clone(),
|
||||
"delta",
|
||||
with(vec![json!([1, 2]), json!({"x": 1, "y": 2}), json!([10000, -10000]), json!([10001, 0]), json!([1, 2, 3]), json!([1e10, 0])]),
|
||||
);
|
||||
let pan = json!({"camera": nav_camera(), "motion": "pan", "delta": [1, 1]});
|
||||
check(
|
||||
"view.navigate",
|
||||
pan,
|
||||
"viewport",
|
||||
with(vec![json!([1, 1]), json!([1e6, 1e6]), json!([0.5, 5]), json!([5, 1000001]), json!({"x": 3, "y": 4}), json!([3])]),
|
||||
);
|
||||
let zoom = json!({"camera": nav_camera(), "motion": "zoom", "factor": 2});
|
||||
check("view.navigate", zoom.clone(), "anchor", with(vec![json!([1, 2, 3]), json!({"x": 1, "y": 2, "z": 3}), json!([1, 2]), json!([1e10, 0, 0])]));
|
||||
check(
|
||||
"view.navigate",
|
||||
json!({"camera": nav_camera(), "motion": "zoom"}),
|
||||
"factor",
|
||||
with(vec![json!(0.05), json!(20), json!(0.04), json!(20.5), json!(1)]),
|
||||
);
|
||||
let orbit = json!({"camera": nav_camera(), "motion": "orbit", "delta": [1, 1], "viewport": [800, 600]});
|
||||
check("view.navigate", orbit.clone(), "pivot", with(vec![json!([1, 2, 3]), json!([1, 2]), json!({"x": 1, "y": 2, "z": 3})]));
|
||||
check("view.navigate", orbit, "free_orbit", with(vec![json!(true), json!(false)]));
|
||||
let camera_values = vec![
|
||||
nav_camera(),
|
||||
camera(json!({"yaw": 1e6})),
|
||||
camera(json!({"yaw": 1.1e6})),
|
||||
camera(json!({"roll": 1.1e6})),
|
||||
camera(json!({"pitch": std::f64::consts::FRAC_PI_2})),
|
||||
camera(json!({"pitch": 1.58})),
|
||||
camera(json!({"pitch": -1.58})),
|
||||
camera(json!({"distance": 0})),
|
||||
camera(json!({"fov": 3.2})),
|
||||
camera(json!({"up": "y"})),
|
||||
camera(json!({"up": "w"})),
|
||||
];
|
||||
check("view.navigate", json!({"action": "fit"}), "camera", with(camera_values));
|
||||
}
|
||||
|
||||
/// A key of another form than the one the handler takes is not used, so the contract does not
|
||||
/// read it either: such a call replies as it does with the check off, in warn and in strict.
|
||||
#[test]
|
||||
fn navigate_reads_only_the_keys_of_its_form() {
|
||||
for p in [
|
||||
// A motion wins over an action and a direction.
|
||||
json!({"camera": nav_camera(), "motion": "zoom", "factor": 2, "action": "bogus", "direction": "up"}),
|
||||
// Each motion reads its own keys: a pan has no factor, a zoom no delta.
|
||||
json!({"camera": nav_camera(), "motion": "pan", "delta": [3, 4], "viewport": [800, 600], "factor": 99}),
|
||||
json!({"camera": nav_camera(), "motion": "zoom", "factor": 2, "delta": "far", "viewport": 0, "pivot": "here"}),
|
||||
// An orbit reads the viewport only about a pivot.
|
||||
json!({"camera": nav_camera(), "motion": "orbit", "delta": [3, 4], "viewport": 0}),
|
||||
// A direction has no degrees or action.
|
||||
json!({"camera": nav_camera(), "direction": [0, 0, 1], "degrees": 500, "action": "bogus"}),
|
||||
] {
|
||||
let off = Session::default().execute("view.navigate", &p).unwrap();
|
||||
for check in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let checked = Session { param_check: check, ..Session::default() }.execute("view.navigate", &p).unwrap();
|
||||
assert_eq!(checked, off, "{check:?} {p}");
|
||||
}
|
||||
}
|
||||
// The keys of the form taken are still checked.
|
||||
let p = json!({"camera": nav_camera(), "motion": "pan", "delta": [3, 4], "viewport": [0, 600]});
|
||||
let strict = Session { param_check: ParamCheck::Strict, ..Session::default() }.execute("view.navigate", &p);
|
||||
assert!(strict.unwrap_err().to_string().contains("viewport dimensions must be in 1..1000000"));
|
||||
}
|
||||
|
||||
@@ -359,108 +359,6 @@ fn an_inserted_assembly_exports_its_nested_parts_where_they_are() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file from the STEP corpus (`tests/step-conformance`).
|
||||
fn corpus(name: &str) -> String {
|
||||
format!("{}/../../tests/step-conformance/{name}.step", env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
/// With `folder: true`, a file that brings more than one item into the history (its Import
|
||||
/// feature and the rigid joints holding its parts: one cylinder placed twice, an assembly) gets a
|
||||
/// history folder of its own, `Group #N` with the first free number, holding its Import feature and those joints,
|
||||
/// made by the same command and undone with it; a file with one object gets none. Inserted
|
||||
/// between the features of a folder, the import's folder nests in it.
|
||||
#[test]
|
||||
fn a_multi_object_step_insert_is_one_history_folder() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0], "name": "Mine"}));
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": corpus("cylinder"), "folder": true}));
|
||||
assert!(r.get("group").is_none() && s.doc.timeline_groups.is_empty(), "one object: no folder: {r}");
|
||||
|
||||
let undo = s.undo.len();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": corpus("cylinder_twice"), "folder": true}));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
let feature = r["feature"].as_u64().unwrap();
|
||||
let [folder] = s.doc.timeline_groups.as_slice() else { panic!("one folder: {:?}", s.doc.timeline_groups) };
|
||||
assert_eq!(
|
||||
(json!(folder.id), folder.name.as_str(), folder.features.as_slice(), folder.parent),
|
||||
(r["group"].clone(), "Group #1", &[feature][..], None),
|
||||
"{r}"
|
||||
);
|
||||
let mut rigid: Vec<u64> = s.doc.assembly.joints.iter().map(|j| j.id).collect();
|
||||
rigid.sort_unstable();
|
||||
let mut held = folder.joints.clone();
|
||||
held.sort_unstable();
|
||||
assert!(!rigid.is_empty() && held == rigid, "the folder holds the import's joints: {held:?} of {rigid:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty() && s.doc.feature(feature).is_none(), "undo takes the folder with the import");
|
||||
|
||||
// An assembly, inserted with the marker inside a folder: its folder nests in that one.
|
||||
let dir = temp_dir("folder");
|
||||
let path = rig_file(&dir);
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0], "operation": "new_body", "name": "Later"}));
|
||||
let all: Vec<u64> = s.doc.features.iter().map(|f| f.id).collect();
|
||||
let outer = run(&mut s, "timeline.group", json!({"name": "Everything", "features": all}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Mine"}));
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path, "folder": true}));
|
||||
let folder = s.doc.timeline_groups.iter().find(|f| json!(f.id) == r["group"]).unwrap();
|
||||
assert_eq!((folder.name.as_str(), folder.parent), ("Group #1", Some(outer)), "{r}");
|
||||
assert_eq!(folder.features, [r["feature"].as_u64().unwrap()]);
|
||||
// The next one takes the next free number.
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path, "folder": true}));
|
||||
let folder = s.doc.timeline_groups.iter().find(|f| json!(f.id) == r["group"]).unwrap();
|
||||
assert_eq!(folder.name, "Group #2", "{r}");
|
||||
assert!(solidcraft_doc::non_contiguous_timeline_group(&s.doc.features, &s.doc.timeline_groups).is_none());
|
||||
// Opening a file as a design folds it the same way.
|
||||
let r = run(&mut s, "doc.open", json!({"path": corpus("cylinder_twice"), "folder": true}));
|
||||
assert_eq!(s.doc.timeline_groups.len(), 1, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without `folder`, as before the option, and with `folder: false` (the app's "Fold the History
|
||||
/// bar into groups" turned off), a multi-object import goes into no folder, inserted or opened as
|
||||
/// a design; a value that isn't true or false is refused.
|
||||
#[test]
|
||||
fn a_step_import_told_no_folder_makes_none() {
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": corpus("cylinder_twice"), "folder": false}));
|
||||
assert!(r.get("group").is_none() && s.doc.timeline_groups.is_empty(), "no folder: {r}");
|
||||
assert_eq!(s.doc.assembly.joints.len(), 2, "its rigid joints are there all the same");
|
||||
let r = run(&mut s, "doc.open", json!({"path": corpus("cylinder_twice"), "folder": false}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "opened as a design, no folder either: {r}");
|
||||
let e = s.execute("file.insert_step", &json!({"path": corpus("cylinder_twice"), "folder": "no"})).unwrap_err();
|
||||
assert!(e.to_string().contains("`folder` must be true"), "{e}");
|
||||
// Left out or null, as before the option: no folder either.
|
||||
let r = run(&mut s, "doc.open", json!({"path": corpus("cylinder_twice")}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "opened without `folder`, no folder: {r}");
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": corpus("cylinder_twice"), "folder": null}));
|
||||
assert!(r.get("group").is_none() && s.doc.timeline_groups.is_empty(), "inserted with `folder: null`, no folder: {r}");
|
||||
}
|
||||
|
||||
/// Grouping is refused, with a reason a menu can show, when the features aren't one run of the
|
||||
/// history or would split another folder; one or several neighbours group.
|
||||
#[test]
|
||||
fn group_refusal_says_why_a_selection_cannot_be_grouped() {
|
||||
let mut s = Session::default();
|
||||
for (i, name) in ["A", "B", "C", "D"].iter().enumerate() {
|
||||
let corner = [20.0 * i as f64, 0.0, 0.0];
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": corner, "operation": "new_body", "name": name}));
|
||||
}
|
||||
let id = |s: &Session, n: &str| s.doc.find_feature(n).unwrap().id;
|
||||
let (a, b, c, d) = (id(&s, "A"), id(&s, "B"), id(&s, "C"), id(&s, "D"));
|
||||
assert_eq!(s.group_refusal(&[b], None), None);
|
||||
assert_eq!(s.group_refusal(&[b, c], None), None);
|
||||
let apart = s.group_refusal(&[a, c], None).unwrap();
|
||||
assert!(apart.contains("next to each other"), "{apart}");
|
||||
assert!(s.execute("timeline.group", &json!({"features": [a, c]})).is_err(), "the command refuses it too");
|
||||
run(&mut s, "timeline.group", json!({"name": "Run", "features": [a, b, c]}));
|
||||
let split = s.group_refusal(&[b], None).unwrap();
|
||||
assert!(split.contains("Run"), "taking the middle out splits the folder: {split}");
|
||||
let run_id = s.doc.timeline_groups.first().unwrap().id;
|
||||
assert_eq!(s.group_refusal(&[b], Some(run_id)), None, "inside the folder it nests");
|
||||
assert_eq!(s.group_refusal(&[c, d], None), None, "a folder's last feature leaves it for the new folder");
|
||||
assert!(s.group_refusal(&[], None).is_some());
|
||||
}
|
||||
|
||||
/// The parts of one STEP import share their face names, and each keeps its body where the file
|
||||
/// has it, placed in its component by an offset. A joint on one part's hole takes that part's
|
||||
/// hole, not the other part's hole of that name, though the pick (in the part's own coordinates)
|
||||
|
||||
@@ -3,14 +3,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// The edges [`Mesh::tangent_chain`] looked at to extend a chain, on this thread: the work a
|
||||
/// scan of every edge for every edge made quadratic (#3745), counted for the test that checks
|
||||
/// it stays linear, whatever else the machine is doing (#9901).
|
||||
pub(crate) static CHAIN_CANDIDATES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Axis-aligned bounding box.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Aabb3 {
|
||||
@@ -557,8 +549,6 @@ impl Mesh {
|
||||
near.sort_unstable();
|
||||
near.dedup();
|
||||
for i in near {
|
||||
#[cfg(test)]
|
||||
CHAIN_CANDIDATES.with(|n| n.set(n.get() + 1));
|
||||
if in_chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+55
-10
@@ -222,30 +222,75 @@ fn helix(pieces: usize) -> Mesh {
|
||||
m
|
||||
}
|
||||
|
||||
/// The tangent chain of a helix of `pieces` pieces: every piece of it joins, and how many edges
|
||||
/// finding the chain looked at ([`crate::mesh::CHAIN_CANDIDATES`]).
|
||||
fn helix_chain(pieces: usize) -> usize {
|
||||
/// The tangent chain of a helix of `pieces` pieces: every piece of it joins, and how long finding
|
||||
/// the chain took. The mesh is built before the clock starts.
|
||||
fn helix_chain(pieces: usize) -> std::time::Duration {
|
||||
let m = helix(pieces);
|
||||
let before = crate::mesh::CHAIN_CANDIDATES.with(std::cell::Cell::get);
|
||||
let began = std::time::Instant::now();
|
||||
let chain = m.tangent_chain(pieces / 2, 2f64.to_radians());
|
||||
let looked = crate::mesh::CHAIN_CANDIDATES.with(std::cell::Cell::get) - before;
|
||||
let took = began.elapsed();
|
||||
let mut sorted = chain.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(sorted, (0..pieces).collect::<Vec<_>>(), "every piece of the helix is in the chain");
|
||||
looked
|
||||
took
|
||||
}
|
||||
|
||||
/// Whether a job four times the size took at most about twice linear: `pair` times the small job,
|
||||
/// then the big one. Linear is 4x, the quadratic that the bug cost 16x or more, so eight times the
|
||||
/// small time is the limit. The load of the other test binaries can rise between the two runs, so a
|
||||
/// pair that misses is measured again, up to three times. Load only ever slows a run, so each size
|
||||
/// keeps its fastest time and the ratio is taken of those: a retry cannot pass a regression on a
|
||||
/// small run that a spike happened to slow, once an earlier small run was fast.
|
||||
///
|
||||
/// `slack` is the room the noise of a busy machine gets, on top of the ratio.
|
||||
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration), slack: std::time::Duration) -> Result<(), String> {
|
||||
let limit = |small: f64| 8.0 * small + slack.as_secs_f64();
|
||||
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (s, b) = pair();
|
||||
small = small.min(s.as_secs_f64());
|
||||
big = big.min(b.as_secs_f64());
|
||||
runs.push(format!("{s:?} then {b:?}"));
|
||||
if big < limit(small) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{}; fastest {small:.3} s and {big:.3} s, limit {:.3} s", runs.join(", "), limit(small)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_linearly_keeps_each_sizes_fastest_run() {
|
||||
let pairs = |p: Vec<(u64, u64)>| {
|
||||
let mut p = p.into_iter().map(|(s, b)| (std::time::Duration::from_millis(s), std::time::Duration::from_millis(b)));
|
||||
move || p.next().unwrap_or((std::time::Duration::from_secs(1), std::time::Duration::from_secs(1)))
|
||||
};
|
||||
let slack = std::time::Duration::from_millis(0);
|
||||
// Linear: four times the work, four times the time.
|
||||
assert!(scales_linearly(pairs(vec![(5, 20)]), slack).is_ok());
|
||||
// A big run slowed by load, then a quiet pair.
|
||||
assert!(scales_linearly(pairs(vec![(5, 80), (5, 20)]), slack).is_ok());
|
||||
// A quadratic cost (16x) whose later small runs a spike slowed: the fast first one still counts.
|
||||
let r = scales_linearly(pairs(vec![(5, 80), (15, 80), (15, 80)]), slack);
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 0.005 s and 0.080 s, limit 0.040 s")), "{r:?}");
|
||||
}
|
||||
|
||||
/// #3745: the tangent chain of a long helix (a thread's crest, in many edge pieces) is found
|
||||
/// without scanning every edge for every edge of it: hovering a threaded part's edge looked for
|
||||
/// its rim this way, and took seconds inside the frame. Every piece joins, in edge order.
|
||||
///
|
||||
/// The edges looked at are counted, not the time taken, so a busy machine can't fail it (#9901).
|
||||
/// Four times the pieces: linear is four times the edges, the scan sixteen times; at most eight.
|
||||
/// A ratio and not a limit: a test must not depend on wall-clock timing (CONTRIBUTING.md), the
|
||||
/// machine is busy. Four times the pieces must not cost sixteen times the time.
|
||||
#[test]
|
||||
fn a_long_helical_tangent_chain_is_found_quickly() {
|
||||
/// The room the noise of a run on a busy machine gets, on top of the ratio: the chain of 750
|
||||
/// pieces takes a fraction of a second, so this is the noise of one run and not a change of
|
||||
/// scale.
|
||||
const SLACK: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
let pieces = 3_000usize;
|
||||
let (small, big) = (helix_chain(pieces / 4), helix_chain(pieces));
|
||||
assert!(small > 0 && big <= 8 * small, "{} pieces looked at {small} edges, {pieces} looked at {big}", pieces / 4);
|
||||
if let Err(missed) = scales_linearly(|| (helix_chain(pieces / 4), helix_chain(pieces)), SLACK) {
|
||||
panic!("{pieces} pieces took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
//! Original, headless CPU/GPU Glass evidence. This is not an application or image-quality acceptance test.
|
||||
//! Run with `--features solidcraft-render/raytrace-cpu`; output directory must not exist.
|
||||
use serde_json::json;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3Geom};
|
||||
use solidcraft_render::{
|
||||
Camera, ProductMesh, ProductScene,
|
||||
glass_model::{Dispersion, GlassModel, ThinFilm},
|
||||
job::{
|
||||
AreaLight, AreaLights, CausticOptions, CpuBudget, CpuHost, CpuPathOptions, CpuProgressiveOptions, DielectricBoundary, DielectricOptions,
|
||||
DirectionalLightOptions, DirectionalLights, ImageHost, ImageRequest, Integrator, JobId, JobOptions, OutputOptions, Phase,
|
||||
PunctualLightOptions, PunctualLights, RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, SpotConeOptions, StudioLighting,
|
||||
},
|
||||
surface::{OpaqueSurface, unit_direction},
|
||||
transmission::{Transmission, subsurface::Subsurface},
|
||||
};
|
||||
use std::{error::Error, fs, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
const CASES: &[&str] = &[
|
||||
"clear",
|
||||
"tinted",
|
||||
"hollow",
|
||||
"nested",
|
||||
"sheet",
|
||||
"rough",
|
||||
"dispersion",
|
||||
"film",
|
||||
"rough_sheet",
|
||||
"caustics",
|
||||
"rough_lights",
|
||||
"rough_sheet_lights",
|
||||
"rough_caustics",
|
||||
"scattering_caustics",
|
||||
];
|
||||
const ENVIRONMENT: [f64; 3] = [0.7, 0.8, 1.0];
|
||||
const LIGHT: AreaLight = AreaLight { center: [-22.0, -30.0, 28.0], u: [9.0, 0.0, 0.0], v: [0.0, 0.0, -13.0], radiance: [4.0, 4.0, 4.0] };
|
||||
|
||||
fn quad(mesh: &mut Mesh, vertices: [u32; 4]) {
|
||||
let [a, b, c, d] = vertices;
|
||||
mesh.triangles.extend([[a, b, c], [a, c, d]]);
|
||||
mesh.tri_face.extend([0, 0]);
|
||||
}
|
||||
|
||||
fn rectangle(points: [Vec3; 4]) -> Mesh {
|
||||
let mut mesh = Mesh { positions: points.to_vec(), ..Default::default() };
|
||||
quad(&mut mesh, [0, 1, 2, 3]);
|
||||
mesh
|
||||
}
|
||||
|
||||
fn box_mesh(half: Vec3) -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for z in [-half.z, half.z] {
|
||||
mesh.positions.extend([
|
||||
Vec3::new(-half.x, -half.y, z),
|
||||
Vec3::new(half.x, -half.y, z),
|
||||
Vec3::new(half.x, half.y, z),
|
||||
Vec3::new(-half.x, half.y, z),
|
||||
]);
|
||||
}
|
||||
quad(&mut mesh, [0, 3, 2, 1]);
|
||||
quad(&mut mesh, [4, 5, 6, 7]);
|
||||
for i in 0..4 {
|
||||
let next = (i + 1) % 4;
|
||||
quad(&mut mesh, [i, next, next + 4, i + 4]);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn cup_mesh() -> Mesh {
|
||||
// One connected, outward shell with an open air cavity and a solid bottom.
|
||||
// Shared coordinates are authored once; no welded or repaired seam is needed.
|
||||
let mut mesh = Mesh::default();
|
||||
for (half, z) in [(10.0, -10.0), (10.0, 10.0), (7.0, 10.0), (7.0, -7.0)] {
|
||||
mesh.positions.extend([Vec3::new(-half, -half, z), Vec3::new(half, -half, z), Vec3::new(half, half, z), Vec3::new(-half, half, z)]);
|
||||
}
|
||||
quad(&mut mesh, [0, 3, 2, 1]);
|
||||
quad(&mut mesh, [12, 13, 14, 15]);
|
||||
for i in 0..4 {
|
||||
let next = (i + 1) % 4;
|
||||
quad(&mut mesh, [i, next, next + 4, i + 4]);
|
||||
quad(&mut mesh, [i + 4, next + 4, next + 8, i + 8]);
|
||||
quad(&mut mesh, [next + 12, i + 12, i + 8, next + 8]);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn prism_mesh() -> Mesh {
|
||||
let mut mesh = Mesh {
|
||||
positions: vec![
|
||||
Vec3::new(-10.0, -8.0, -10.0),
|
||||
Vec3::new(10.0, -8.0, -10.0),
|
||||
Vec3::new(-10.0, 8.0, -10.0),
|
||||
Vec3::new(-10.0, -8.0, 10.0),
|
||||
Vec3::new(10.0, -8.0, 10.0),
|
||||
Vec3::new(-10.0, 8.0, 10.0),
|
||||
],
|
||||
triangles: vec![[0, 2, 1], [3, 4, 5]],
|
||||
tri_face: vec![0; 2],
|
||||
..Default::default()
|
||||
};
|
||||
for face in [[0, 1, 4, 3], [1, 2, 5, 4], [2, 0, 3, 5]] {
|
||||
quad(&mut mesh, face);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn sphere_mesh() -> Mesh {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
const SEGMENTS: u32 = 32;
|
||||
const RINGS: u32 = 15;
|
||||
let mut mesh = Mesh { positions: vec![Vec3::new(0.0, 0.0, -10.0)], ..Default::default() };
|
||||
for ring in 1..=RINGS {
|
||||
let latitude = -PI * 0.5 + PI * f64::from(ring) / f64::from(RINGS + 1);
|
||||
for sector in 0..SEGMENTS {
|
||||
let angle = TAU * f64::from(sector) / f64::from(SEGMENTS);
|
||||
mesh.positions.push(Vec3::new(10.0 * latitude.cos() * angle.cos(), 10.0 * latitude.cos() * angle.sin(), 10.0 * latitude.sin()));
|
||||
}
|
||||
}
|
||||
let top = 1 + RINGS * SEGMENTS;
|
||||
mesh.positions.push(Vec3::new(0.0, 0.0, 10.0));
|
||||
for sector in 0..SEGMENTS {
|
||||
let next = (sector + 1) % SEGMENTS;
|
||||
mesh.triangles.push([0, 1 + next, 1 + sector]);
|
||||
mesh.triangles.push([top, 1 + (RINGS - 1) * SEGMENTS + sector, 1 + (RINGS - 1) * SEGMENTS + next]);
|
||||
}
|
||||
for ring in 0..RINGS - 1 {
|
||||
for sector in 0..SEGMENTS {
|
||||
let next = (sector + 1) % SEGMENTS;
|
||||
let a = 1 + ring * SEGMENTS + sector;
|
||||
let b = 1 + ring * SEGMENTS + next;
|
||||
quad(&mut mesh, [a, b, b + SEGMENTS, a + SEGMENTS]);
|
||||
}
|
||||
}
|
||||
mesh.tri_face.resize(mesh.triangles.len(), 0);
|
||||
mesh
|
||||
}
|
||||
|
||||
fn flat_mesh(source: Mesh) -> Result<Mesh> {
|
||||
let count = source.triangles.len().checked_mul(3).ok_or("fixture vertex count overflow")?;
|
||||
let mut mesh = Mesh::default();
|
||||
mesh.positions.try_reserve_exact(count)?;
|
||||
mesh.normals.try_reserve_exact(count)?;
|
||||
mesh.triangles.try_reserve_exact(source.triangles.len())?;
|
||||
mesh.tri_face.try_reserve_exact(source.triangles.len())?;
|
||||
for (face, &[a, b, c]) in source.triangles.iter().enumerate() {
|
||||
let point = |index: u32| source.positions.get(index as usize).copied().ok_or("fixture vertex index is invalid");
|
||||
let [a, b, c] = [point(a)?, point(b)?, point(c)?];
|
||||
let normal = unit_direction((b - a).cross(c - a))?;
|
||||
let begin = u32::try_from(mesh.positions.len())?;
|
||||
mesh.positions.extend([a, b, c]);
|
||||
mesh.normals.extend([normal; 3]);
|
||||
mesh.triangles.push([begin, begin + 1, begin + 2]);
|
||||
mesh.tri_face.push(source.tri_face.get(face).copied().ok_or("fixture face ID is missing")?);
|
||||
}
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
fn body(name: &str, mesh: Mesh, color: Vec3, ior: f64, transmission: Option<Transmission>) -> Result<ProductMesh> {
|
||||
Ok(ProductMesh {
|
||||
name: name.into(),
|
||||
mesh: Arc::new(flat_mesh(mesh)?),
|
||||
color: [255, 255, 255],
|
||||
opacity: 1.0,
|
||||
surface: Some(OpaqueSurface::new(color, 0.0, 0.45, ior)?),
|
||||
transmission,
|
||||
faces: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scene(case: &str) -> Result<ProductScene> {
|
||||
let mut meshes = Vec::new();
|
||||
let sigma = if ["tinted", "hollow", "nested", "sheet"].contains(&case) { Vec3::new(0.065, 0.012, 0.003) } else { Vec3::ZERO };
|
||||
let sheet = ["sheet", "rough_sheet", "rough_sheet_lights"].contains(&case).then_some(3.0);
|
||||
let mut transmission = Transmission::new(sigma, sheet)?;
|
||||
let model = match case {
|
||||
"rough" | "rough_sheet" | "rough_lights" | "rough_sheet_lights" => Some(GlassModel { roughness: 0.35, ..Default::default() }),
|
||||
"rough_caustics" => Some(GlassModel { roughness: 0.2, ..Default::default() }),
|
||||
"dispersion" => Some(GlassModel { dispersion: Some(Dispersion { abbe_number: 15.0 }), ..Default::default() }),
|
||||
"film" => Some(GlassModel { thin_film: Some(ThinFilm { ior: 1.35, thickness_nm: 400.0 }), ..Default::default() }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = model {
|
||||
transmission = transmission.with_shadow_transmission(false).with_model(model)?;
|
||||
}
|
||||
if case == "scattering_caustics" {
|
||||
transmission = transmission.with_subsurface(Subsurface { scattering_per_mm: 0.06, anisotropy: 0.35 })?;
|
||||
}
|
||||
let optical_mesh = match case {
|
||||
"hollow" => cup_mesh(),
|
||||
"dispersion" => prism_mesh(),
|
||||
"caustics" | "rough_caustics" => sphere_mesh(),
|
||||
"sheet" | "rough_sheet" | "rough_sheet_lights" => {
|
||||
rectangle([Vec3::new(-10.0, -1.0, -10.0), Vec3::new(10.0, -1.0, -10.0), Vec3::new(10.0, -1.0, 10.0), Vec3::new(-10.0, -1.0, 10.0)])
|
||||
}
|
||||
_ => box_mesh(Vec3::new(10.0, 8.0, 10.0)),
|
||||
};
|
||||
meshes.push(body("Original Glass boundary", optical_mesh, Vec3::ZERO, 1.5, Some(transmission))?);
|
||||
if case == "nested" {
|
||||
meshes.push(body(
|
||||
"Original enclosed air bubble",
|
||||
box_mesh(Vec3::new(6.0, 4.0, 6.0)),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
Some(Transmission::new(Vec3::ZERO, None)?),
|
||||
)?);
|
||||
}
|
||||
for row in 0..6 {
|
||||
for column in 0..8 {
|
||||
let x = (f64::from(column) - 4.0) * 8.0;
|
||||
let z = (f64::from(row) - 3.0) * 8.0;
|
||||
let color = if (row + column) % 2 == 0 { Vec3::new(0.055, 0.08, 0.13) } else { Vec3::new(0.8, 0.65, 0.38) };
|
||||
let mesh =
|
||||
rectangle([Vec3::new(x, 18.0, z), Vec3::new(x + 8.0, 18.0, z), Vec3::new(x + 8.0, 18.0, z + 8.0), Vec3::new(x, 18.0, z + 8.0)]);
|
||||
meshes.push(body(&format!("Original checker {column} {row}"), mesh, color, 1.5, None)?);
|
||||
}
|
||||
}
|
||||
let floor = if ["caustics", "rough_caustics"].contains(&case) { -15.0 } else { -12.0 };
|
||||
meshes.push(body(
|
||||
"Original opaque floor",
|
||||
rectangle([Vec3::new(-45.0, -35.0, floor), Vec3::new(45.0, -35.0, floor), Vec3::new(45.0, 35.0, floor), Vec3::new(-45.0, 35.0, floor)]),
|
||||
Vec3::new(0.33, 0.37, 0.43),
|
||||
1.5,
|
||||
None,
|
||||
)?);
|
||||
let bounds = meshes.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh.bounds()));
|
||||
Ok(ProductScene { meshes, bounds })
|
||||
}
|
||||
|
||||
fn camera() -> Camera {
|
||||
Camera { target: Vec3::ZERO, yaw: -0.23, pitch: 0.25, distance: 82.0, fov: 0.65, ..Default::default() }
|
||||
}
|
||||
|
||||
fn caustics(case: &str) -> Option<CausticOptions> {
|
||||
["caustics", "rough_caustics", "scattering_caustics"].contains(&case).then_some(CausticOptions {
|
||||
photons: 65536,
|
||||
radius_mm: if case == "scattering_caustics" { 2.0 } else { 0.75 },
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn hard_only(case: &str) -> bool {
|
||||
["rough_lights", "rough_sheet_lights", "rough_caustics", "scattering_caustics"].contains(&case)
|
||||
}
|
||||
fn environment(case: &str) -> [f64; 3] {
|
||||
if hard_only(case) { [0.0; 3] } else { ENVIRONMENT }
|
||||
}
|
||||
fn area_lights(case: &str) -> Vec<AreaLight> {
|
||||
if hard_only(case) { Vec::new() } else { vec![LIGHT] }
|
||||
}
|
||||
fn directional(case: &str) -> Vec<DirectionalLightOptions> {
|
||||
if ["rough_lights", "rough_sheet_lights"].contains(&case) {
|
||||
vec![DirectionalLightOptions { direction: [0.15, -1.0, 0.25], color: [0.5, 0.7, 1.0], intensity: 3.0 }]
|
||||
} else if caustics(case).is_some() {
|
||||
vec![DirectionalLightOptions { direction: [0.2, -0.3, 1.0], color: [1.0; 3], intensity: 4.0 }]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
fn punctual(case: &str) -> Vec<PunctualLightOptions> {
|
||||
if !["rough_lights", "rough_sheet_lights"].contains(&case) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
PunctualLightOptions {
|
||||
position_mm: [-12.0, -30.0, 15.0],
|
||||
color: [1.0, 0.3, 0.1],
|
||||
intensity: 2.0,
|
||||
reference_distance_mm: 30.0,
|
||||
minimum_distance_mm: 0.1,
|
||||
cone: None,
|
||||
},
|
||||
PunctualLightOptions {
|
||||
position_mm: [20.0, -25.0, 18.0],
|
||||
color: [0.2, 1.0, 0.3],
|
||||
intensity: 2.0,
|
||||
reference_distance_mm: 30.0,
|
||||
minimum_distance_mm: 0.1,
|
||||
cone: Some(SpotConeOptions { emitted_axis: [-20.0, 25.0, -18.0], inner_half_angle_radians: 0.25, outer_half_angle_radians: 0.65 }),
|
||||
},
|
||||
]
|
||||
}
|
||||
fn request(scene: &ProductScene, cpu: bool, case: &str, samples: u32, width: u32) -> Result<ImageRequest> {
|
||||
let raytrace = if cpu {
|
||||
let mut dielectrics = Vec::new();
|
||||
for (body_index, body) in scene.meshes.iter().enumerate() {
|
||||
if let Some(optics) = body.transmission {
|
||||
let surface = body.surface.ok_or("Glass body lacks its optical IOR")?;
|
||||
let sigma = optics.extinction();
|
||||
dielectrics.push(DielectricOptions {
|
||||
model: optics.model(),
|
||||
body_index,
|
||||
body_name: body.name.clone(),
|
||||
boundary: if optics.thin_wall_thickness_mm().is_some() { DielectricBoundary::ThinSheet } else { DielectricBoundary::ClosedMesh },
|
||||
ior: surface.dielectric_ior(),
|
||||
absorption_per_mm: [sigma.x, sigma.y, sigma.z],
|
||||
thickness_mm: optics.thin_wall_thickness_mm().unwrap_or(0.0),
|
||||
shadow_transmission: optics.shadow_transmission(),
|
||||
subsurface: optics.subsurface(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let areas = area_lights(case);
|
||||
Some(RaytraceOptions {
|
||||
caustics: caustics(case),
|
||||
budget: CpuBudget::Studio,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 81 }),
|
||||
path: Some(CpuPathOptions { max_depth: 16 }),
|
||||
dielectrics,
|
||||
environment: Some(RaytraceEnvironmentOptions { color: environment(case), intensity: 1.0 }),
|
||||
lights: Some(directional(case)),
|
||||
punctual_lights: Some(punctual(case)),
|
||||
area_lights: Some(
|
||||
areas
|
||||
.iter()
|
||||
.map(|light| RectangleLightOptions {
|
||||
center_mm: light.center,
|
||||
edge_u_mm: light.u.map(|v| v * 2.0),
|
||||
edge_v_mm: light.v.map(|v| v * 2.0),
|
||||
color: [1.0; 3],
|
||||
intensity: 4.0,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
area_samples: (!areas.is_empty()).then_some(4),
|
||||
output: Some(Box::new(OutputOptions { linear_exr: true, ..Default::default() })),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut camera = camera();
|
||||
if ["caustics", "rough_caustics"].contains(&case) {
|
||||
camera.pitch = 0.65;
|
||||
camera.target.z = -4.0;
|
||||
}
|
||||
Ok(ImageRequest {
|
||||
camera,
|
||||
width,
|
||||
height: width * 3 / 4,
|
||||
background: [228, 235, 245],
|
||||
transparent_background: false,
|
||||
integrator: if cpu { Integrator::Raytrace } else { Integrator::Raster },
|
||||
raytrace,
|
||||
studio_weights: None,
|
||||
environment: None,
|
||||
textures: None,
|
||||
})
|
||||
}
|
||||
|
||||
struct Evidence<'a> {
|
||||
samples: u32,
|
||||
width: u32,
|
||||
out: &'a Path,
|
||||
}
|
||||
|
||||
fn render(host: &ImageHost, source: ProductScene, cpu: bool, case: &str, evidence: &Evidence<'_>, serial: u64) -> Result<()> {
|
||||
let Evidence { samples, width, out } = *evidence;
|
||||
let backend = if cpu { "cpu" } else { "gpu" };
|
||||
let request = request(&source, cpu, case, samples, width)?;
|
||||
let options = if cpu {
|
||||
JobOptions::default()
|
||||
} else {
|
||||
JobOptions {
|
||||
caustics: caustics(case),
|
||||
samples,
|
||||
seed: 81,
|
||||
max_depth: 16,
|
||||
nested_media: case == "nested",
|
||||
linear_exr: true,
|
||||
lighting: Some(StudioLighting {
|
||||
directional_lights: DirectionalLights::new(&directional(case))?,
|
||||
punctual_lights: PunctualLights::new(&punctual(case))?,
|
||||
environment: environment(case),
|
||||
area_lights: AreaLights::new(&area_lights(case))?,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let descriptor = host.descriptor_for_request(&request)?;
|
||||
let recorded_request = serde_json::to_value(&request)?;
|
||||
let recorded_options = serde_json::to_value(options)?;
|
||||
let geometry: Vec<_> = source
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|body| {
|
||||
json!({
|
||||
"name":body.name,"positions":body.mesh.positions,"normals":body.mesh.normals,
|
||||
"triangles":body.mesh.triangles,"tri_face":body.mesh.tri_face,"preview_color":body.color,"opacity":body.opacity,
|
||||
"surface":body.surface.map(|surface|json!({
|
||||
"base_color_linear":surface.base_color(),"metallic":surface.metallic(),"roughness":surface.roughness(),
|
||||
"ior":surface.dielectric_ior(),"clear_coat_present":surface.clear_coat().is_some()
|
||||
})),
|
||||
"transmission":body.transmission.map(|optics|json!({
|
||||
"absorption_per_mm":optics.extinction(),"thin_wall_thickness_mm":optics.thin_wall_thickness_mm(),
|
||||
"shadow_transmission":optics.shadow_transmission(),"subsurface":optics.subsurface(),"model":optics.model()
|
||||
}))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut source = source;
|
||||
if cpu {
|
||||
// CPU optics were lowered into RaytraceOptions above. Its capture scene
|
||||
// carries opaque geometry; scene-level Transmission belongs to GPU.
|
||||
for body in &mut source.meshes {
|
||||
body.transmission = None;
|
||||
for face in &mut body.faces {
|
||||
face.transmission = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let handle = host.submit_with_options(JobId { session: 20261104, serial, generation: 1 }, 1, source, request, options)?;
|
||||
let status = match handle.wait_timeout(Duration::from_secs(600)) {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
let _ = handle.cancel();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(format!("{backend} {case} failed: {:?}", status.error).into());
|
||||
}
|
||||
let frame = handle.frame()?.ok_or("completed render has no frame")?;
|
||||
let png = image::load_from_memory(frame.png())?.to_rgba8();
|
||||
if png.dimensions() != (width, width * 3 / 4) || !png.pixels().any(|pixel| pixel.0 != [228, 235, 245, 255]) {
|
||||
return Err("render did not produce the original Glass fixture".into());
|
||||
}
|
||||
let exr = frame.linear_exr().ok_or("completed render has no raw EXR master")?;
|
||||
fs::write(out.join(format!("{backend}-{case}.png")), frame.png())?;
|
||||
fs::write(out.join(format!("{backend}-{case}.exr")), exr)?;
|
||||
let receipt = json!({
|
||||
"status":"PASS", "case":case,"backend":backend,"executor":descriptor,"request":recorded_request,"options":recorded_options,
|
||||
"frame":frame.info,"linear_master":frame.linear_master_info(),"original_geometry":geometry,
|
||||
"qualification":"headless completed image and retained same-frame raw master only",
|
||||
"application_ui_tested":false,"saved_design_tested":false,"image_quality_acceptance":false,
|
||||
"pixel_identity_between_backends_expected":false,
|
||||
"sampling_difference":"CPU uses four samples per enabled area connection; GPU uses its native estimator. Camera and branch sample streams differ."
|
||||
});
|
||||
fs::write(out.join(format!("{backend}-{case}.json")), serde_json::to_vec_pretty(&receipt)?)?;
|
||||
println!("Completed {backend} {case}: {width}x{}, {samples} samples/pixel", width * 3 / 4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let out = args.next().ok_or("usage: glass_comparison NEW_OUTPUT_DIRECTORY [cpu|gpu|both] [SPP=32] [CASE=all] [WIDTH=256]")?;
|
||||
let backend = args.next().unwrap_or_else(|| "both".into());
|
||||
let samples = args.next().map_or(Ok(32), |v| v.parse::<u32>())?;
|
||||
let case = args.next().unwrap_or_else(|| "all".into());
|
||||
let width = args.next().map_or(Ok(256), |v| v.parse::<u32>())?;
|
||||
if args.next().is_some() || !["cpu", "gpu", "both"].contains(&backend.as_str()) || !(1..=64).contains(&samples) || !(64..=1024).contains(&width) {
|
||||
return Err("expected cpu|gpu|both, 1..64 SPP and width 64..1024".into());
|
||||
}
|
||||
if case != "all" && !CASES.contains(&case.as_str()) {
|
||||
return Err(format!("case must be one of {} or all", CASES.join("|")).into());
|
||||
}
|
||||
let out = Path::new(&out);
|
||||
fs::create_dir(out)?;
|
||||
let evidence = Evidence { samples, width, out };
|
||||
let cpu = (backend != "gpu").then(CpuHost::for_platform).transpose()?;
|
||||
let gpu = (backend != "cpu").then(solidcraft_gpu::path::host).transpose()?;
|
||||
for (index, selected) in CASES.iter().copied().enumerate().filter(|(_, selected)| case == "all" || case == *selected) {
|
||||
let serial = u64::try_from(index)? + 1;
|
||||
if let Some(host) = &cpu {
|
||||
render(host, scene(selected)?, true, selected, &evidence, serial)?;
|
||||
}
|
||||
if let Some(host) = &gpu {
|
||||
render(host, scene(selected)?, false, selected, &evidence, serial)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,51 +3,6 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::adaptive::SamplingReport;
|
||||
|
||||
fn internal(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::Internal, message)
|
||||
}
|
||||
|
||||
fn production_count(options: &JobOptions, bytes: Option<&[u8]>) -> Result<u32> {
|
||||
let adaptive = options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let bytes: [u8; 4] = bytes.and_then(|bytes| bytes.try_into().ok()).ok_or_else(|| internal("missing adaptive preview budget"))?;
|
||||
let value = f32::from_le_bytes(bytes);
|
||||
if !value.is_finite() || value.fract() != 0.0 || value < adaptive.min_samples as f32 || value > options.samples as f32 {
|
||||
return Err(internal("invalid adaptive preview production count"));
|
||||
}
|
||||
Ok(value as u32)
|
||||
}
|
||||
pub(super) fn preview_divisor(options: &JobOptions, completed: u32, budget: Option<&[u8]>) -> Result<f32> {
|
||||
let adaptive = options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let total = options.samples.checked_add(adaptive.pilot_samples).ok_or_else(|| internal("adaptive preview target overflow"))?;
|
||||
if completed == 0 || completed > total {
|
||||
return Err(internal("adaptive preview iteration exceeds its target"));
|
||||
}
|
||||
if completed <= adaptive.pilot_samples {
|
||||
return Ok(1.0);
|
||||
}
|
||||
let production = completed - adaptive.pilot_samples;
|
||||
let target = production_count(options, budget)?;
|
||||
// The shader divides a finished pixel exactly once, then leaves that mean alone.
|
||||
Ok(if production < target { production as f32 } else { 1.0 })
|
||||
}
|
||||
pub(super) fn preview_paths(task: &Task, budgets: Option<&[u8]>) -> Result<u64> {
|
||||
let options = task.options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let pixels = u64::from(task.request.width) * u64::from(task.request.height);
|
||||
if task.sample <= options.pilot_samples {
|
||||
return Ok(pixels * u64::from(task.sample));
|
||||
}
|
||||
let production = task.sample - options.pilot_samples;
|
||||
let mut paths = pixels * u64::from(options.pilot_samples);
|
||||
let bytes = budgets.ok_or_else(|| internal("missing adaptive preview budgets"))?;
|
||||
for (index, pixel) in bytes.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
task.cancel.check()?;
|
||||
}
|
||||
paths += u64::from(production.min(production_count(&task.options, pixel.get(..4))?)) * u64::from(task.options.transmission_samples);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub(super) fn report(task: &Task) -> Result<SamplingReport> {
|
||||
task.cancel.check()?;
|
||||
task.context.ensure_available()?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user