Compare commits

...

11 Commits

Author SHA1 Message Date
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck 728f753b80 Release 0.18.4 (#4362)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:30:18 -04:00
The Nomad f22a026c27 3D mouse: choose which motion drives each axis (#2782)
* 3D mouse calibration: find each axis from a guided recording (#1463)

A recording of the cap at rest and moved along each axis both ways gives which raw axis is which,
which way it runs, where it rests, how much it wanders at rest and how far it goes each way.
Hands aren't exact: on our SpaceMouse Pro, sliding the cap right moved Ry further than X. So each
raw axis is picked by the one-to-one assignment that best explains all six motions together, each
raw axis scored against its own range so a wider one doesn't win through cross-talk. Each
direction is scaled on its own, so a device that goes further one way still reaches full scale
both ways; a direction under a twelfth of the furthest axis counts as not having responded.

A small state machine walks the user through it and moves on by itself once the cap has been held
out for 0.4 s in one go and let go. "Held out" is the least the solver accepts, twice an axis's own
rest wander, so whatever the solver would accept can be recorded and a noisy axis doesn't hold up a
quiet one.

A calibration also carries a dead zone the user sets, a share of full deflection taken off the
bottom of the travel, with full deflection still reading full scale.

Serde support is behind an optional feature, so the crate still needs nothing by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse calibration in Preferences, saved per device (#1463)

Preferences > Navigation gets a 3D mouse calibration row: whether the device being read is
calibrated, Calibrate... and Forget calibration. The window asks for each motion in turn and
saves what it found under the device (hid, web, spacenavd or 3dxware, with vendor and product ids), so
switching devices or turning spacenavd on doesn't apply the wrong mapping. A calibration is saved
for the device it started with, and ends if another one is read instead.

navigate_frame puts the reading through the device's calibration before the driver mapping, so
the per-axis Invert and speed and everything after them are unchanged. While the window is open
the reading goes to it and the view stays put; it closes with Preferences. Restore Defaults on
the Navigation page keeps calibrations, since they describe the device.

The web app hands over the gamepad's readings as they are, so calibration sees where the cap
rests; navigate_frame now leaves a reading inside the dead zone alone after calibration, so a
cap resting just off zero still doesn't keep redrawing. Whether the cap is moving is asked of the
calibrated reading there, in the web page's idle waker and in both wheel filters. Those run before
the frame, so they keep the last frame's calibration with its device and use it only for that
device. On the desktop, an axis with nothing fresh (timed out, or not reported since the device
connected) reads as the device's calibrated rest, so a device resting off zero doesn't drift.

A saved calibration that can't be read or used (a hand edit) is dropped on its own when the
preferences load, with a log line.

The window shows the device's raw readings live and has a dead zone slider, a share of full
travel on top of the measured noise; it's saved with the calibration and adjustable in Preferences
afterwards.

New strings are in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: choose which motion drives each axis (#1463)

After calibrating, which motion of the cap should drive which view axis is partly a matter of
taste: rolling and twisting swapped, say, or Y and Z. Each axis row in Preferences > Navigation
now has a "driven by" choice next to Invert and speed. Picking a motion another axis uses swaps
the two, so each motion always drives exactly one axis, and Reset axes puts them back.

The choice applies after the device's calibration and before the driver mapping, so Invert and
speed stay with the view axis rather than moving with the motion. It's a preference, not part
of a device's calibration, and stays in force when recalibrating; the calibration window says
so while one is set. A saved assignment that doesn't drive each axis once loads as the default.

New strings are in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: axis choice review follow-ups (#1463)

- An axis's speed, like its Invert, stays with the view axis when a motion is reassigned; tested.
- Calibrating records the device's own reading, before the axis choice, so a saved swap isn't
  baked into a new calibration and then applied twice; tested through a whole calibration.
- A settings file without the axis choice, or with one of the wrong length, loads with the
  default and keeps every other setting; tested.
- Each axis row says "driven by" in sight before its choice, which otherwise only explained
  itself on hover (no help on a touchscreen or the web).
- "Reset axes" is now "Reset axis assignment", so it doesn't read as forgetting the
  calibration just below it; the "driven by" tip reads more clearly in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: name axis rows by what they do, and motions without a device in mind (#1463)

The axis rows and the "driven by" choices showed the same names, "Push left / right" and so
on, which read as one thing mapped to itself, and they described a cap that springs back to
centre, which a trackball or a knob that stays put isn't.

Each row is now named for what it does to the view: Pan left / right, Zoom, Pan up / down,
Orbit up / down, Orbit left / right or Roll, or Off. The name comes from the driver mapping
navigation used last frame, so it stays right where the mapping swaps axes (object mode swaps
the last two). Before navigation has run there's no mapping to read, and a row is named for
its own motion instead of a guessed effect. The choices are the device's calibrated motions:
Translation X / Y / Z and Rotation X / Y / Z, under a header row, and the calibration window
names a motion that barely moved the same way. This renames #1814's existing rows, which had
no translations; every new name is in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: rows never fall back to a motion name (#1463)

Without a driver mapping, a row was named for its own calibrated motion,
which is also what its dropdown lists. It now takes the default mapping's
view axis, so the two sides never share a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: grey out Invert and speed on rows the driver turns off (#1463)

A row the driver profile turns off says so on hover, and its Invert and
speed are greyed out, since they do nothing there. Its motion choice stays
live so a motion can still be swapped out. The column header is shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: unmapped rows say Axis 1-6, and the off-row tip explains the swap (#1463)

Without a driver mapping, a row is now just "Axis n" rather than the
default mapping's view axis, which would be a guess there. The tip on a row
the driver turns off says that a motion swapped into it stops moving the
view, and a test swaps one in and back out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse calibration: a device that stops short of rest after a release (#2778)

Through spacenavd, a relative-axis device like the Space Navigator never
reports its return to zero, so the reading stays at its last small report
after every release. The wizard never saw the cap let go, and once
calibrated, the stuck value crept the view.

A reading that stays exactly the same, close to rest, now counts as let go,
and where the cap settled after each step goes into the calibration: such a
device rests at the middle of where it settled, with a dead zone out to the
furthest of them. A device that comes back to rest calibrates as before.

* 3D mouse calibration: a steady hold through spacenavd stays a hold (#2778)

* 3D mouse calibration: stopping short of rest only counts from spacenavd and 3DxWare (#2778)

Recentring on where the cap settled kept the rest stage's wander radius but
not its offset, so a rest extreme could fall outside the dead zone; the
radius now grows by how far the centre moved. A reading that stops changing
close to rest only ends a step from a source that sends nothing at rest, so
HID and web devices calibrate exactly as before.

* 3D mouse calibration: settling off-centre counts on any device (#2778)

A drifting stick settles off its rest after a release just as spacenavd's
stuck reading does, but it keeps reporting, noisily. A step now ends once
the reading stays steady, each raw axis within twice its rest wander (so
exactly still from a silent source), close to rest, on any device. The
dead zone takes that wander on top of the furthest settle point.

* 3D mouse calibration: steadiness by the run's spread, settled on the step's own axis (#2778)

An axis that read flat at rest may dither once off-centre, so steady
readings may spread at least 2 units and a 50th of the furthest any step
went. Steadiness is the spread of the whole run, not distance from its
first reading. Close to rest is judged on the axis that set the step's
peak, since a device's axes may have different ranges, and the settle point
is the middle of the steady run.

* 3D mouse calibration: readings close to rest never count as held (#2778)

* 3D mouse calibration: the dead zone reaches every reading of each release (#2778)

Saving one reading of each release and padding it by the rest wander could
miss a reading seen in the same steady run. Each release now keeps the whole
run, and the dead zone is centred on and reaches everything read at rest
and after the releases.

* 3D mouse calibration: settle for half a second before a step counts as let go (#2778)

A hand held still close to rest is less likely to end a step early; a
device that comes back to rest never waits for it.

* 3D mouse calibration: show the dead zones as a game controller tester would (#2778)

Once calibrated, the window, and Preferences under the Dead zone slider,
show three pads: slide, press and twist, and tilt. Each has a circle for
the full travel, the dead zone shaded inside it with each direction its
own size and the user's dead zone on top, and a dot where the cap is now,
lit once the view moves.

* 3D mouse calibration: settle through cross-talk, per-axis steadiness floor (#2778)

The axis that went furthest in a step needn't be the one asked for:
cross-talk can move another raw axis further. A step now counts as let go
once the furthest axis is at least halfway back and some axis that moved
about as far is within a quarter of its own peak; a hand still holding the
push keeps the furthest axis out. The steadiness floor follows each axis's
own reach, and each stage's steadiness starts with its own readings.

* 3D mouse calibration: steady readings may dither ±2 on any axis (#2778)

* space-mouse: make the settle check's near band match the steadiness spread, test the 4-unit floor

near() measured distance from the settled run's middle against the whole tolerance,
so it accepted twice the band the steadiness check does. Use half. Add a short-range
test where only the .max(4.0) floor lets a +-2 dither settle, and one where dither
the spread check rejects is also not skipped as untouched.

* space-mouse: settle on the middle of the steady run so a lopsided dither isnt movement

near() allows half the steadiness spread around the settled point, but the point was the run mean. A dither that is mostly one value with an occasional far one (10, 10, 10, 14) is steady, yet the far reading sat 3 from the mean and started a hold in the next step. Use the middle of the run range instead.

Adds a lopsided-dither test and a test pinning the documented cross-talk limit ([100,300] then [5,90] stays open).

* Judge an untouched cap by the settled run's spread, not a rounded midpoint

Rounding the midpoint to i16 could leave one end of an odd-width run more
than half the tolerance away, so the cap read as touched while sitting at
the end of the run it settled on. Keep the run itself and ask whether one
more reading keeps its spread within tolerance.

* space-mouse: pin the wider skip band of a narrow settled run and a single-reading settle

* space-mouse: keep a step held while a lesser axis stays out as the larger one relaxes

Settling needed one axis furthest and returned, and one axis about as far and settled, which a single relaxed cross-talk axis satisfied alone. Every axis that moved at least half as far as the furthest now has to be back by RETURNED_SHARE of its own peak too.

* 3D mouse: the calibration tests run in a focused window, as the focus check now requires

* 3D mouse: the axis tests run in a focused window, as the focus check now requires

* 3D mouse: the calibrated-rest test runs in a focused window, as the focus check now requires

* Settle a spacenavd reading that is nearly at rest (#3523)

spacenavd sends a record only when the values change and nothing when the cap springs back, so the last small leftover of a release was held forever and the view kept drifting.

A held reading now reads as rest once every axis is within 150 raw units of the device rest and no record has arrived for 0.5 s. A firmer push stays as long as it lasts, and so does a small value next to a large one. 150 is the smallest round number above the worst leftover in the issue (129 on one axis).

* Choose which 3D mouse driver to read on Linux

When both spacenavd and the device itself (hidraw) are there, Preferences now has a "3D mouse driver" choice: Automatic, spacenavd or Direct. Automatic is what it always was, spacenavd when it runs and the device otherwise, so nobody's setup changes. If the chosen one isn't available the other is read and the log says so once. Each driver keeps its own calibration, as before.

To switch without a restart both reads now wake up when idle and look at the choice: spacenavd with a 250 ms read timeout, hidraw opened non-blocking and polled every 5 ms when it has nothing. A record that arrives is read as before, and one split across a timeout is kept. The reader also publishes which of the two exist, which is what shows the combo box; with only one it says which one is in use.

* Build the 3D mouse row header from existing labels

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 11:29:33 -04:00
bherbruck 4b37c046e4 Sketch tools come back in the variant used last (#4308)
* Sketch tools come back in the variant used last, and it is kept in preferences

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Scenario: a new design keeps the remembered sketch variant

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:22:04 -04:00
bherbruck 54072b7014 Start page: sample pictures and the existence check of recent files run off the window thread (#4349)
A sample picked before its picture arrives still opens; the language test waits for the file check.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:20:12 -04:00
bherbruck cb3cabeda1 Remove camera animation: every view change and orbit applies at once (#4296)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:19:40 -04:00
bherbruck 775e5155bb Main green: revert the start page change (#4295) and fix the format 28 test (#4335)
* Revert "Start page: sample pictures and the existence check of recent files run off the window thread (#4295)"

This reverts commit d9059d8da5.

* Test: an older design is marked format 28 now that 0.18.3 folded face pieces into 29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:52:08 -04:00
bherbruck e570c0b4eb Windows: a restored window that is out of reach is fitted to the monitor's work area (#4299)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:36:36 -04:00
bherbruck d9059d8da5 Start page: sample pictures and the existence check of recent files run off the window thread (#4295)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:36:27 -04:00
bherbruck 463cea0615 Windows: ask for the foreground once, when the first frame is on screen (#4284)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:17:10 -04:00
bherbruck 0225e4d2a5 Startup: the first frame no longer waits for the system CJK/Hangul/Thai font search; SOLIDCRAFT_PROFILE_STARTUP prints startup steps (#4283)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:17:02 -04:00
68 changed files with 1790 additions and 732 deletions
Generated
+17 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.3"
version = "0.18.4"
[[package]]
name = "solidcraft-cli"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"image",
"log",
@@ -4209,7 +4209,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"proc-macro2",
"quote",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"log",
"serde",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"log",
"schemars",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"serde",
"serde_json",
@@ -4263,7 +4263,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"log",
"naga",
@@ -4280,7 +4280,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"log",
"proptest",
@@ -4323,7 +4323,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4332,7 +4332,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"image",
"moxcms",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"serde",
"serde_json",
@@ -4357,14 +4357,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"arboard",
"egui",
@@ -4391,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"eframe",
"egui",
@@ -5921,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.3"
version = "0.18.4"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.18.3"
version = "0.18.4"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1
View File
@@ -52,6 +52,7 @@ raw-window-handle = "0.6"
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
+62 -1
View File
@@ -28,16 +28,54 @@ mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
mod space_mouse;
mod startup;
#[cfg(any(target_os = "windows", test))]
mod window_fit;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
/// Windows: put the restored window back inside the monitor's work area if it is out of reach
/// (a gone monitor, another scale, a taskbar edge). Sizes are in points, the work area in pixels.
#[cfg(target_os = "windows")]
fn fit_window_to_work_area(ctx: &egui::Context, frame: &eframe::Frame) {
use window_fit::Area;
let Some((x, y, w, h)) = native_frame::work_area(frame) else { return };
let (outer, inner, scale, maximized) = ctx.input(|i| {
let v = i.viewport();
(v.outer_rect, v.inner_rect, v.native_pixels_per_point, v.maximized.unwrap_or(false) || v.fullscreen.unwrap_or(false))
});
let (Some(outer), Some(inner), Some(scale)) = (outer, inner, scale.filter(|s| s.is_finite() && *s > 0.0)) else { return };
if maximized {
return;
}
let work = Area::new(x as f32 / scale, y as f32 / scale, w as f32 / scale, h as f32 / scale);
let now = Area::new(outer.min.x, outer.min.y, outer.width(), outer.height());
let fitted = window_fit::fit(now, work, (960.0, 600.0));
if fitted != now {
// The frame (title bar, borders) keeps its size; only the inner size follows.
let inner_size = egui::vec2((inner.width() - (now.w - fitted.w)).max(1.0), (inner.height() - (now.h - fitted.h)).max(1.0));
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner_size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(fitted.x, fitted.y)));
}
}
/// `SOLIDCRAFT_PROFILE_STARTUP=1` prints the time since launch at each startup step.
fn mark(step: &str) {
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
if *on {
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
}
}
struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
/// Started over the control channel: the window doesn't ask for the foreground.
take_focus_unwanted: bool,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
}
@@ -46,9 +84,23 @@ impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.frames < 2 {
self.frames += 1;
// Before the window is shown: its title bar must be within reach (window_fit.rs).
#[cfg(target_os = "windows")]
if self.frames == 1 {
fit_window_to_work_area(ctx, _frame);
}
mark(if self.frames == 1 { "first frame begins" } else { "second frame begins (first presented)" });
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
// The window is shown only once its first frame is presented, and Windows then
// keeps a window of a process started from a shortcut or an installer behind the
// others. Ask for the foreground once, now that it is on screen; never again, so
// later frames don't take the focus from whatever the user moved to. Automation
// over the control channel keeps the focus where it is.
if !self.take_focus_unwanted {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
if let Some(dir) = self.starting.take() {
startup::started(&dir);
@@ -477,6 +529,7 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
mark("main");
adopt_legacy_settings();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
@@ -587,13 +640,17 @@ fn main() -> eframe::Result {
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
mark("run_native");
let result = eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_engine::crash::phase("app setup");
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
mark("window and device ready");
solidcraft_ui_egui::fonts::setup_bundled_fonts(&cc.egui_ctx);
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
mark("fonts");
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
@@ -611,6 +668,7 @@ fn main() -> eframe::Result {
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
mark("app state and prefs");
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
@@ -628,6 +686,7 @@ fn main() -> eframe::Result {
info.backend, info.name, info.driver, rs.msaa_samples
));
}
mark("viewport pipelines");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -680,11 +739,13 @@ fn main() -> eframe::Result {
}
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
mark("app created");
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
take_focus_unwanted: control_port.is_some(),
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
}))
+21
View File
@@ -26,6 +26,7 @@ use std::sync::atomic::{AtomicIsize, Ordering};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use solidcraft_ui_egui::titlebar;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
@@ -33,6 +34,26 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
};
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
/// pixels: left, top, width, height.
pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)> {
let handle = window.window_handle().ok()?;
let RawWindowHandle::Win32(w) = handle.as_raw() else { return None };
let hwnd = w.hwnd.get() as HWND;
// SAFETY: `hwnd` is the live main window; `info` is a MONITORINFO with its size set, as
// GetMonitorInfoW requires, and the monitor handle comes from the call before it.
let rc = unsafe {
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
let mut info: MONITORINFO = std::mem::zeroed();
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
if monitor.is_null() || GetMonitorInfoW(monitor, &mut info) == 0 {
return None;
}
info.rcWork
};
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
}
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
+322 -34
View File
@@ -8,6 +8,8 @@ mod hidraw;
#[cfg(target_os = "linux")]
mod spacenavd;
#[cfg(target_os = "linux")]
use std::sync::atomic::AtomicU8;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -19,9 +21,22 @@ use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_space_mouse::Layout;
use solidcraft_ui_egui::SolveApp;
#[cfg(target_os = "linux")]
use solidcraft_ui_egui::prefs::SpaceMouseDriver;
#[cfg(target_os = "linux")]
use solidcraft_ui_egui::space_mouse::DriversPresent;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150);
/// How long a held (spacenavd) reading that is nearly at rest may go without a new record before
/// it reads as rest: spacenavd sends no final record when the cap springs back, only the last
/// small leftover. Longer than `STALE_AFTER` so a light, steady hold gets a moment before it ends.
const HELD_SETTLE_AFTER: Duration = Duration::from_millis(500);
/// How far from rest (raw units, full deflection is 350) every axis of a held reading must be for
/// it to settle. The dead zone is 20, but the last records of real releases reach 129 on one axis
/// (tangency-labs/solidcraft#3523), so this sits well above it. A firmer push is a deliberate
/// hold and stays.
const HELD_SETTLE_BAND: u16 = 150;
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
/// the HID device would fail or report it twice.
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
@@ -199,9 +214,11 @@ struct Reading {
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
/// translation and rotation in separate reports, or spread even one group over several.
at: [Instant; 6],
/// The source says when the cap is back at rest, so a reading stays until the next one.
/// A HID device repeats its report while the cap is held; spacenavd sends a record only when
/// the values change, and a steady hold can go seconds without one.
/// The source sends a record only when the values change, so a reading stays until the next
/// one: a steady hold can go seconds without one. It never says when the cap springs back,
/// though, so a reading within `HELD_SETTLE_BAND` of rest on every axis settles after
/// `HELD_SETTLE_AFTER`. A HID device repeats its report while the cap is held, so it ages out
/// per axis instead.
held: bool,
}
@@ -214,7 +231,14 @@ impl Reading {
/// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting.
fn current(self, now: Instant, rest: Axes) -> Axes {
let mut axes = self.axes;
if !self.held {
if self.held {
let values = axes.translation.into_iter().chain(axes.rotation);
let rests = rest.translation.into_iter().chain(rest.rotation);
let near_rest = values.zip(rests).all(|(value, rest)| value.abs_diff(rest) <= HELD_SETTLE_BAND);
if near_rest && now.duration_since(self.at[0]) >= HELD_SETTLE_AFTER {
return rest;
}
} else {
let rest = rest.translation.into_iter().chain(rest.rotation);
for ((value, at), rest) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at).zip(rest) {
if now.duration_since(at) >= STALE_AFTER {
@@ -359,39 +383,130 @@ fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
}
}
/// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted
/// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it reads
/// `/dev/hidrawN` with plain blocking reads, which end with an error when the device is unplugged.
/// Opening needs permission: most distributions grant it to the logged-in user for devices that
/// carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
/// What the user chose for the reader and what the reader found, shared with the egui thread.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
use std::io::Read;
#[derive(Default)]
struct Drivers {
/// `SpaceMouseDriver::to_u8` of the preference.
choice: AtomicU8,
/// `PRESENT_SPACENAVD` and `PRESENT_DIRECT`, as of the reader's last look.
found: AtomicU8,
}
#[cfg(target_os = "linux")]
const PRESENT_SPACENAVD: u8 = 1;
#[cfg(target_os = "linux")]
const PRESENT_DIRECT: u8 = 2;
/// How often a connected reader looks again at what is there, and how long a read of spacenavd
/// waits for a record before it checks the choice.
#[cfg(target_os = "linux")]
const LOOK_EVERY: Duration = Duration::from_secs(1);
#[cfg(target_os = "linux")]
const SPACENAVD_WAKE: Duration = Duration::from_millis(250);
#[cfg(target_os = "linux")]
impl Drivers {
fn choice(&self) -> SpaceMouseDriver {
SpaceMouseDriver::from_u8(self.choice.load(Ordering::Relaxed))
}
fn present(&self) -> DriversPresent {
let found = self.found.load(Ordering::Relaxed);
DriversPresent { spacenavd: found & PRESENT_SPACENAVD != 0, direct: found & PRESENT_DIRECT != 0 }
}
fn publish(&self, present: DriversPresent) {
let found = (u8::from(present.spacenavd) * PRESENT_SPACENAVD) | (u8::from(present.direct) * PRESENT_DIRECT);
self.found.store(found, Ordering::Relaxed);
}
}
/// The reader that was picked.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pick {
Spacenavd,
Direct,
}
/// Which reader to use: the one asked for when it is there, else the other, else none. Automatic
/// asks for spacenavd, which is what has always been read first.
#[cfg(target_os = "linux")]
fn choose(wanted: SpaceMouseDriver, spacenavd: bool, direct: bool) -> Option<Pick> {
let first = match wanted {
SpaceMouseDriver::Automatic | SpaceMouseDriver::Spacenavd => Pick::Spacenavd,
SpaceMouseDriver::Direct => Pick::Direct,
};
let there = |pick| match pick {
Pick::Spacenavd => spacenavd,
Pick::Direct => direct,
};
let other = if first == Pick::Spacenavd { Pick::Direct } else { Pick::Spacenavd };
[first, other].into_iter().find(|&pick| there(pick))
}
/// Reads the 3D mouse through spacenavd or `/dev/hidrawN`, as `Drivers::choice` says (Automatic
/// is spacenavd when it runs, so what `spnavcfg` sets, swapped and inverted axes, sensitivity,
/// dead zones, applies here as in other programs). Both reads wake up when idle to see whether
/// the choice changed, which then moves to the other reader without a restart. hidraw is opened
/// non-blocking and ends with an error when the device is unplugged. Opening needs permission:
/// most distributions grant it to the logged-in user for devices that carry the `uaccess` udev
/// tag, which a rule for the 3D mouse has to add.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, drivers: Arc<Drivers>) {
use std::os::unix::fs::OpenOptionsExt;
let look = || DriversPresent { spacenavd: spacenavd::exists(), direct: hidraw::find().is_some() };
let mut denied = None;
let mut told = None;
loop {
if let Some(daemon) = spacenavd::connect() {
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
connected(&ctx, &shared, Source::Spacenavd);
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
reset(&ctx, &shared);
} else if let Some(device) = hidraw::find() {
match std::fs::File::open(&device.path) {
Ok(mut file) => {
let device = hidraw::find();
let present = DriversPresent { spacenavd: spacenavd::exists(), direct: device.is_some() };
drivers.publish(present);
let wanted = drivers.choice();
let mut pick = choose(wanted, present.spacenavd, present.direct);
// A socket file with no daemon behind it is no spacenavd.
let daemon = if pick == Some(Pick::Spacenavd) { spacenavd::connect() } else { None };
if pick == Some(Pick::Spacenavd) && daemon.is_none() {
pick = choose(wanted, false, present.direct);
}
let asked_for = match wanted {
SpaceMouseDriver::Automatic => None,
SpaceMouseDriver::Spacenavd => Some(Pick::Spacenavd),
SpaceMouseDriver::Direct => Some(Pick::Direct),
};
if let (Some(asked), Some(pick)) = (asked_for, pick)
&& asked != pick
&& told != Some((wanted, pick))
{
log::info!("3D mouse: {asked:?} is not available, reading {pick:?} instead");
told = Some((wanted, pick));
}
// Asked at every idle moment of a read: has the choice changed, and what is there now.
let mut looked = Instant::now();
let mut stop = || {
if looked.elapsed() >= LOOK_EVERY {
looked = Instant::now();
drivers.publish(look());
}
drivers.choice() != wanted
};
match (pick, daemon, device) {
(Some(Pick::Spacenavd), Some(daemon), _) => {
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
if let Err(error) = daemon.set_read_timeout(Some(SPACENAVD_WAKE)) {
log::warn!("3D mouse: spacenavd reads cannot time out ({error}); a change of driver waits for the next event");
}
connected(&ctx, &shared, Source::Spacenavd);
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes), &mut stop);
reset(&ctx, &shared);
}
(Some(Pick::Direct), _, Some(device)) => match std::fs::OpenOptions::new().read(true).custom_flags(hidraw::O_NONBLOCK).open(&device.path)
{
Ok(file) => {
denied = None;
log::info!("3D mouse: reading {}", device.path.display());
connected(&ctx, &shared, Source::Hid(device.ids));
let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop {
match file.read(&mut report) {
Ok(0) => break,
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
hidraw::read_reports(file, |report| publish(&ctx, &shared, device.layout, report), &mut stop);
reset(&ctx, &shared);
}
// Say so once per device node, not every retry.
@@ -400,9 +515,13 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
denied = Some(device.path);
}
Err(_) => {}
}
},
_ => {}
}
// A new choice is taken up at once; anything else waits to try again.
if drivers.choice() == wanted {
std::thread::sleep(RETRY_AFTER);
}
std::thread::sleep(RETRY_AFTER);
}
}
@@ -445,6 +564,8 @@ fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
#[cfg(target_os = "linux")]
drivers: Arc<Drivers>,
last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
@@ -462,13 +583,25 @@ impl SpaceMouse {
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
let worker_ctx = ctx.clone();
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
#[cfg(target_os = "linux")]
let drivers = Arc::new(Drivers::default());
#[cfg(target_os = "linux")]
let worker_drivers = Arc::clone(&drivers);
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
#[cfg(target_os = "linux")]
reader(worker_ctx, worker_state, worker_drivers);
#[cfg(not(target_os = "linux"))]
reader(worker_ctx, worker_state);
});
if let Err(error) = spawned {
log::warn!("3D mouse: reader could not start: {error}");
}
#[cfg(target_os = "macos")]
start_driver_client(ctx, &shared);
Self {
shared,
#[cfg(target_os = "linux")]
drivers,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
mapping_checked_at: now,
@@ -516,6 +649,11 @@ impl SpaceMouse {
}
self.mapping_checked_at = now;
}
#[cfg(target_os = "linux")]
{
self.drivers.choice.store(app.preferences.space_mouse_driver.to_u8(), Ordering::Relaxed);
app.space_mouse.drivers = Some(self.drivers.present());
}
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
app.space_mouse.device = reading.device.map(Source::key);
let calibration = calibration(app);
@@ -529,11 +667,108 @@ impl SpaceMouse {
}
}
/// A stream that answers reads from a script, for the timeout paths.
#[cfg(all(test, target_os = "linux"))]
mod fake {
use std::collections::VecDeque;
use std::io::{Error, ErrorKind, Read, Result};
enum Step {
Data(Vec<u8>),
Fail(ErrorKind),
End,
}
#[derive(Default)]
pub struct Script(VecDeque<Step>);
impl Script {
pub fn new() -> Self {
Self::default()
}
pub fn data(&mut self, bytes: &[u8]) -> &mut Self {
self.0.push_back(Step::Data(bytes.to_vec()));
self
}
pub fn timeout(&mut self, kind: ErrorKind) -> &mut Self {
self.fail(kind)
}
pub fn fail(&mut self, kind: ErrorKind) -> &mut Self {
self.0.push_back(Step::Fail(kind));
self
}
pub fn end(&mut self) -> &mut Self {
self.0.push_back(Step::End);
self
}
}
/// Past the script's last step the stream has ended.
impl Read for Script {
fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
match self.0.pop_front() {
Some(Step::Data(bytes)) => {
let size = bytes.len().min(buf.len());
buf[..size].copy_from_slice(&bytes[..size]);
Ok(size)
}
Some(Step::Fail(kind)) => Err(Error::from(kind)),
Some(Step::End) | None => Ok(0),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
#[cfg(target_os = "linux")]
#[test]
fn the_reader_asked_for_is_used_when_it_is_there_and_the_other_when_it_is_not() {
use Pick::{Direct, Spacenavd};
use SpaceMouseDriver::{Automatic, Direct as AskDirect, Spacenavd as AskSpacenavd};
// (asked, spacenavd there, device there) and what is read.
let cases = [
(Automatic, true, true, Some(Spacenavd)),
(Automatic, true, false, Some(Spacenavd)),
(Automatic, false, true, Some(Direct)),
(Automatic, false, false, None),
(AskSpacenavd, true, true, Some(Spacenavd)),
(AskSpacenavd, true, false, Some(Spacenavd)),
(AskSpacenavd, false, true, Some(Direct)),
(AskSpacenavd, false, false, None),
(AskDirect, true, true, Some(Direct)),
(AskDirect, true, false, Some(Spacenavd)),
(AskDirect, false, true, Some(Direct)),
(AskDirect, false, false, None),
];
for (asked, spacenavd, direct, expected) in cases {
assert_eq!(choose(asked, spacenavd, direct), expected, "{asked:?}, spacenavd {spacenavd}, device {direct}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn what_the_reader_found_and_the_choice_pass_between_threads() {
let drivers = Drivers::default();
assert_eq!(drivers.present(), DriversPresent::default());
for (spacenavd, direct) in [(true, false), (false, true), (true, true), (false, false)] {
drivers.publish(DriversPresent { spacenavd, direct });
assert_eq!(drivers.present(), DriversPresent { spacenavd, direct });
}
assert_eq!(drivers.choice(), SpaceMouseDriver::Automatic);
for choice in SpaceMouseDriver::ALL {
drivers.choice.store(choice.to_u8(), Ordering::Relaxed);
assert_eq!(drivers.choice(), choice);
}
}
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
@@ -625,6 +860,55 @@ mod tests {
assert_eq!(hid.current(later, Axes::default()), Axes::default());
}
#[test]
fn a_held_reading_near_rest_settles_after_the_timeout() {
// The last records of seven releases from tangency-labs/solidcraft#3523: spacenavd sends
// nothing after them.
let leftovers = [
[15, -60, 13, 59, 34, 129],
[-12, -69, 4, -26, 7, -6],
[0, -45, -3, 16, 8, -60],
[-31, 27, 22, -5, 6, -10],
[0, -30, -3, 3, -6, 0],
[5, 32, -2, 43, -8, 12],
[3, 2, -22, 3, 6, 5],
];
let now = Instant::now();
for [x, y, z, rx, ry, rz] in leftovers {
let axes = Axes { translation: [x, y, z], rotation: [rx, ry, rz] };
let held = Reading { axes, held: true, ..Reading::new(now) };
assert_eq!(held.current(now + HELD_SETTLE_AFTER - Duration::from_millis(1), Axes::default()), axes);
assert_eq!(held.current(now + HELD_SETTLE_AFTER, Axes::default()), Axes::default());
}
}
#[test]
fn a_settled_held_reading_reads_as_the_devices_own_rest() {
let now = Instant::now();
let rest = Axes { translation: [10, -8, 0], rotation: [0, 5, 0] };
// 40 from zero on X, but only 30 from this device's rest.
let held = Reading { axes: Axes { translation: [40, -8, 0], rotation: [0, 5, 0] }, held: true, ..Reading::new(now) };
assert_eq!(held.current(now + HELD_SETTLE_AFTER, rest), rest);
}
#[test]
fn a_firm_held_push_stays_however_long_it_lasts() {
let now = Instant::now();
let later = now + Duration::from_secs(10);
let push = Axes { translation: [0, 350, 0], rotation: [0; 3] };
let held = Reading { axes: push, held: true, ..Reading::new(now) };
assert_eq!(held.current(later, Axes::default()), push);
// One firm axis keeps the small ones with it.
let mixed = Axes { translation: [5, 300, -10], rotation: [3, 0, 20] };
let held = Reading { axes: mixed, held: true, ..Reading::new(now) };
assert_eq!(held.current(later, Axes::default()), mixed);
// The band is per reading, edge included: at it is a leftover, one past it is a hold.
let band = HELD_SETTLE_BAND as i16;
let twist = |value| Reading { axes: Axes { translation: [0; 3], rotation: [0, 0, value] }, held: true, ..Reading::new(now) };
assert_eq!(twist(band).current(later, Axes::default()), Axes::default());
assert_eq!(twist(band + 1).current(later, Axes::default()).rotation, [0, 0, band + 1]);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
@@ -689,6 +973,8 @@ mod tests {
reading.axes.rotation = [0, 350, 0];
let mut mouse = SpaceMouse {
shared: Arc::new(Mutex::new(reading)),
#[cfg(target_os = "linux")]
drivers: Arc::default(),
last_frame: now - Duration::from_millis(20),
driver_mapping: DriverMapping::object_mode(),
mapping_checked_at: now,
@@ -717,6 +1003,8 @@ mod tests {
fn mouse(reading: Reading, now: Instant) -> SpaceMouse {
SpaceMouse {
shared: Arc::new(Mutex::new(reading)),
#[cfg(target_os = "linux")]
drivers: Arc::default(),
last_frame: now,
driver_mapping: DriverMapping::object_mode(),
mapping_checked_at: now,
+63
View File
@@ -2,13 +2,23 @@
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
//! file I/O, so no udev or hidapi library is needed.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use solidcraft_space_mouse::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
/// `O_NONBLOCK` on Linux's common architectures (x86, ARM, RISC-V). The device is opened with it so
/// a read with nothing to read returns at once and the reader can see whether it should stop.
pub const O_NONBLOCK: i32 = 0o4000;
/// How long the reader waits when the device has nothing to say, which is also the most a report
/// can be delayed.
const IDLE_POLL: Duration = Duration::from_millis(5);
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
@@ -23,6 +33,30 @@ pub fn find() -> Option<Device> {
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
/// Hands every report to `publish` until the device is gone (end of file or an error) or `stop`
/// says so. `source` is non-blocking: when it has nothing, `stop` is asked and the reader waits
/// [`IDLE_POLL`]. Reports are read and handed over exactly as they were with blocking reads.
pub fn read_reports(mut source: impl Read, mut publish: impl FnMut(&[u8]), mut stop: impl FnMut() -> bool) {
let mut report = [0_u8; super::REPORT_BUFFER_BYTES];
loop {
match source.read(&mut report) {
Ok(0) => return,
Ok(size) => publish(report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if stop() {
return;
}
std::thread::sleep(IDLE_POLL);
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
return;
}
}
}
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
// A controller that declares no motion axis (buttons only, say) isn't read.
@@ -42,6 +76,35 @@ fn ids(uevent: &str) -> Option<(u16, u16)> {
mod tests {
use super::*;
#[test]
fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() {
use crate::space_mouse::fake::Script;
let mut script = Script::new();
script.timeout(std::io::ErrorKind::WouldBlock).data(&[1, 2, 3]).timeout(std::io::ErrorKind::WouldBlock).data(&[4]).end();
let (mut seen, mut asked) = (Vec::new(), 0);
read_reports(
script,
|report| seen.push(report.to_vec()),
|| {
asked += 1;
false
},
);
assert_eq!(seen, [vec![1, 2, 3], vec![4]]);
assert_eq!(asked, 2);
let mut script = Script::new();
script.data(&[1]).timeout(std::io::ErrorKind::WouldBlock).data(&[2]);
let mut seen = Vec::new();
read_reports(script, |report| seen.push(report.to_vec()), || true);
assert_eq!(seen, [vec![1]], "a stop at an idle moment ends the read");
// An unplugged device is an error, which ends the read as before.
let mut script = Script::new();
script.data(&[1]).fail(std::io::ErrorKind::BrokenPipe).data(&[2]);
let mut seen = Vec::new();
read_reports(script, |report| seen.push(report.to_vec()), || false);
assert_eq!(seen, [vec![1]]);
}
#[test]
fn ids_come_from_the_uevent() {
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
+60 -8
View File
@@ -23,6 +23,11 @@ const RECORD_BYTES: usize = 32;
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
const DAEMON_FULL_SCALE: i64 = 500;
/// Whether the daemon's socket is there, without connecting to it.
pub fn exists() -> bool {
SOCKETS.iter().any(|path| Path::new(path).exists())
}
pub fn connect() -> Option<UnixStream> {
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
}
@@ -56,18 +61,34 @@ fn unit(value: impl Into<i64>) -> i16 {
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
}
/// Hands every motion record to `publish` until the daemon closes the connection or fails. A
/// partial record at the end is dropped.
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes)) {
/// Hands every motion record to `publish` until the daemon closes the connection or fails, or
/// `stop` says so. A stream with a read timeout (`set_read_timeout`) times out when the daemon is
/// quiet, which is no record and no disconnect: `stop` is asked, and the read goes on, with what
/// of a record had arrived kept. A partial record at the end is dropped.
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes), mut stop: impl FnMut() -> bool) {
let mut record = [0_u8; RECORD_BYTES];
let mut filled = 0;
loop {
match stream.read_exact(&mut record) {
Ok(()) => {
if let Some(axes) = decode(&record) {
publish(axes);
match stream.read(record.get_mut(filled..).unwrap_or_default()) {
Ok(0) => {
log::info!("3D mouse: spacenavd connection ended");
return;
}
Ok(size) => {
filled += size;
if filled == RECORD_BYTES {
filled = 0;
if let Some(axes) = decode(&record) {
publish(axes);
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
if stop() {
return;
}
}
Err(error) => {
log::info!("3D mouse: spacenavd connection ended: {error}");
return;
@@ -132,10 +153,41 @@ mod tests {
daemon.write_all(&bytes).expect("written");
drop(daemon);
let mut seen = Vec::new();
read(client, |axes| seen.push(axes.translation));
read(client, |axes| seen.push(axes.translation), || false);
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
}
#[test]
fn a_quiet_daemon_times_out_without_losing_the_record_it_was_sending() {
use crate::space_mouse::fake::Script;
use std::io::ErrorKind::{TimedOut, WouldBlock};
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
let mut script = Script::new();
script.timeout(WouldBlock).data(&bytes[..10]).timeout(TimedOut).timeout(WouldBlock).data(&bytes[10..]).timeout(WouldBlock).end();
let (mut seen, mut asked) = (Vec::new(), 0);
read(
script,
|axes| seen.push(axes.translation),
|| {
asked += 1;
false
},
);
assert_eq!(seen, [[70, 0, 0]]);
assert_eq!(asked, 4, "each timeout asks whether to stop");
}
#[test]
fn a_stop_ends_the_read_at_the_next_timeout_only() {
use crate::space_mouse::fake::Script;
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
let mut script = Script::new();
script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes);
let mut seen = 0;
read(script, |_| seen += 1, || true);
assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read");
}
#[test]
fn no_daemon_means_no_connection() {
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
+117
View File
@@ -0,0 +1,117 @@
//! Keeping the restored window reachable.
//!
//! The window comes back where it was left. After a monitor was unplugged, the scale changed or
//! the settings came from a larger screen, that can put its title bar above the top edge or
//! under the taskbar, out of reach of the mouse. [`fit`] pulls a window back into the monitor's
//! work area (the screen without the taskbar), in whatever unit the caller measures both, as
//! long as it is the same one.
/// A rectangle: left, top, width, height.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Area {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Area {
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Area { x, y, w, h }
}
fn right(self) -> f32 {
self.x + self.w
}
fn bottom(self) -> f32 {
self.y + self.h
}
fn sane(self) -> bool {
[self.x, self.y, self.w, self.h].iter().all(|v| v.is_finite()) && self.w > 0.0 && self.h > 0.0
}
}
/// How much of the title bar must stay over the work area at least, to grab it.
pub const REACHABLE: f32 = 100.0;
/// The size a window gets when its saved geometry is unusable, before it is fitted to the screen.
pub const DEFAULT_SIZE: (f32, f32) = (1600.0, 1000.0);
/// `window` moved and shrunk so that it can be reached on `work`: no larger than the work area
/// (but not below `min`), the title bar not above the work area's top, the window not below its
/// bottom, and at least [`REACHABLE`] of it over the work area horizontally. A window that is
/// fine is returned unchanged. Geometry that makes no sense (not finite, empty), or a work
/// area that does not, gives the default size centred on the work area.
pub fn fit(window: Area, work: Area, min: (f32, f32)) -> Area {
if !work.sane() {
return window;
}
let min = (min.0.min(work.w), min.1.min(work.h));
if !window.sane() {
let w = DEFAULT_SIZE.0.min(work.w * 0.9).max(min.0);
let h = DEFAULT_SIZE.1.min(work.h * 0.9).max(min.1);
return Area::new(work.x + (work.w - w) / 2.0, work.y + (work.h - h) / 2.0, w, h);
}
let w = window.w.clamp(min.0, work.w);
let h = window.h.clamp(min.1, work.h);
let reach = REACHABLE.min(w);
// A window that had to shrink goes fully onto the screen; one that fits may keep straddling two.
let x = if w < window.w { window.x.max(work.x).min(work.right() - w) } else { window.x.max(work.x + reach - w).min(work.right() - reach) };
let y = window.y.min(work.bottom() - h).max(work.y);
Area::new(x, y, w, h)
}
#[cfg(test)]
mod tests {
use super::*;
const WORK: Area = Area::new(0.0, 0.0, 2560.0, 1400.0);
const MIN: (f32, f32) = (960.0, 600.0);
#[test]
fn a_window_on_screen_is_left_alone() {
let w = Area::new(300.0, 120.0, 1600.0, 1000.0);
assert_eq!(fit(w, WORK, MIN), w);
}
#[test]
fn a_title_bar_above_the_top_or_under_the_taskbar_comes_back() {
let fitted = fit(Area::new(300.0, -400.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!((fitted.y, fitted.h), (0.0, 1000.0));
let fitted = fit(Area::new(300.0, 1300.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(fitted.y + fitted.h, 1400.0);
// A taskbar at the top moves the work area down.
let below_bar = Area::new(0.0, 48.0, 2560.0, 1352.0);
assert_eq!(fit(Area::new(300.0, 0.0, 1600.0, 1000.0), below_bar, MIN).y, 48.0);
}
#[test]
fn a_window_from_a_monitor_that_is_gone_keeps_a_grabbable_part_on_screen() {
let right = fit(Area::new(5000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(right.x, 2560.0 - REACHABLE);
let left = fit(Area::new(-4000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(left.x + left.w, REACHABLE);
}
#[test]
fn a_window_larger_than_the_screen_shrinks_to_it() {
let small = Area::new(0.0, 0.0, 1280.0, 720.0);
let fitted = fit(Area::new(-20.0, -30.0, 3200.0, 2000.0), small, MIN);
assert_eq!(fitted, small);
// Never below the minimum, unless the screen itself is smaller.
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), WORK, MIN).w, 960.0);
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), Area::new(0.0, 0.0, 800.0, 500.0), MIN).h, 500.0);
}
#[test]
fn unusable_geometry_is_centred_at_a_sensible_size() {
for bad in [Area::new(f32::NAN, 0.0, 1600.0, 1000.0), Area::new(0.0, 0.0, 0.0, 0.0), Area::new(0.0, 0.0, f32::INFINITY, 10.0)] {
let fitted = fit(bad, WORK, MIN);
assert_eq!(fitted, Area::new(480.0, 200.0, 1600.0, 1000.0));
}
let small = Area::new(100.0, 50.0, 1366.0, 728.0);
let fitted = fit(Area::new(f32::NAN, 0.0, 1.0, 1.0), small, MIN);
assert!(fitted.w <= small.w && fitted.h <= small.h && fitted.x >= small.x && fitted.y >= small.y);
// No work area to go by: nothing to change.
let w = Area::new(1.0, 2.0, 3.0, 4.0);
assert_eq!(fit(w, Area::new(0.0, 0.0, 0.0, 0.0), MIN), w);
}
}
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Úhel",
"ui.angle_decimals": "Desetinná místa úhlu",
"ui.animateJoint": "Animovat kloub",
"ui.animate_view_changes": "Animovat změny pohledu",
"ui.animation": "Animace",
"ui.animation_speed": "Rychlost animace",
"ui.anti_aliasing": "Vyhlazování hran",
"ui.apply": "Použít",
"ui.arc": "Oblouk",
@@ -1551,7 +1549,6 @@
"ui.lock": "Zamknout",
"ui.loft": "Spojení profilů",
"ui.look_at": "Pohled kolmo",
"ui.look_at_anim_desc": "Pohled podél osy, pohled kolmo, výchozí pohled a přizpůsobení",
"ui.manipulator_free_alt": "Volné tažení manipulátorem také s Alt/Option",
"ui.manipulator_free_alt_tip": "Zapnuto: Alt/Option nebo Ctrl/Cmd na šipkách vysunutí / manipulátoru vypne přichytávání hodnot. Vypnuto: pouze Ctrl/Cmd.",
"ui.manufacture": "Výroba",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem",
"ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání",
"ui.snap": "Přichytávání",
"ui.space_mouse_axes_reassigned": "Osy jsou v Předvolbách přeřazené a to platí i po této kalibraci.",
"ui.space_mouse_calibrate": "Kalibrovat…",
"ui.space_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.",
"ui.space_mouse_calibrate_title": "Kalibrace 3D myši",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.",
"ui.space_mouse_dead_zone": "Mrtvá zóna",
"ui.space_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.",
"ui.space_mouse_driven_by": "Který kalibrovaný pohyb zařízení ovládá tuto osu. Když vyberete pohyb, který už používá jiná osa, obě se prohodí.",
"ui.space_mouse_driven_by_label": "ovládá",
"ui.space_mouse_driver": "Ovladač 3D myši",
"ui.space_mouse_driver_auto": "Automaticky",
"ui.space_mouse_driver_direct": "Přímo (hidraw)",
"ui.space_mouse_driver_only_direct": "Zařízení se čte přímo; spacenavd neběží",
"ui.space_mouse_driver_only_spacenavd": "Čte se přes spacenavd, jiná možnost není",
"ui.space_mouse_forget": "Zapomenout kalibraci",
"ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.",
"ui.space_mouse_motion_rotation_x": "Rotace X",
"ui.space_mouse_motion_rotation_y": "Rotace Y",
"ui.space_mouse_motion_rotation_z": "Rotace Z",
"ui.space_mouse_motion_translation_x": "Posun X",
"ui.space_mouse_motion_translation_y": "Posun Y",
"ui.space_mouse_motion_translation_z": "Posun Z",
"ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D myš nenalezena",
"ui.space_mouse_not_calibrated": "Nezkalibrováno",
"ui.space_mouse_reading": "Hodnoty",
"ui.space_mouse_reset_axes": "Obnovit přiřazení os",
"ui.space_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.",
"ui.space_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.",
"ui.space_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.",
"ui.space_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.",
"ui.space_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.",
"ui.space_mouse_view_axis": "Osa pohledu",
"ui.space_mouse_view_off": "Vypnuto",
"ui.space_mouse_view_off_tip": "Profil ovladače 3D myši tuto osu vypíná, takže její pohyb s pohledem nehýbe. Když tu vyberete pohyb jiné osy, prohodí se s ní a s pohledem přestane hýbat.",
"ui.space_mouse_view_orbit_sideways": "Orbit vlevo / vpravo",
"ui.space_mouse_view_orbit_up_down": "Orbit nahoru / dolů",
"ui.space_mouse_view_pan_sideways": "Posun vlevo / vpravo",
"ui.space_mouse_view_pan_up_down": "Posun nahoru / dolů",
"ui.space_mouse_view_roll": "Natočení",
"ui.space_mouse_view_unmapped": "Osa {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Rozteč",
"ui.spacing_2": "Rozteč 2",
"ui.sphere": "Koule",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Vinkel",
"ui.angle_decimals": "Vinkeldecimaler",
"ui.animateJoint": "Animér led",
"ui.animate_view_changes": "Animér visningsskift",
"ui.animation": "Animation",
"ui.animation_speed": "Animationshastighed",
"ui.anti_aliasing": "Kantudjævning",
"ui.apply": "Anvend",
"ui.arc": "Bue",
@@ -1551,7 +1549,6 @@
"ui.lock": "Lås",
"ui.loft": "Loft",
"ui.look_at": "Se på",
"ui.look_at_anim_desc": "Aksevisning, Se på, Hjem og Tilpas",
"ui.manipulator_free_alt": "Brug også Alt/Option til at trække frit",
"ui.manipulator_free_alt_tip": "Slået til: Hold Alt/Option eller Ctrl/Cmd nede, mens du trækker i pilene ved ekstrudering eller flytning, for at undgå faste værditrin. Slået fra: Kun Ctrl/Cmd.",
"ui.manufacture": "Fremstilling",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Winkel",
"ui.angle_decimals": "Winkel-Dezimalstellen",
"ui.animateJoint": "Gelenk animieren",
"ui.animate_view_changes": "Ansichtsänderungen animieren",
"ui.animation": "Animation",
"ui.animation_speed": "Animationsgeschwindigkeit",
"ui.anti_aliasing": "Kantenglättung",
"ui.apply": "Anwenden",
"ui.arc": "Kreisbogen",
@@ -1551,7 +1549,6 @@
"ui.lock": "Sperren",
"ui.loft": "Loft",
"ui.look_at": "Ansehen",
"ui.look_at_anim_desc": "Achsenansicht, Ansehen, Home und Einpassen",
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
"ui.manufacture": "Fertigung",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Sanftes Zoomen mit dem Rad",
"ui.smooth_wheel_zoom_tip": "Ausschalten, um jedes Radereignis sofort anzuwenden, ohne Nachlauf",
"ui.snap": "Einrasten",
"ui.space_mouse_axes_reassigned": "Die Achsen sind in den Einstellungen neu zugeordnet, und das gilt auch nach dieser Kalibrierung.",
"ui.space_mouse_calibrate": "Kalibrieren…",
"ui.space_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.",
"ui.space_mouse_calibrate_title": "3D-Maus kalibrieren",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.",
"ui.space_mouse_dead_zone": "Totzone",
"ui.space_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.",
"ui.space_mouse_driven_by": "Welche kalibrierte Bewegung des Geräts diese Achse steuert. Wählen Sie eine, die schon eine andere Achse nutzt, werden die beiden getauscht.",
"ui.space_mouse_driven_by_label": "gesteuert von",
"ui.space_mouse_driver": "3D-Maus-Treiber",
"ui.space_mouse_driver_auto": "Automatisch",
"ui.space_mouse_driver_direct": "Direkt (hidraw)",
"ui.space_mouse_driver_only_direct": "Das Gerät wird direkt gelesen; spacenavd läuft nicht",
"ui.space_mouse_driver_only_spacenavd": "Lesen über spacenavd, die einzige verfügbare Möglichkeit",
"ui.space_mouse_forget": "Kalibrierung vergessen",
"ui.space_mouse_hint": "Ansicht gleichzeitig verschieben und drehen; Skizzen verwenden nur Schwenken und Zoomen.",
"ui.space_mouse_motion_rotation_x": "Rotation X",
"ui.space_mouse_motion_rotation_y": "Rotation Y",
"ui.space_mouse_motion_rotation_z": "Rotation Z",
"ui.space_mouse_motion_translation_x": "Translation X",
"ui.space_mouse_motion_translation_y": "Translation Y",
"ui.space_mouse_motion_translation_z": "Translation Z",
"ui.space_mouse_navigate": "Mit SpaceNavigator / SpaceMouse navigieren",
"ui.space_mouse_none": "Keine 3D-Maus gefunden",
"ui.space_mouse_not_calibrated": "Nicht kalibriert",
"ui.space_mouse_reading": "Werte",
"ui.space_mouse_reset_axes": "Achsenzuordnung zurücksetzen",
"ui.space_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.",
"ui.space_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.",
"ui.space_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.",
"ui.space_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.",
"ui.space_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.",
"ui.space_mouse_view_axis": "Ansichtsachse",
"ui.space_mouse_view_off": "Aus",
"ui.space_mouse_view_off_tip": "Das Profil des 3D-Maus-Treibers schaltet diese Achse ab, daher bewegt ihre Bewegung die Ansicht nicht. Wählen Sie hier die Bewegung einer anderen Achse, werden die beiden getauscht und sie bewegt die Ansicht nicht mehr.",
"ui.space_mouse_view_orbit_sideways": "Orbit links / rechts",
"ui.space_mouse_view_orbit_up_down": "Orbit hoch / runter",
"ui.space_mouse_view_pan_sideways": "Schwenken links / rechts",
"ui.space_mouse_view_pan_up_down": "Schwenken hoch / runter",
"ui.space_mouse_view_roll": "Rollen",
"ui.space_mouse_view_unmapped": "Achse {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Abstand",
"ui.spacing_2": "Abstand 2",
"ui.sphere": "Sphäre",
+25 -3
View File
@@ -1295,9 +1295,7 @@
"ui.angle": "Angle",
"ui.angle_decimals": "Angle decimals",
"ui.animateJoint": "Animate Joint",
"ui.animate_view_changes": "Animate view changes",
"ui.animation": "Animation",
"ui.animation_speed": "Animation speed",
"ui.anti_aliasing": "Anti-aliasing",
"ui.apply": "Apply",
"ui.arc": "Arc",
@@ -1559,7 +1557,6 @@
"ui.lock": "Lock",
"ui.loft": "Loft",
"ui.look_at": "Look At",
"ui.look_at_anim_desc": "Axis view, Look At, Home and Fit",
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
"ui.manufacture": "Manufacture",
@@ -1745,6 +1742,7 @@
"ui.smooth_wheel_zoom": "Smooth wheel zoom",
"ui.smooth_wheel_zoom_tip": "Turn off to apply each wheel event immediately, without a smoothing tail",
"ui.snap": "Snap",
"ui.space_mouse_axes_reassigned": "Axes are reassigned in Preferences, and that still applies on top of this calibration.",
"ui.space_mouse_calibrate": "Calibrate…",
"ui.space_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.",
"ui.space_mouse_calibrate_title": "Calibrate 3D mouse",
@@ -1754,12 +1752,26 @@
"ui.space_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.",
"ui.space_mouse_dead_zone": "Dead zone",
"ui.space_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.",
"ui.space_mouse_driven_by": "Which calibrated motion of the device drives this axis. Picking a motion another axis already uses swaps the two.",
"ui.space_mouse_driven_by_label": "driven by",
"ui.space_mouse_driver": "3D mouse driver",
"ui.space_mouse_driver_auto": "Automatic",
"ui.space_mouse_driver_direct": "Direct (hidraw)",
"ui.space_mouse_driver_only_direct": "Reading the device directly; spacenavd is not running",
"ui.space_mouse_driver_only_spacenavd": "Reading through spacenavd, the only way available",
"ui.space_mouse_forget": "Forget calibration",
"ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.",
"ui.space_mouse_motion_rotation_x": "Rotation X",
"ui.space_mouse_motion_rotation_y": "Rotation Y",
"ui.space_mouse_motion_rotation_z": "Rotation Z",
"ui.space_mouse_motion_translation_x": "Translation X",
"ui.space_mouse_motion_translation_y": "Translation Y",
"ui.space_mouse_motion_translation_z": "Translation Z",
"ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "No 3D mouse found",
"ui.space_mouse_not_calibrated": "Not calibrated",
"ui.space_mouse_reading": "Reading",
"ui.space_mouse_reset_axes": "Reset axis assignment",
"ui.space_mouse_step_lift": "Lift the cap up, hold it, then let go.",
"ui.space_mouse_step_press": "Press the cap down, hold it, then let go.",
"ui.space_mouse_step_pull": "Pull the cap toward you, hold it, then let go.",
@@ -1773,6 +1785,16 @@
"ui.space_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.",
"ui.space_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.",
"ui.space_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.",
"ui.space_mouse_view_axis": "View axis",
"ui.space_mouse_view_off": "Off",
"ui.space_mouse_view_off_tip": "The 3D mouse driver's profile turns this axis off, so its motion doesn't move the view. Picking another axis's motion here swaps the two, and that motion stops moving the view.",
"ui.space_mouse_view_orbit_sideways": "Orbit left / right",
"ui.space_mouse_view_orbit_up_down": "Orbit up / down",
"ui.space_mouse_view_pan_sideways": "Pan left / right",
"ui.space_mouse_view_pan_up_down": "Pan up / down",
"ui.space_mouse_view_roll": "Roll",
"ui.space_mouse_view_unmapped": "Axis {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Spacing",
"ui.spacing_2": "Spacing 2",
"ui.sphere": "Sphere",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Ángulo",
"ui.angle_decimals": "Decimales de ángulo",
"ui.animateJoint": "Animar unión",
"ui.animate_view_changes": "Animar cambios de vista",
"ui.animation": "Animación",
"ui.animation_speed": "Velocidad de animación",
"ui.anti_aliasing": "Suavizado de bordes",
"ui.apply": "Aplicar",
"ui.arc": "Arco",
@@ -1551,7 +1549,6 @@
"ui.lock": "Bloquear",
"ui.loft": "Solevación",
"ui.look_at": "Mirar hacia",
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
"ui.manipulator_free_alt": "Arrastre libre del manipulador también con Alt/Option",
"ui.manipulator_free_alt_tip": "Activado: Alt/Option o Ctrl/Cmd sobre las flechas de extrusión / manipulador desactiva el ajuste de valores. Desactivado: solo Ctrl/Cmd.",
"ui.manufacture": "Fabricación",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
"ui.snap": "Referencia a objetos",
"ui.space_mouse_axes_reassigned": "Los ejes están reasignados en Preferencias, y eso sigue aplicándose sobre esta calibración.",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.",
"ui.space_mouse_calibrate_title": "Calibrar ratón 3D",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.",
"ui.space_mouse_dead_zone": "Zona muerta",
"ui.space_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.",
"ui.space_mouse_driven_by": "Qué movimiento calibrado del dispositivo controla este eje. Si eliges uno que ya usa otro eje, se intercambian.",
"ui.space_mouse_driven_by_label": "controlado por",
"ui.space_mouse_driver": "Controlador del ratón 3D",
"ui.space_mouse_driver_auto": "Automático",
"ui.space_mouse_driver_direct": "Directo (hidraw)",
"ui.space_mouse_driver_only_direct": "Lectura directa del dispositivo; spacenavd no se está ejecutando",
"ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, la única opción disponible",
"ui.space_mouse_forget": "Olvidar calibración",
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
"ui.space_mouse_motion_rotation_x": "Rotación X",
"ui.space_mouse_motion_rotation_y": "Rotación Y",
"ui.space_mouse_motion_rotation_z": "Rotación Z",
"ui.space_mouse_motion_translation_x": "Traslación X",
"ui.space_mouse_motion_translation_y": "Traslación Y",
"ui.space_mouse_motion_translation_z": "Traslación Z",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "No se encontró ningún ratón 3D",
"ui.space_mouse_not_calibrated": "Sin calibrar",
"ui.space_mouse_reading": "Lectura",
"ui.space_mouse_reset_axes": "Restablecer asignación de ejes",
"ui.space_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.",
"ui.space_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.",
"ui.space_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.",
"ui.space_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.",
"ui.space_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.",
"ui.space_mouse_view_axis": "Eje de la vista",
"ui.space_mouse_view_off": "Desactivado",
"ui.space_mouse_view_off_tip": "El perfil del controlador del ratón 3D desactiva este eje, así que su movimiento no mueve la vista. Si eliges aquí el movimiento de otro eje, se intercambian y ese movimiento deja de mover la vista.",
"ui.space_mouse_view_orbit_sideways": "Orbitar izquierda / derecha",
"ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abajo",
"ui.space_mouse_view_pan_sideways": "Desplazar izquierda / derecha",
"ui.space_mouse_view_pan_up_down": "Desplazar arriba / abajo",
"ui.space_mouse_view_roll": "Girar sobre la vista",
"ui.space_mouse_view_unmapped": "Eje {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Espaciado",
"ui.spacing_2": "Espaciado 2",
"ui.sphere": "Esfera",
+25 -3
View File
@@ -1293,9 +1293,7 @@
"ui.angle": "Angle",
"ui.angle_decimals": "Décimales des angles",
"ui.animateJoint": "Animer la liaison",
"ui.animate_view_changes": "Animer les changements de vue",
"ui.animation": "Animation",
"ui.animation_speed": "Vitesse d'animation",
"ui.anti_aliasing": "Anticrénelage",
"ui.apply": "Appliquer",
"ui.arc": "Arc",
@@ -1557,7 +1555,6 @@
"ui.lock": "Verrouiller",
"ui.loft": "Lissage",
"ui.look_at": "Vue normale",
"ui.look_at_anim_desc": "Vue d'axe, Vue normale, Accueil et Cadrer",
"ui.manipulator_free_alt": "Glissement libre du manipulateur aussi avec Alt/Option",
"ui.manipulator_free_alt_tip": "Activé : Alt/Option ou Ctrl/Cmd sur les flèches d’extrusion / du manipulateur désactive l’accrochage aux valeurs. Désactivé : Ctrl/Cmd uniquement.",
"ui.manufacture": "Fabrication",
@@ -1743,6 +1740,7 @@
"ui.smooth_wheel_zoom": "Zoom fluide à la molette",
"ui.smooth_wheel_zoom_tip": "Désactiver pour appliquer chaque cran de molette immédiatement, sans lissage",
"ui.snap": "Accrochage",
"ui.space_mouse_axes_reassigned": "Les axes sont réattribués dans les Préférences, et cela s'applique toujours en plus de ce calibrage.",
"ui.space_mouse_calibrate": "Calibrer…",
"ui.space_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.",
"ui.space_mouse_calibrate_title": "Calibrer la souris 3D",
@@ -1752,12 +1750,26 @@
"ui.space_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.",
"ui.space_mouse_dead_zone": "Zone morte",
"ui.space_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.",
"ui.space_mouse_driven_by": "Quel mouvement calibré de l'appareil pilote cet axe. Choisir un mouvement déjà utilisé par un autre axe les échange.",
"ui.space_mouse_driven_by_label": "piloté par",
"ui.space_mouse_driver": "Pilote de la souris 3D",
"ui.space_mouse_driver_auto": "Automatique",
"ui.space_mouse_driver_direct": "Direct (hidraw)",
"ui.space_mouse_driver_only_direct": "Lecture directe du périphérique ; spacenavd n'est pas lancé",
"ui.space_mouse_driver_only_spacenavd": "Lecture via spacenavd, seule possibilité disponible",
"ui.space_mouse_forget": "Oublier le calibrage",
"ui.space_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.",
"ui.space_mouse_motion_rotation_x": "Rotation X",
"ui.space_mouse_motion_rotation_y": "Rotation Y",
"ui.space_mouse_motion_rotation_z": "Rotation Z",
"ui.space_mouse_motion_translation_x": "Translation X",
"ui.space_mouse_motion_translation_y": "Translation Y",
"ui.space_mouse_motion_translation_z": "Translation Z",
"ui.space_mouse_navigate": "Naviguer avec SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Aucune souris 3D trouvée",
"ui.space_mouse_not_calibrated": "Non calibrée",
"ui.space_mouse_reading": "Lecture",
"ui.space_mouse_reset_axes": "Réinitialiser l'attribution des axes",
"ui.space_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.",
"ui.space_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.",
"ui.space_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.",
@@ -1771,6 +1783,16 @@
"ui.space_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.",
"ui.space_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.",
"ui.space_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.",
"ui.space_mouse_view_axis": "Axe de la vue",
"ui.space_mouse_view_off": "Désactivé",
"ui.space_mouse_view_off_tip": "Le profil du pilote de la souris 3D désactive cet axe : son mouvement ne déplace pas la vue. Choisir ici le mouvement d'un autre axe les échange, et ce mouvement cesse de déplacer la vue.",
"ui.space_mouse_view_orbit_sideways": "Orbite gauche / droite",
"ui.space_mouse_view_orbit_up_down": "Orbite haut / bas",
"ui.space_mouse_view_pan_sideways": "Panoramique gauche / droite",
"ui.space_mouse_view_pan_up_down": "Panoramique haut / bas",
"ui.space_mouse_view_roll": "Roulis",
"ui.space_mouse_view_unmapped": "Axe {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Espacement",
"ui.spacing_2": "Espacement 2",
"ui.sphere": "Sphère",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Ángulo",
"ui.angle_decimals": "Decimais dos ángulos",
"ui.animateJoint": "Animar a unión",
"ui.animate_view_changes": "Animar os cambios de vista",
"ui.animation": "Animación",
"ui.animation_speed": "Velocidade da animación",
"ui.anti_aliasing": "Antialiasing",
"ui.apply": "Aplicar",
"ui.arc": "Arco",
@@ -1551,7 +1549,6 @@
"ui.lock": "Bloquear",
"ui.loft": "Transición",
"ui.look_at": "Mirar cara a",
"ui.look_at_anim_desc": "Vista de eixe, Mirar cara a, Inicio e Axustar",
"ui.manipulator_free_alt": "Arrastre libre do manipulador tamén con Alt/Option",
"ui.manipulator_free_alt_tip": "Activado: Alt/Option ou Ctrl/Cmd nas frechas de extrusión / do gizmo desactiva o axuste de valores. Desactivado: só Ctrl/Cmd.",
"ui.manufacture": "Fabricación",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Zoom suave coa roda",
"ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado",
"ui.snap": "Axuste",
"ui.space_mouse_axes_reassigned": "Os eixes están reasignados en Preferencias, e iso segue aplicándose sobre esta calibración.",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.",
"ui.space_mouse_calibrate_title": "Calibrar o rato 3D",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.",
"ui.space_mouse_driven_by": "Que movemento calibrado do dispositivo controla este eixe. Se escolles un que xa usa outro eixe, intercámbianse.",
"ui.space_mouse_driven_by_label": "controlado por",
"ui.space_mouse_driver": "Controlador do rato 3D",
"ui.space_mouse_driver_auto": "Automático",
"ui.space_mouse_driver_direct": "Directo (hidraw)",
"ui.space_mouse_driver_only_direct": "Lectura directa do dispositivo; spacenavd non está en execución",
"ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, a única opción dispoñible",
"ui.space_mouse_forget": "Esquecer a calibración",
"ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.",
"ui.space_mouse_motion_rotation_x": "Rotación X",
"ui.space_mouse_motion_rotation_y": "Rotación Y",
"ui.space_mouse_motion_rotation_z": "Rotación Z",
"ui.space_mouse_motion_translation_x": "Translación X",
"ui.space_mouse_motion_translation_y": "Translación Y",
"ui.space_mouse_motion_translation_z": "Translación Z",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Non se atopou ningún rato 3D",
"ui.space_mouse_not_calibrated": "Sen calibrar",
"ui.space_mouse_reading": "Lectura",
"ui.space_mouse_reset_axes": "Restablecer asignación de eixes",
"ui.space_mouse_step_lift": "Levanta o capuchón, mantéño e sóltao.",
"ui.space_mouse_step_press": "Preme o capuchón cara abaixo, mantéño e sóltao.",
"ui.space_mouse_step_pull": "Tira do capuchón cara a ti, mantéño e sóltao.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantéño e sóltao.",
"ui.space_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantéño e sóltao.",
"ui.space_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantéño e sóltao.",
"ui.space_mouse_view_axis": "Eixe da vista",
"ui.space_mouse_view_off": "Desactivado",
"ui.space_mouse_view_off_tip": "O perfil do controlador do rato 3D desactiva este eixe, así que o seu movemento non move a vista. Se escolles aquí o movemento doutro eixe, intercámbianse e ese movemento deixa de mover a vista.",
"ui.space_mouse_view_orbit_sideways": "Orbitar á esquerda / dereita",
"ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abaixo",
"ui.space_mouse_view_pan_sideways": "Desprazar á esquerda / dereita",
"ui.space_mouse_view_pan_up_down": "Desprazar arriba / abaixo",
"ui.space_mouse_view_roll": "Xirar sobre a vista",
"ui.space_mouse_view_unmapped": "Eixe {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Espazado",
"ui.spacing_2": "Espazado 2",
"ui.sphere": "Esfera",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Szög",
"ui.angle_decimals": "Szög tizedesjegyei",
"ui.animateJoint": "Csukló animálása",
"ui.animate_view_changes": "Nézetváltozások animálása",
"ui.animation": "Animáció",
"ui.animation_speed": "Animáció sebessége",
"ui.anti_aliasing": "Élsimítás",
"ui.apply": "Alkalmaz",
"ui.arc": "Ív",
@@ -1551,7 +1549,6 @@
"ui.lock": "Zárolás",
"ui.loft": "Áthúzás",
"ui.look_at": "Ránézés",
"ui.look_at_anim_desc": "Tengelynézet, Ránézés, Kezdőlap és Illesztés",
"ui.manipulator_free_alt": "A manipulátor szabad húzása Alt/Option billentyűvel is",
"ui.manipulator_free_alt_tip": "Be: az Alt/Option vagy Ctrl/Cmd a kihúzás / gizmo nyilakon letiltja az értékillesztést. Ki: csak a Ctrl/Cmd.",
"ui.manufacture": "Gyártás",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Sudut",
"ui.angle_decimals": "Desimal sudut",
"ui.animateJoint": "Animasikan Sambungan",
"ui.animate_view_changes": "Animasikan perubahan tampilan",
"ui.animation": "Animasi",
"ui.animation_speed": "Kecepatan animasi",
"ui.anti_aliasing": "Anti-aliasing",
"ui.apply": "Terapkan",
"ui.arc": "Busur",
@@ -1551,7 +1549,6 @@
"ui.lock": "Kunci",
"ui.loft": "Loft",
"ui.look_at": "Look At",
"ui.look_at_anim_desc": "Tampilan sumbu, Look At, Home, dan Fit",
"ui.manipulator_free_alt": "Seret bebas manipulator juga dengan Alt/Option",
"ui.manipulator_free_alt_tip": "Aktif: Alt/Option atau Ctrl/Cmd pada panah ekstrusi / gizmo menonaktifkan snap nilai. Mati: hanya Ctrl/Cmd.",
"ui.manufacture": "Manufaktur",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Angolo",
"ui.angle_decimals": "Decimali degli angoli",
"ui.animateJoint": "Anima giunto",
"ui.animate_view_changes": "Anima cambi di vista",
"ui.animation": "Animazione",
"ui.animation_speed": "Velocità dell'animazione",
"ui.anti_aliasing": "Antialiasing",
"ui.apply": "Applica",
"ui.arc": "Arco",
@@ -1551,7 +1549,6 @@
"ui.lock": "Blocca",
"ui.loft": "Loft",
"ui.look_at": "Vista normale",
"ui.look_at_anim_desc": "Viste degli assi, Vista normale, Vista iniziale e Adatta",
"ui.manipulator_free_alt": "Trascinamento libero del manipolatore anche con Alt/Option",
"ui.manipulator_free_alt_tip": "Attivo: Alt/Option o Ctrl/Cmd sulle frecce di estrusione / gizmo disattivano l'aggancio ai valori. Disattivo: solo Ctrl/Cmd.",
"ui.manufacture": "Produzione",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Zoom fluido con rotellina",
"ui.smooth_wheel_zoom_tip": "Disattiva per applicare immediatamente ogni evento della rotellina, senza coda di interpolazione",
"ui.snap": "Aggancio",
"ui.space_mouse_axes_reassigned": "Gli assi sono riassegnati nelle Preferenze, e questo vale anche dopo questa calibrazione.",
"ui.space_mouse_calibrate": "Calibra…",
"ui.space_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.",
"ui.space_mouse_calibrate_title": "Calibra mouse 3D",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.",
"ui.space_mouse_driven_by": "Quale movimento calibrato del dispositivo comanda questo asse. Se ne scegli uno già usato da un altro asse, i due si scambiano.",
"ui.space_mouse_driven_by_label": "comandato da",
"ui.space_mouse_driver": "Driver del mouse 3D",
"ui.space_mouse_driver_auto": "Automatico",
"ui.space_mouse_driver_direct": "Diretto (hidraw)",
"ui.space_mouse_driver_only_direct": "Lettura diretta del dispositivo; spacenavd non è in esecuzione",
"ui.space_mouse_driver_only_spacenavd": "Lettura tramite spacenavd, l'unica possibilità disponibile",
"ui.space_mouse_forget": "Dimentica calibrazione",
"ui.space_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.",
"ui.space_mouse_motion_rotation_x": "Rotazione X",
"ui.space_mouse_motion_rotation_y": "Rotazione Y",
"ui.space_mouse_motion_rotation_z": "Rotazione Z",
"ui.space_mouse_motion_translation_x": "Traslazione X",
"ui.space_mouse_motion_translation_y": "Traslazione Y",
"ui.space_mouse_motion_translation_z": "Traslazione Z",
"ui.space_mouse_navigate": "Naviga con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Nessun mouse 3D trovato",
"ui.space_mouse_not_calibrated": "Non calibrato",
"ui.space_mouse_reading": "Lettura",
"ui.space_mouse_reset_axes": "Ripristina assegnazione assi",
"ui.space_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.",
"ui.space_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.",
"ui.space_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.",
"ui.space_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.",
"ui.space_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.",
"ui.space_mouse_view_axis": "Asse della vista",
"ui.space_mouse_view_off": "Disattivato",
"ui.space_mouse_view_off_tip": "Il profilo del driver del mouse 3D disattiva questo asse, quindi il suo movimento non muove la vista. Se qui scegli il movimento di un altro asse, i due si scambiano e quel movimento smette di muovere la vista.",
"ui.space_mouse_view_orbit_sideways": "Orbita sinistra / destra",
"ui.space_mouse_view_orbit_up_down": "Orbita su / giù",
"ui.space_mouse_view_pan_sideways": "Panoramica sinistra / destra",
"ui.space_mouse_view_pan_up_down": "Panoramica su / giù",
"ui.space_mouse_view_roll": "Rollio",
"ui.space_mouse_view_unmapped": "Asse {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Spaziatura",
"ui.spacing_2": "Spaziatura 2",
"ui.sphere": "Sfera",
+25 -3
View File
@@ -1295,9 +1295,7 @@
"ui.angle": "角度",
"ui.angle_decimals": "角度の小数点以下の桁数",
"ui.animateJoint": "ジョイントをアニメート",
"ui.animate_view_changes": "視点変更をアニメーション表示",
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.anti_aliasing": "アンチエイリアス",
"ui.apply": "適用",
"ui.arc": "円弧",
@@ -1559,7 +1557,6 @@
"ui.lock": "ロック",
"ui.loft": "ロフト",
"ui.look_at": "正面視",
"ui.look_at_anim_desc": "軸ビュー、正面視、ホーム、全体表示",
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
"ui.manufacture": "製造",
@@ -1745,6 +1742,7 @@
"ui.smooth_wheel_zoom": "滑らかなホイールズーム",
"ui.smooth_wheel_zoom_tip": "オフにすると、ホイール操作のたびにズームが即座に反映され、滑らかな減速効果は適用されません。",
"ui.snap": "スナップ",
"ui.space_mouse_axes_reassigned": "軸の割り当ては環境設定で変更されており、このキャリブレーションの後も適用されます。",
"ui.space_mouse_calibrate": "キャリブレーション…",
"ui.space_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。",
"ui.space_mouse_calibrate_title": "3Dマウスのキャリブレーション",
@@ -1754,12 +1752,26 @@
"ui.space_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。",
"ui.space_mouse_dead_zone": "デッドゾーン",
"ui.space_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。",
"ui.space_mouse_driven_by": "この軸を操作するデバイスのキャリブレーション済みの動き。他の軸で使われている動きを選ぶと、2つが入れ替わります。",
"ui.space_mouse_driven_by_label": "操作元",
"ui.space_mouse_driver": "3Dマウスのドライバー",
"ui.space_mouse_driver_auto": "自動",
"ui.space_mouse_driver_direct": "直接 (hidraw)",
"ui.space_mouse_driver_only_direct": "デバイスを直接読み取り中(spacenavd は起動していません)",
"ui.space_mouse_driver_only_spacenavd": "spacenavd 経由で読み取り中(他の方法はありません)",
"ui.space_mouse_forget": "キャリブレーションを削除",
"ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)",
"ui.space_mouse_motion_rotation_x": "回転 X",
"ui.space_mouse_motion_rotation_y": "回転 Y",
"ui.space_mouse_motion_rotation_z": "回転 Z",
"ui.space_mouse_motion_translation_x": "平行移動 X",
"ui.space_mouse_motion_translation_y": "平行移動 Y",
"ui.space_mouse_motion_translation_z": "平行移動 Z",
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作",
"ui.space_mouse_none": "3Dマウスが見つかりません",
"ui.space_mouse_not_calibrated": "未キャリブレーション",
"ui.space_mouse_reading": "読み取り値",
"ui.space_mouse_reset_axes": "軸の割り当てをリセット",
"ui.space_mouse_step_lift": "キャップを持ち上げて保持し、離してください。",
"ui.space_mouse_step_press": "キャップを押し下げて保持し、離してください。",
"ui.space_mouse_step_pull": "キャップを手前に引いて保持し、離してください。",
@@ -1773,6 +1785,16 @@
"ui.space_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。",
"ui.space_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。",
"ui.space_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。",
"ui.space_mouse_view_axis": "ビューの軸",
"ui.space_mouse_view_off": "オフ",
"ui.space_mouse_view_off_tip": "3D マウスのドライバーのプロファイルがこの軸をオフにしているため、この軸の動きではビューは動きません。ここで他の軸の動きを選ぶと 2 つが入れ替わり、その動きではビューが動かなくなります。",
"ui.space_mouse_view_orbit_sideways": "左右オービット",
"ui.space_mouse_view_orbit_up_down": "上下オービット",
"ui.space_mouse_view_pan_sideways": "左右パン",
"ui.space_mouse_view_pan_up_down": "上下パン",
"ui.space_mouse_view_roll": "ロール",
"ui.space_mouse_view_unmapped": "軸 {n}",
"ui.space_mouse_view_zoom": "ズーム",
"ui.spacing": "間隔",
"ui.spacing_2": "間隔 2",
"ui.sphere": "球",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "각도",
"ui.angle_decimals": "각도 소수점 자릿수",
"ui.animateJoint": "조인트 애니메이션",
"ui.animate_view_changes": "뷰 변경 애니메이션",
"ui.animation": "애니메이션",
"ui.animation_speed": "애니메이션 속도",
"ui.anti_aliasing": "안티앨리어싱",
"ui.apply": "적용",
"ui.arc": "호",
@@ -1551,7 +1549,6 @@
"ui.lock": "잠금",
"ui.loft": "로프트",
"ui.look_at": "바라보기",
"ui.look_at_anim_desc": "축 뷰, 보기, 홈 및 맞춤",
"ui.manipulator_free_alt": "Alt/Option으로도 매니퓰레이터 자유 드래그",
"ui.manipulator_free_alt_tip": "켬: 돌출 / 기즈모 화살표에서 Alt/Option 또는 Ctrl/Cmd를 누르면 값 스냅이 해제됩니다. 끔: Ctrl/Cmd만 해당.",
"ui.manufacture": "제조",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "부드러운 휠 확대/축소",
"ui.smooth_wheel_zoom_tip": "끄면 휠 이벤트를 부드러운 꼬리 없이 즉시 적용합니다",
"ui.snap": "스냅",
"ui.space_mouse_axes_reassigned": "축이 환경설정에서 다시 지정되어 있으며, 이 보정 후에도 적용됩니다.",
"ui.space_mouse_calibrate": "보정…",
"ui.space_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.",
"ui.space_mouse_calibrate_title": "3D 마우스 보정",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.",
"ui.space_mouse_dead_zone": "데드존",
"ui.space_mouse_dead_zone_tip": "보기가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 보기가 움직이면 높이세요.",
"ui.space_mouse_driven_by": "이 축을 움직이는 장치의 보정된 동작입니다. 다른 축이 이미 쓰는 동작을 고르면 둘이 서로 바뀝니다.",
"ui.space_mouse_driven_by_label": "제어 동작",
"ui.space_mouse_driver": "3D 마우스 드라이버",
"ui.space_mouse_driver_auto": "자동",
"ui.space_mouse_driver_direct": "직접 (hidraw)",
"ui.space_mouse_driver_only_direct": "장치를 직접 읽는 중 (spacenavd가 실행 중이 아님)",
"ui.space_mouse_driver_only_spacenavd": "spacenavd로 읽는 중 (다른 방법이 없음)",
"ui.space_mouse_forget": "보정 삭제",
"ui.space_mouse_hint": "뷰를 함께 이동하고 회전합니다. 스케치는 이동과 확대/축소만 사용합니다.",
"ui.space_mouse_motion_rotation_x": "회전 X",
"ui.space_mouse_motion_rotation_y": "회전 Y",
"ui.space_mouse_motion_rotation_z": "회전 Z",
"ui.space_mouse_motion_translation_x": "이동 X",
"ui.space_mouse_motion_translation_y": "이동 Y",
"ui.space_mouse_motion_translation_z": "이동 Z",
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse로 탐색",
"ui.space_mouse_none": "3D 마우스를 찾을 수 없음",
"ui.space_mouse_not_calibrated": "보정 안 됨",
"ui.space_mouse_reading": "읽은 값",
"ui.space_mouse_reset_axes": "축 지정 초기화",
"ui.space_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.",
"ui.space_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.",
"ui.space_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
"ui.space_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.",
"ui.space_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.",
"ui.space_mouse_view_axis": "보기 축",
"ui.space_mouse_view_off": "끔",
"ui.space_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.",
"ui.space_mouse_view_orbit_sideways": "좌우 궤도",
"ui.space_mouse_view_orbit_up_down": "상하 궤도",
"ui.space_mouse_view_pan_sideways": "좌우 이동",
"ui.space_mouse_view_pan_up_down": "상하 이동",
"ui.space_mouse_view_roll": "롤",
"ui.space_mouse_view_unmapped": "축 {n}",
"ui.space_mouse_view_zoom": "확대/축소",
"ui.spacing": "간격",
"ui.spacing_2": "간격 2",
"ui.sphere": "구",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Sudut",
"ui.angle_decimals": "Perpuluhan sudut",
"ui.animateJoint": "Animasikan Sendi",
"ui.animate_view_changes": "Animasikan perubahan pandangan",
"ui.animation": "Animasi",
"ui.animation_speed": "Kelajuan animasi",
"ui.anti_aliasing": "Antialias",
"ui.apply": "Gunakan",
"ui.arc": "Lengkok",
@@ -1551,7 +1549,6 @@
"ui.lock": "Kunci",
"ui.loft": "Loft",
"ui.look_at": "Pandang Tepat",
"ui.look_at_anim_desc": "Pandangan paksi, Pandang Tepat, Utama dan Muatkan",
"ui.manipulator_free_alt": "Seret bebas manipulator juga dengan Alt/Option",
"ui.manipulator_free_alt_tip": "Hidup: Alt/Option atau Ctrl/Cmd pada anak panah ekstrud / gizmo mematikan pelekapan nilai. Mati: Ctrl/Cmd sahaja.",
"ui.manufacture": "Pembuatan",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Hoek",
"ui.angle_decimals": "Hoekdecimalen",
"ui.animateJoint": "Verbinding animeren",
"ui.animate_view_changes": "Weergavewijzigingen animeren",
"ui.animation": "Animatie",
"ui.animation_speed": "Animatiesnelheid",
"ui.anti_aliasing": "Anti-aliasing",
"ui.apply": "Toepassen",
"ui.arc": "Boog",
@@ -1551,7 +1549,6 @@
"ui.lock": "Vergrendelen",
"ui.loft": "Loft",
"ui.look_at": "Kijken naar",
"ui.look_at_anim_desc": "Aanzicht op as, Kijken naar, Home en Passend",
"ui.manipulator_free_alt": "Manipulator vrij slepen ook met Alt/Option",
"ui.manipulator_free_alt_tip": "Aan: Alt/Option of Ctrl/Cmd op extrusie-/gizmopijlen schakelt waardesnap uit. Uit: alleen Ctrl/Cmd.",
"ui.manufacture": "Fabricage",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Kąt",
"ui.angle_decimals": "Miejsca dziesiętne kąta",
"ui.animateJoint": "Animuj złącze",
"ui.animate_view_changes": "Animuj zmiany widoku",
"ui.animation": "Animacja",
"ui.animation_speed": "Szybkość animacji",
"ui.anti_aliasing": "Wygładzanie krawędzi",
"ui.apply": "Zastosuj",
"ui.arc": "Łuk",
@@ -1551,7 +1549,6 @@
"ui.lock": "Zablokuj",
"ui.loft": "Wyciągnięcie złożone",
"ui.look_at": "Patrz na",
"ui.look_at_anim_desc": "Widok osi, Patrz na, Widok główny i Dopasuj",
"ui.manipulator_free_alt": "Swobodne przeciąganie manipulatora także z Alt/Option",
"ui.manipulator_free_alt_tip": "Wł.: Alt/Option lub Ctrl/Cmd na strzałkach wyciągnięcia / uchwytu wyłącza przyciąganie wartości. Wył.: tylko Ctrl/Cmd.",
"ui.manufacture": "Wytwarzanie",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Ângulo",
"ui.angle_decimals": "Casas decimais de ângulo",
"ui.animateJoint": "Animar junta",
"ui.animate_view_changes": "Animar mudanças de vista",
"ui.animation": "Animação",
"ui.animation_speed": "Velocidade da animação",
"ui.anti_aliasing": "Antisserrilhamento",
"ui.apply": "Aplicar",
"ui.arc": "Arco",
@@ -1551,7 +1549,6 @@
"ui.lock": "Bloquear",
"ui.loft": "Loft",
"ui.look_at": "Olhar para",
"ui.look_at_anim_desc": "Vista do eixo, Olhar para, Início e Ajustar",
"ui.manipulator_free_alt": "Arrasto livre do manipulador também com Alt/Option",
"ui.manipulator_free_alt_tip": "Ativado: Alt/Option ou Ctrl/Cmd nas setas de extrusão / gizmo desativa o ajuste de valores. Desativado: somente Ctrl/Cmd.",
"ui.manufacture": "Fabricação",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Zoom suave com a roda",
"ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização",
"ui.snap": "Encaixe",
"ui.space_mouse_axes_reassigned": "Os eixos estão reatribuídos nas Preferências, e isso continua valendo sobre esta calibração.",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.",
"ui.space_mouse_calibrate_title": "Calibrar mouse 3D",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.",
"ui.space_mouse_driven_by": "Qual movimento calibrado do dispositivo controla este eixo. Escolher um que outro eixo já usa troca os dois.",
"ui.space_mouse_driven_by_label": "controlado por",
"ui.space_mouse_driver": "Driver do mouse 3D",
"ui.space_mouse_driver_auto": "Automático",
"ui.space_mouse_driver_direct": "Direto (hidraw)",
"ui.space_mouse_driver_only_direct": "Lendo o dispositivo diretamente; o spacenavd não está em execução",
"ui.space_mouse_driver_only_spacenavd": "Lendo pelo spacenavd, a única opção disponível",
"ui.space_mouse_forget": "Esquecer calibração",
"ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.",
"ui.space_mouse_motion_rotation_x": "Rotação X",
"ui.space_mouse_motion_rotation_y": "Rotação Y",
"ui.space_mouse_motion_rotation_z": "Rotação Z",
"ui.space_mouse_motion_translation_x": "Translação X",
"ui.space_mouse_motion_translation_y": "Translação Y",
"ui.space_mouse_motion_translation_z": "Translação Z",
"ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Nenhum mouse 3D encontrado",
"ui.space_mouse_not_calibrated": "Não calibrado",
"ui.space_mouse_reading": "Leitura",
"ui.space_mouse_reset_axes": "Redefinir atribuição de eixos",
"ui.space_mouse_step_lift": "Levante a tampa, segure e solte.",
"ui.space_mouse_step_press": "Pressione a tampa para baixo, segure e solte.",
"ui.space_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.",
"ui.space_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.",
"ui.space_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
"ui.space_mouse_view_axis": "Eixo da vista",
"ui.space_mouse_view_off": "Desligado",
"ui.space_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.",
"ui.space_mouse_view_orbit_sideways": "Orbitar esquerda / direita",
"ui.space_mouse_view_orbit_up_down": "Orbitar cima / baixo",
"ui.space_mouse_view_pan_sideways": "Deslocar esquerda / direita",
"ui.space_mouse_view_pan_up_down": "Deslocar cima / baixo",
"ui.space_mouse_view_roll": "Girar a vista",
"ui.space_mouse_view_unmapped": "Eixo {n}",
"ui.space_mouse_view_zoom": "Zoom",
"ui.spacing": "Espaçamento",
"ui.spacing_2": "Espaçamento 2",
"ui.sphere": "Esfera",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Unghi",
"ui.angle_decimals": "Zecimale unghi",
"ui.animateJoint": "Animă îmbinarea",
"ui.animate_view_changes": "Animă schimbările de vizualizare",
"ui.animation": "Animație",
"ui.animation_speed": "Viteza animației",
"ui.anti_aliasing": "Anti-aliasing",
"ui.apply": "Aplică",
"ui.arc": "Arc",
@@ -1551,7 +1549,6 @@
"ui.lock": "Blochează",
"ui.loft": "Loft",
"ui.look_at": "Privește spre",
"ui.look_at_anim_desc": "Vizualizare pe axă, Privește spre, Acasă și Potrivire",
"ui.manipulator_free_alt": "Tragere liberă a manipulatorului și cu Alt/Option",
"ui.manipulator_free_alt_tip": "Activat: Alt/Option sau Ctrl/Cmd pe săgețile de extrudare / manipulator dezactivează agățarea la valori. Dezactivat: doar Ctrl/Cmd.",
"ui.manufacture": "Fabricație",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Угол",
"ui.angle_decimals": "Знаков после запятой (углы)",
"ui.animateJoint": "Анимировать сопряжение",
"ui.animate_view_changes": "Анимировать смену вида",
"ui.animation": "Анимация",
"ui.animation_speed": "Скорость анимации",
"ui.anti_aliasing": "Сглаживание",
"ui.apply": "Применить",
"ui.arc": "Дуга",
@@ -1551,7 +1549,6 @@
"ui.lock": "Заблокировать",
"ui.loft": "Лофт",
"ui.look_at": "Взгляд на",
"ui.look_at_anim_desc": "Осевые виды, «Взгляд на», «Главная» и «Вписать»",
"ui.manipulator_free_alt": "Свободное перетаскивание манипулятора также по Alt/Option",
"ui.manipulator_free_alt_tip": "Вкл.: Alt/Option или Ctrl/Cmd на стрелках выдавливания / гизмо отключают привязку значений. Выкл.: только Ctrl/Cmd.",
"ui.manufacture": "Производство",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Плавное масштабирование колесом",
"ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания",
"ui.snap": "Привязка",
"ui.space_mouse_axes_reassigned": "Оси переназначены в настройках, и это действует и поверх этой калибровки.",
"ui.space_mouse_calibrate": "Калибровать…",
"ui.space_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.",
"ui.space_mouse_calibrate_title": "Калибровка 3D-мыши",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.",
"ui.space_mouse_dead_zone": "Мёртвая зона",
"ui.space_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.",
"ui.space_mouse_driven_by": "Какое откалиброванное движение устройства управляет этой осью. Если выбрать движение, которое уже использует другая ось, они поменяются местами.",
"ui.space_mouse_driven_by_label": "управляется",
"ui.space_mouse_driver": "Драйвер 3D-мыши",
"ui.space_mouse_driver_auto": "Автоматически",
"ui.space_mouse_driver_direct": "Напрямую (hidraw)",
"ui.space_mouse_driver_only_direct": "Чтение напрямую с устройства; spacenavd не запущен",
"ui.space_mouse_driver_only_spacenavd": "Чтение через spacenavd, других вариантов нет",
"ui.space_mouse_forget": "Забыть калибровку",
"ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.",
"ui.space_mouse_motion_rotation_x": "Вращение X",
"ui.space_mouse_motion_rotation_y": "Вращение Y",
"ui.space_mouse_motion_rotation_z": "Вращение Z",
"ui.space_mouse_motion_translation_x": "Смещение X",
"ui.space_mouse_motion_translation_y": "Смещение Y",
"ui.space_mouse_motion_translation_z": "Смещение Z",
"ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D-мышь не найдена",
"ui.space_mouse_not_calibrated": "Не откалибрована",
"ui.space_mouse_reading": "Показания",
"ui.space_mouse_reset_axes": "Сбросить назначение осей",
"ui.space_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.",
"ui.space_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.",
"ui.space_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.",
"ui.space_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.",
"ui.space_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.",
"ui.space_mouse_view_axis": "Ось вида",
"ui.space_mouse_view_off": "Выкл.",
"ui.space_mouse_view_off_tip": "Профиль драйвера 3D-мыши отключает эту ось, поэтому её движение не двигает вид. Если выбрать здесь движение другой оси, они поменяются местами, и это движение перестанет двигать вид.",
"ui.space_mouse_view_orbit_sideways": "Орбита влево / вправо",
"ui.space_mouse_view_orbit_up_down": "Орбита вверх / вниз",
"ui.space_mouse_view_pan_sideways": "Сдвиг влево / вправо",
"ui.space_mouse_view_pan_up_down": "Сдвиг вверх / вниз",
"ui.space_mouse_view_roll": "Крен",
"ui.space_mouse_view_unmapped": "Ось {n}",
"ui.space_mouse_view_zoom": "Масштаб",
"ui.spacing": "Интервал",
"ui.spacing_2": "Интервал 2",
"ui.sphere": "Сфера",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Vinkel",
"ui.angle_decimals": "Vinkeldecimaler",
"ui.animateJoint": "Animera led",
"ui.animate_view_changes": "Animera vyändringar",
"ui.animation": "Animation",
"ui.animation_speed": "Animationshastighet",
"ui.anti_aliasing": "Kantutjämning",
"ui.apply": "Tillämpa",
"ui.arc": "Båge",
@@ -1551,7 +1549,6 @@
"ui.lock": "Lås",
"ui.loft": "Loftning",
"ui.look_at": "Titta på",
"ui.look_at_anim_desc": "Axelvy, Titta på, Hem och Anpassa",
"ui.manipulator_free_alt": "Fri manipulatordragning även med Alt/Option",
"ui.manipulator_free_alt_tip": "På: Alt/Option eller Ctrl/Cmd på extruderings-/gizmopilar inaktiverar värdesnappning. Av: endast Ctrl/Cmd.",
"ui.manufacture": "Tillverkning",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Açı",
"ui.angle_decimals": "Açı ondalık basamakları",
"ui.animateJoint": "Bağlantıyı canlandır",
"ui.animate_view_changes": "Görünüm değişikliklerini animasyonla göster",
"ui.animation": "Animasyon",
"ui.animation_speed": "Animasyon hızı",
"ui.anti_aliasing": "Kenar yumuşatma",
"ui.apply": "Uygula",
"ui.arc": "Yay",
@@ -1551,7 +1549,6 @@
"ui.lock": "Kilitle",
"ui.loft": "Loft",
"ui.look_at": "Bak",
"ui.look_at_anim_desc": "Eksen görünümü, Bak, Başlangıç ve Sığdır",
"ui.manipulator_free_alt": "Manipülatör serbest sürükleme Alt/Option ile de",
"ui.manipulator_free_alt_tip": "Açık: ekstrüzyon / gizmo oklarında Alt/Option veya Ctrl/Cmd değer yakalamayı devre dışı bırakır. Kapalı: yalnızca Ctrl/Cmd.",
"ui.manufacture": "Üretim",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Yumuşak tekerlek yakınlaştırması",
"ui.smooth_wheel_zoom_tip": "Her tekerlek hareketini yumuşatma olmadan hemen uygulamak için kapatın",
"ui.snap": "Yakala",
"ui.space_mouse_axes_reassigned": "Eksenler Tercihler'de yeniden atanmış; bu, bu kalibrasyonun üstüne de uygulanır.",
"ui.space_mouse_calibrate": "Kalibre et…",
"ui.space_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.",
"ui.space_mouse_calibrate_title": "3B fareyi kalibre et",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.",
"ui.space_mouse_dead_zone": "Ölü bölge",
"ui.space_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.",
"ui.space_mouse_driven_by": "Bu ekseni cihazın hangi kalibre edilmiş hareketinin yönettiği. Başka bir eksenin zaten kullandığını seçerseniz ikisi yer değiştirir.",
"ui.space_mouse_driven_by_label": "kumanda eden",
"ui.space_mouse_driver": "3B fare sürücüsü",
"ui.space_mouse_driver_auto": "Otomatik",
"ui.space_mouse_driver_direct": "Doğrudan (hidraw)",
"ui.space_mouse_driver_only_direct": "Aygıt doğrudan okunuyor; spacenavd çalışmıyor",
"ui.space_mouse_driver_only_spacenavd": "spacenavd üzerinden okunuyor, tek seçenek bu",
"ui.space_mouse_forget": "Kalibrasyonu unut",
"ui.space_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.",
"ui.space_mouse_motion_rotation_x": "Dönme X",
"ui.space_mouse_motion_rotation_y": "Dönme Y",
"ui.space_mouse_motion_rotation_z": "Dönme Z",
"ui.space_mouse_motion_translation_x": "Öteleme X",
"ui.space_mouse_motion_translation_y": "Öteleme Y",
"ui.space_mouse_motion_translation_z": "Öteleme Z",
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse ile gezin",
"ui.space_mouse_none": "3B fare bulunamadı",
"ui.space_mouse_not_calibrated": "Kalibre edilmedi",
"ui.space_mouse_reading": "Okunan değer",
"ui.space_mouse_reset_axes": "Eksen atamasını sıfırla",
"ui.space_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.",
"ui.space_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.",
"ui.space_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.",
"ui.space_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.",
"ui.space_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.",
"ui.space_mouse_view_axis": "Görünüm ekseni",
"ui.space_mouse_view_off": "Kapalı",
"ui.space_mouse_view_off_tip": "3D fare sürücüsünün profili bu ekseni kapatıyor, bu yüzden hareketi görünümü oynatmaz. Burada başka bir eksenin hareketini seçerseniz ikisi yer değiştirir ve o hareket görünümü oynatmayı bırakır.",
"ui.space_mouse_view_orbit_sideways": "Sola / sağa yörünge",
"ui.space_mouse_view_orbit_up_down": "Yukarı / aşağı yörünge",
"ui.space_mouse_view_pan_sideways": "Sola / sağa kaydır",
"ui.space_mouse_view_pan_up_down": "Yukarı / aşağı kaydır",
"ui.space_mouse_view_roll": "Yuvarlanma",
"ui.space_mouse_view_unmapped": "Eksen {n}",
"ui.space_mouse_view_zoom": "Yakınlaştır",
"ui.spacing": "Aralık",
"ui.spacing_2": "Aralık 2",
"ui.sphere": "Küre",
+25 -3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Кут",
"ui.angle_decimals": "Знаки після коми для кутів",
"ui.animateJoint": "Анімувати з'єднання",
"ui.animate_view_changes": "Анімувати зміни виду",
"ui.animation": "Анімація",
"ui.animation_speed": "Швидкість анімації",
"ui.anti_aliasing": "Згладжування",
"ui.apply": "Застосувати",
"ui.arc": "Дуга",
@@ -1551,7 +1549,6 @@
"ui.lock": "Заблокувати",
"ui.loft": "Лофт",
"ui.look_at": "Наведення",
"ui.look_at_anim_desc": "Вид осі, наведення, головний і вписування",
"ui.manipulator_free_alt": "Вільне перетягування маніпулятора також з Alt/Option",
"ui.manipulator_free_alt_tip": "Увімк.: Alt/Option або Ctrl/Cmd на стрілках видавлювання / гізмо вимикає прив'язку до значень. Вимк.: лише Ctrl/Cmd.",
"ui.manufacture": "Виготовлення",
@@ -1737,6 +1734,7 @@
"ui.smooth_wheel_zoom": "Плавне масштабування колесом",
"ui.smooth_wheel_zoom_tip": "Вимкніть, щоб застосовувати кожну подію колеса негайно, без плавного завершення",
"ui.snap": "Прив'язка",
"ui.space_mouse_axes_reassigned": "Осі перепризначено в налаштуваннях, і це діє й поверх цього калібрування.",
"ui.space_mouse_calibrate": "Калібрувати…",
"ui.space_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.",
"ui.space_mouse_calibrate_title": "Калібрування 3D-миші",
@@ -1746,12 +1744,26 @@
"ui.space_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.",
"ui.space_mouse_dead_zone": "Мертва зона",
"ui.space_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.",
"ui.space_mouse_driven_by": "Який відкалібрований рух пристрою керує цією віссю. Якщо вибрати рух, який уже використовує інша вісь, вони поміняються місцями.",
"ui.space_mouse_driven_by_label": "керується",
"ui.space_mouse_driver": "Драйвер 3D-миші",
"ui.space_mouse_driver_auto": "Автоматично",
"ui.space_mouse_driver_direct": "Напряму (hidraw)",
"ui.space_mouse_driver_only_direct": "Читання напряму з пристрою; spacenavd не запущено",
"ui.space_mouse_driver_only_spacenavd": "Читання через spacenavd, інших варіантів немає",
"ui.space_mouse_forget": "Забути калібрування",
"ui.space_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.",
"ui.space_mouse_motion_rotation_x": "Обертання X",
"ui.space_mouse_motion_rotation_y": "Обертання Y",
"ui.space_mouse_motion_rotation_z": "Обертання Z",
"ui.space_mouse_motion_translation_x": "Зміщення X",
"ui.space_mouse_motion_translation_y": "Зміщення Y",
"ui.space_mouse_motion_translation_z": "Зміщення Z",
"ui.space_mouse_navigate": "Навігація за допомогою SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D-мишу не знайдено",
"ui.space_mouse_not_calibrated": "Не відкалібровано",
"ui.space_mouse_reading": "Показники",
"ui.space_mouse_reset_axes": "Скинути призначення осей",
"ui.space_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.",
"ui.space_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.",
"ui.space_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.",
@@ -1765,6 +1777,16 @@
"ui.space_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.",
"ui.space_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.",
"ui.space_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.",
"ui.space_mouse_view_axis": "Вісь вигляду",
"ui.space_mouse_view_off": "Вимк.",
"ui.space_mouse_view_off_tip": "Профіль драйвера 3D-миші вимикає цю вісь, тож її рух не рухає вигляд. Якщо вибрати тут рух іншої осі, вони поміняються місцями, і цей рух перестане рухати вигляд.",
"ui.space_mouse_view_orbit_sideways": "Орбіта ліворуч / праворуч",
"ui.space_mouse_view_orbit_up_down": "Орбіта вгору / вниз",
"ui.space_mouse_view_pan_sideways": "Зсув ліворуч / праворуч",
"ui.space_mouse_view_pan_up_down": "Зсув угору / вниз",
"ui.space_mouse_view_roll": "Крен",
"ui.space_mouse_view_unmapped": "Вісь {n}",
"ui.space_mouse_view_zoom": "Масштаб",
"ui.spacing": "Крок",
"ui.spacing_2": "Крок 2",
"ui.sphere": "Куля",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "Góc",
"ui.angle_decimals": "Số chữ số thập phân cho góc",
"ui.animateJoint": "Mô phỏng chuyển động khớp nối",
"ui.animate_view_changes": "Hiệu ứng chuyển góc nhìn",
"ui.animation": "Hoạt họa",
"ui.animation_speed": "Tốc độ chuyển động",
"ui.anti_aliasing": "Khử răng cưa",
"ui.apply": "Áp dụng",
"ui.arc": "Cung tròn",
@@ -1551,7 +1549,6 @@
"ui.lock": "Khóa",
"ui.loft": "Vuốt nối biên dạng (Loft)",
"ui.look_at": "Nhìn trực diện",
"ui.look_at_anim_desc": "Góc nhìn theo trục, Nhìn thẳng, Chuẩn và Vừa vặn",
"ui.manipulator_free_alt": "Kéo tự do bộ thao tác cũng bằng Alt/Option",
"ui.manipulator_free_alt_tip": "Bật: Alt/Option hoặc Ctrl/Cmd trên các mũi tên đùn / gizmo sẽ tắt bước nhảy giá trị. Tắt: chỉ Ctrl/Cmd.",
"ui.manufacture": "Gia công chế tạo",
-3
View File
@@ -1287,9 +1287,7 @@
"ui.angle": "角度",
"ui.angle_decimals": "角度小數位數",
"ui.animateJoint": "動畫接頭",
"ui.animate_view_changes": "檢視變化動畫",
"ui.animation": "動畫",
"ui.animation_speed": "動畫速度",
"ui.anti_aliasing": "抗鋸齒",
"ui.apply": "套用",
"ui.arc": "圓弧",
@@ -1551,7 +1549,6 @@
"ui.lock": "鎖定",
"ui.loft": "放樣",
"ui.look_at": "注視",
"ui.look_at_anim_desc": "軸檢視、注視、主檢視與適合視窗",
"ui.manipulator_free_alt": "操縱器自由拖曳也適用於 Alt/Option",
"ui.manipulator_free_alt_tip": "開:在擠出/小工具箭頭上按 Alt/Option 或 Ctrl/Cmd 可停用數值鎖點。關:僅 Ctrl/Cmd。",
"ui.manufacture": "製造",
+25 -3
View File
@@ -1295,9 +1295,7 @@
"ui.angle": "角度",
"ui.angle_decimals": "角度小数位",
"ui.animateJoint": "动画接头",
"ui.animate_view_changes": "视图变化动画",
"ui.animation": "动画",
"ui.animation_speed": "动画速度",
"ui.anti_aliasing": "抗锯齿",
"ui.apply": "应用",
"ui.arc": "圆弧",
@@ -1559,7 +1557,6 @@
"ui.lock": "锁定",
"ui.loft": "放样",
"ui.look_at": "朝向",
"ui.look_at_anim_desc": "轴视图、注视、主视图与适合窗口",
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
"ui.manufacture": "制造",
@@ -1745,6 +1742,7 @@
"ui.smooth_wheel_zoom": "平滑滚轮缩放",
"ui.smooth_wheel_zoom_tip": "关闭后每个滚轮事件立即生效,不做平滑延迟",
"ui.snap": "捕捉",
"ui.space_mouse_axes_reassigned": "轴已在偏好设置中重新分配,在此次校准之上仍然生效。",
"ui.space_mouse_calibrate": "校准…",
"ui.space_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。",
"ui.space_mouse_calibrate_title": "校准 3D 鼠标",
@@ -1754,12 +1752,26 @@
"ui.space_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。",
"ui.space_mouse_dead_zone": "死区",
"ui.space_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。",
"ui.space_mouse_driven_by": "由设备的哪个校准动作控制此轴。选择另一个轴已在使用的动作时,两者会互换。",
"ui.space_mouse_driven_by_label": "控制来源",
"ui.space_mouse_driver": "3D 鼠标驱动",
"ui.space_mouse_driver_auto": "自动",
"ui.space_mouse_driver_direct": "直接 (hidraw)",
"ui.space_mouse_driver_only_direct": "直接读取设备(spacenavd 未运行)",
"ui.space_mouse_driver_only_spacenavd": "通过 spacenavd 读取(没有其他方式)",
"ui.space_mouse_forget": "清除校准",
"ui.space_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。",
"ui.space_mouse_motion_rotation_x": "旋转 X",
"ui.space_mouse_motion_rotation_y": "旋转 Y",
"ui.space_mouse_motion_rotation_z": "旋转 Z",
"ui.space_mouse_motion_translation_x": "平移 X",
"ui.space_mouse_motion_translation_y": "平移 Y",
"ui.space_mouse_motion_translation_z": "平移 Z",
"ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 导航",
"ui.space_mouse_none": "未找到 3D 鼠标",
"ui.space_mouse_not_calibrated": "未校准",
"ui.space_mouse_reading": "读数",
"ui.space_mouse_reset_axes": "重置轴分配",
"ui.space_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。",
@@ -1773,6 +1785,16 @@
"ui.space_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。",
"ui.space_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。",
"ui.space_mouse_view_axis": "视图轴",
"ui.space_mouse_view_off": "关闭",
"ui.space_mouse_view_off_tip": "3D 鼠标驱动程序的配置文件关闭了此轴,因此它的动作不会移动视图。在此选择另一个轴的动作会将两者互换,该动作将不再移动视图。",
"ui.space_mouse_view_orbit_sideways": "左右环绕",
"ui.space_mouse_view_orbit_up_down": "上下环绕",
"ui.space_mouse_view_pan_sideways": "左右平移",
"ui.space_mouse_view_pan_up_down": "上下平移",
"ui.space_mouse_view_roll": "滚转",
"ui.space_mouse_view_unmapped": "轴 {n}",
"ui.space_mouse_view_zoom": "缩放",
"ui.spacing": "间距",
"ui.spacing_2": "间距 2",
"ui.sphere": "球体",
@@ -444,6 +444,8 @@ fn a_design_saved_before_pieces_were_numbered_by_place_keeps_their_numbers() {
if let Some(Value::Array(pending)) = file.get_mut("format_pending") {
pending.retain(|c| c != "face-pieces-by-place");
}
// 0.18.3 folded the change into format 29, so a build before it wrote 28.
file["format"] = Value::String(solidcraft_doc::format::format_string(28));
let old = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
assert!(old.features.iter().all(|f| f.kernel_piece_order), "the features of an older design keep the old numbering");
let mut reopened = Session::new(old);
+1 -112
View File
@@ -72,13 +72,6 @@ impl StandardView {
}
}
/// Smooth ease-in-out on [0, 1] (smoothstep: at its fastest, mid-way, 1.5 times the average
/// speed, so long turns stay gentle).
pub fn ease_in_out(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
t * t * (3.0 - 2.0 * t)
}
/// A unit quaternion (w, x, y, z).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quat {
@@ -151,36 +144,6 @@ impl Quat {
}
}
/// A camera move in progress: from one camera to another over `duration` seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraAnim {
pub from: Camera,
pub to: Camera,
pub start: f64,
pub duration: f64,
}
impl CameraAnim {
/// The usual length of a view change, in seconds.
pub const DURATION: f64 = 0.5;
/// A view change: half a second, longer for turns past a quarter turn (so a big turn, such
/// as from the home view to the bottom, never sweeps more than about 9 degrees a frame).
pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim {
let dy = (to.yaw - from.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let turn = dy.abs().max((to.pitch - from.pitch).abs());
let k = if turn.is_finite() { (turn / std::f64::consts::FRAC_PI_2).clamp(1.0, 2.0) } else { 1.0 };
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION * k }
}
/// The camera at time `now`, and whether the move has finished.
pub fn sample(&self, now: f64) -> (Camera, bool) {
let t = if self.duration > 0.0 { (now - self.start) / self.duration } else { 1.0 };
if !(t < 1.0) {
return (self.to, true);
}
(Camera::interpolate(&self.from, &self.to, ease_in_out(t)), false)
}
}
/// Which world axis points up in the view (#3): Z (the default) or Y, as most STEP files from
/// other systems are modelled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -517,29 +480,13 @@ mod tests {
}
#[test]
fn view_animation_interpolates() {
fn orientation_round_trips_and_looks_from_a_corner() {
let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() };
a.set_view(StandardView::Iso);
let mut b = Camera { target: Vec3::new(-5.0, 0.0, 10.0), distance: 120.0, ..Default::default() };
b.set_view(StandardView::Top);
let anim = CameraAnim::new(a, b, 10.0);
// Endpoints are exact.
assert_eq!(anim.sample(10.0).0, a);
assert_eq!(anim.sample(10.0 + anim.duration), (b, true));
assert_eq!(anim.sample(99.0), (b, true));
// Midpoint: half way in heading, elevation, target and distance.
let (m, done) = anim.sample(10.0 + anim.duration / 2.0);
assert!(!done && m.is_valid());
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12);
assert!(m.target.dist(Vec3::new(-2.0, 1.0, 6.5)) < 1e-9 && (m.distance - 100.0).abs() < 1e-9);
// Quaternion round trip of a basis.
let (r, u, bk) = a.basis();
let (r2, u2, b2) = a.orientation().basis();
assert!(r.dist(r2) < 1e-12 && u.dist(u2) < 1e-12 && bk.dist(b2) < 1e-12);
// Ease: fixed ends, symmetric, monotonic.
assert_eq!((ease_in_out(0.0), ease_in_out(1.0), ease_in_out(0.5)), (0.0, 1.0, 0.5));
assert!(ease_in_out(0.25) < 0.25 && ease_in_out(0.75) > 0.75);
// Looking from a corner direction.
let c = a.looking_from(Vec3::new(1.0, -1.0, 1.0));
assert!(c.back().dist(Vec3::new(1.0, -1.0, 1.0).normalized().unwrap()) < 1e-9);
@@ -574,64 +521,6 @@ mod tests {
assert!(c.is_valid());
}
/// A view change sampled every frame (60 fps) moves smoothly to the very end: no frame
/// turns the view, moves the target or changes the distance by much more than the average
/// step, and the last frame lands exactly on the target camera.
#[test]
fn view_animations_have_no_jumps() {
let base = Camera { target: Vec3::new(5.0, -3.0, 2.0), distance: 150.0, ..Default::default() };
let views = [
StandardView::Iso,
StandardView::Top,
StandardView::Front,
StandardView::Right,
StandardView::Bottom,
StandardView::Back,
StandardView::Left,
];
let mut cams: Vec<Camera> = views
.iter()
.map(|v| {
let mut c = base;
c.set_view(*v);
c
})
.collect();
// Corners and edges of the axis view, and a fit (new target and distance).
cams.push(base.looking_from(Vec3::new(1.0, -1.0, 1.0)));
cams.push(base.looking_from(Vec3::new(-1.0, 0.0, 1.0)));
cams.push(base.looking_from(Vec3::new(0.0, 0.0, 1.0)));
cams.push(Camera { target: Vec3::new(-40.0, 10.0, 0.0), distance: 60.0, ..base });
for a in &cams {
for b in &cams {
let anim = CameraAnim::new(*a, *b, 0.0);
let frames = (anim.duration * 60.0).round() as usize;
let mut prev = anim.sample(0.0).0;
let (mut steps, mut done_at) = (Vec::new(), None);
for f in 1..=frames + 2 {
let (c, done) = anim.sample(f as f64 / 60.0);
let turn = c.back().dist(prev.back()) + c.basis().0.dist(prev.basis().0);
let moved =
c.target.dist(prev.target) / a.distance.max(b.distance) + (c.distance - prev.distance).abs() / a.distance.max(b.distance);
steps.push((f, turn, moved));
prev = c;
if done && done_at.is_none() {
done_at = Some(f);
assert_eq!(c, *b);
}
}
assert!(done_at.is_some_and(|f| f >= frames), "ended early at {done_at:?}");
let total_turn: f64 = steps.iter().map(|s| s.1).sum();
let total_move: f64 = steps.iter().map(|s| s.2).sum();
for (f, turn, moved) in &steps {
// The eased curve's fastest frame is 1.5x the average: allow 2x.
assert!(*turn <= 2.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
assert!(*moved <= 2.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
}
}
}
}
/// With Y up: the front view looks along −Z (eye on +Z), the top view looks down −Y with X
/// to the right, orbiting turns about Y, and the picture's up is the world's up.
#[test]
+1 -1
View File
@@ -27,7 +27,7 @@ pub use backend::{
RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend,
resolve_request,
};
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use camera::{Camera, Mat4, Quat, StandardView, UpAxis};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use gif::gif;
pub use product::{FaceAppearance, ProductMesh, ProductScene};
-26
View File
@@ -386,15 +386,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
}
"ui.view" => {
let v = s("view").unwrap_or("home");
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
if p.get("animate").and_then(Value::as_bool).unwrap_or(false) {
if v != "fit" && v != "home" && StandardView::parse(v).is_none() {
return err(format!("unknown view `{v}`"));
}
app.animate_view(v);
return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) }));
}
app.cancel_view_animation();
match v {
"fit" => app.fit_view(),
@@ -887,7 +879,6 @@ fn camera(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
return serde_json::to_value(app.cam).map_err(|e| e.to_string());
}
app.cancel_view_animation();
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.cam = cam;
match fit {
@@ -1416,23 +1407,6 @@ mod tests {
let mut h = crate::scenario::Harness::new();
h.app = SolveApp::new(s, Services::default());
h.frames(2);
let before = h.app.cam;
// Reading during an animated view change leaves it running (no frames run between).
fn raw(h: &mut crate::scenario::Harness, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
match handle(&mut h.app, &h.ctx, &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => Value::Null,
}
}
raw(&mut h, "ui.view", json!({"view": "top", "animate": true}));
let r = raw(&mut h, "ui.camera", Value::Null);
assert!(r["ok"] == true && h.app.cam_anim.is_some(), "{r}");
// Setting it, even to where it is now, ends the animation there.
let yaw = h.app.cam.yaw;
let r = raw(&mut h, "ui.camera", json!({ "yaw": yaw }));
assert!(r["ok"] == true && h.app.cam_anim.is_none(), "{r}");
h.app.cam = before;
let mut cam = |p: Value| {
let r = h.call("ui.camera", p);
assert_eq!(r["ok"], true, "{r}");
+2 -3
View File
@@ -2780,9 +2780,8 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
look_at_sketch(app);
// On a face: look at the picked point, not the plane's origin (a corner of the face),
// which left the face filling the view off-centre like a big square.
if let Sel::Face { point, .. } = sel
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
{
if let Sel::Face { point, .. } = sel {
let mut to = app.cam;
to.target = *point;
app.animate_to(to);
}
-5
View File
@@ -32,7 +32,6 @@ pub struct DocSlot {
#[derive(Clone, Debug, Default)]
struct DocView {
render: crate::render_workspace::ViewState,
sketch_choices: crate::sketch_families::Choices,
hidden_profiles: Vec<u64>,
shown_dims: Vec<u64>,
locked_bodies: Vec<String>,
@@ -87,7 +86,6 @@ fn take_view(app: &mut SolveApp) -> DocView {
let u = &mut app.ui;
DocView {
render: std::mem::take(&mut app.render_workspace.view),
sketch_choices: std::mem::take(&mut app.sketch_choices),
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
shown_dims: std::mem::take(&mut u.shown_dims),
locked_bodies: std::mem::take(&mut u.locked_bodies),
@@ -98,7 +96,6 @@ fn put_view(app: &mut SolveApp, v: DocView) {
app.render_workspace.view = v.render;
crate::render_workspace::invalidate_scene(app);
crate::render_workspace::sync_backend(app);
app.sketch_choices = v.sketch_choices;
let u = &mut app.ui;
u.hidden_profiles = v.hidden_profiles;
u.shown_dims = v.shown_dims;
@@ -119,7 +116,6 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
app.design_properties = None;
app.bom = None;
app.cancel_view_animation();
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.preview.reset();
app.viewport.hover = None;
@@ -201,7 +197,6 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
crate::sketch_attachment_ui::clear(app);
app.session.replace_design(session);
crate::render_workspace::reset_document(app);
app.sketch_choices = crate::sketch_families::Choices::default();
app.session.invalidate_view(rev);
app.cam = Camera::default();
app.fit_view();
+99 -5
View File
@@ -97,7 +97,7 @@ fn install(ctx: &egui::Context, fonts: FontDefinitions) {
}
/// Install only the bundled definitions (also used by the offscreen scenario shell).
pub(crate) fn setup_bundled_fonts(ctx: &egui::Context) {
pub fn setup_bundled_fonts(ctx: &egui::Context) {
install(ctx, base_font_definitions());
}
@@ -659,10 +659,75 @@ pub fn fallback_order(language: &str) -> [&'static str; 4] {
/// when one is chosen (see [`ui_font`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) {
let cjk = find_system_cjk_font();
let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes));
let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path)));
let thai = THAI_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path)));
install_fonts(ctx, language, ui_font, Some(system_fallbacks()));
}
/// The system fallback faces, searched for once and kept: reading them takes long on a system
/// with large CJK fonts, so the app starts that search on a thread and draws its first frame
/// with the bundled faces (see [`load_system_fonts_in_background`]).
#[cfg(not(target_arch = "wasm32"))]
struct SystemFonts {
cjk: Option<Vec<u8>>,
chinese: Option<Vec<u8>>,
hangul: Option<Vec<u8>>,
thai: Option<Vec<u8>>,
}
#[cfg(not(target_arch = "wasm32"))]
static SYSTEM_FONTS: std::sync::OnceLock<SystemFonts> = std::sync::OnceLock::new();
#[cfg(not(target_arch = "wasm32"))]
fn system_fallbacks() -> &'static SystemFonts {
SYSTEM_FONTS.get_or_init(|| {
let cjk = find_system_cjk_font();
let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes));
let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path)));
let thai = THAI_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path)));
SystemFonts { cjk, chinese, hangul, thai }
})
}
/// Whether the system fallback faces have been searched for.
#[cfg(not(target_arch = "wasm32"))]
pub fn system_fonts_ready() -> bool {
SYSTEM_FONTS.get().is_some()
}
/// Search for the system fallback faces on a thread and repaint when they are found.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts_in_background(ctx: &egui::Context) {
static STARTED: std::sync::Once = std::sync::Once::new();
if SYSTEM_FONTS.get().is_some() {
return;
}
let mut first = false;
STARTED.call_once(|| first = true);
if !first {
return;
}
let ctx = ctx.clone();
let spawned = std::thread::Builder::new().name("system-fonts".into()).spawn(move || {
system_fallbacks();
ctx.request_repaint();
});
// Without a thread the first frame waits for them, as it always did.
if spawned.is_err() {
system_fallbacks();
}
}
/// [`setup_fonts_for`] with the system fallbacks that are there already, never waiting for the search.
#[cfg(not(target_arch = "wasm32"))]
pub fn setup_fonts_without_waiting(ctx: &egui::Context, language: &str, ui_font: &str) {
install_fonts(ctx, language, ui_font, SYSTEM_FONTS.get());
}
#[cfg(not(target_arch = "wasm32"))]
fn install_fonts(ctx: &egui::Context, language: &str, ui_font: &str, system: Option<&SystemFonts>) {
let (cjk, chinese, hangul, thai) = match system {
Some(f) => (f.cjk.clone(), f.chinese.clone(), f.hangul.clone(), f.thai.clone()),
None => (None, None, None, None),
};
let mut fonts = base_font_definitions();
if let Some(font) = self::ui_font(ui_font) {
fonts = with_ui_font(fonts, font);
@@ -769,6 +834,19 @@ fn find_chinese_font_in(dirs: &[std::path::PathBuf]) -> Option<Vec<u8>> {
/// Web installs the bundled faces synchronously; Japanese is fetched by the shell on demand.
/// Preserve that downloaded face when preferences change later in the session.
#[cfg(target_arch = "wasm32")]
pub fn load_system_fonts_in_background(_ctx: &egui::Context) {}
#[cfg(target_arch = "wasm32")]
pub fn system_fonts_ready() -> bool {
true
}
#[cfg(target_arch = "wasm32")]
pub fn setup_fonts_without_waiting(ctx: &egui::Context, language: &str, ui_font: &str) {
setup_fonts_for(ctx, language, ui_font);
}
#[cfg(target_arch = "wasm32")]
pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) {
let previous = ctx.data(|d| d.get_temp::<FontDefinitions>(installed_fonts_id()));
@@ -812,6 +890,22 @@ pub(crate) fn test_context() -> egui::Context {
mod tests {
use super::*;
/// The system faces are searched for on a thread; installing fonts before they are found does
/// not wait for the search and still gives the bundled faces.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn system_fonts_load_in_the_background_and_installing_does_not_wait() {
let ctx = egui::Context::default();
setup_fonts_without_waiting(&ctx, "en", "");
assert!(ctx.data(|d| d.get_temp::<FontDefinitions>(installed_fonts_id())).is_some());
load_system_fonts_in_background(&ctx);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !system_fonts_ready() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(system_fonts_ready());
}
/// Whether a face of the Proportional family has `c`. Checked in the font data:
/// `Fonts::has_glyph` misses a glyph in the face that also gives the replacement glyph.
fn proportional_draws(fonts: &egui::FontDefinitions, c: char) -> bool {
+84 -9
View File
@@ -47,9 +47,29 @@ pub struct HomeState {
remembering: Option<std::sync::mpsc::Receiver<Made>>,
/// A sample picked before its design was ready.
wanted: Option<usize>,
/// Whether each recent file is there, found off the window thread (a stat of a slow or
/// disconnected drive must not hold up the page); unknown counts as there.
present: HashMap<String, bool>,
#[cfg(not(target_arch = "wasm32"))]
present_asked: std::collections::HashSet<String>,
#[cfg(not(target_arch = "wasm32"))]
present_rx: Vec<std::sync::mpsc::Receiver<(String, bool)>>,
}
impl HomeState {
/// Wait for the background check of the recent files to answer (tests only).
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn wait_for_existence_checks(&mut self) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.recent.iter().any(|p| !self.present.contains_key(p)) && std::time::Instant::now() < deadline {
for rx in &self.present_rx {
while let Ok((p, there)) = rx.try_recv() {
self.present.insert(p, there);
}
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
pub fn ready_recents(&self) -> usize {
self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count()
}
@@ -260,6 +280,10 @@ fn make(key: &str) -> Made {
fn read_pictures(keys: Vec<String>, tx: &std::sync::mpsc::Sender<Made>) -> Option<Vec<String>> {
let mut cold = Vec::new();
for key in keys {
if let Some(i) = key.strip_prefix("samplepic:").and_then(|i| i.parse::<usize>().ok()) {
tx.send((format!("sample:{i}"), sample_picture(i), None)).ok()?;
continue;
}
match std::panic::catch_unwind(|| picture(&key)).ok().flatten() {
Some(img) => tx.send((key, Some(img), None)).ok()?,
None => cold.push(key),
@@ -377,7 +401,8 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) {
ctx.request_repaint();
}
for (key, img, session) in done {
if session.is_none() && key.starts_with("sample:") && app.home.wanted.is_some_and(|w| key == format!("sample:{w}")) {
// A picture sent ahead of the build has no session either; only a build with nothing failed.
if session.is_none() && img.is_none() && key.starts_with("sample:") && app.home.wanted.is_some_and(|w| key == format!("sample:{w}")) {
app.home.wanted = None;
app.set_status(format!("the sample could not be built ({key})"), true);
}
@@ -427,7 +452,7 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
/// Is this recent design there to open: in memory, or something the host can bring back?
pub fn available(app: &SolveApp, path: &str) -> bool {
solidcraft_engine::io::vfs::exists(path) || app.services.can_restore.as_ref().is_some_and(|can| can(path))
app.home.present.get(path).copied().unwrap_or(true) || app.services.can_restore.as_ref().is_some_and(|can| can(path))
}
pub fn open_recent(app: &mut SolveApp, path: &str) {
@@ -443,6 +468,39 @@ pub fn open_recent(app: &mut SolveApp, path: &str) {
app.open_path_async(path);
}
/// Find out whether the recent files are there: on a thread, so the page never waits on a drive.
fn ask_present(h: &mut HomeState, ctx: &egui::Context, recent: &[String]) {
#[cfg(not(target_arch = "wasm32"))]
{
let new: Vec<String> = recent.iter().filter(|p| h.present_asked.insert((*p).clone())).cloned().collect();
if !new.is_empty() {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let spawned = std::thread::Builder::new().name("recent-files".into()).spawn(move || {
for p in new {
let there = solidcraft_engine::io::vfs::exists(&p);
if tx.send((p, there)).is_err() {
return;
}
ctx.request_repaint();
}
});
if spawned.is_ok() {
h.present_rx.push(rx);
}
}
for rx in &h.present_rx {
while let Ok((p, there)) = rx.try_recv() {
h.present.insert(p, there);
}
}
}
#[cfg(target_arch = "wasm32")]
for p in recent {
h.present.insert(p.clone(), solidcraft_engine::io::vfs::exists(p));
}
}
/// The page itself (in place of the toolbar, browser and viewport).
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
poll(app, ui.ctx());
@@ -453,12 +511,23 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
for i in 0..samples.len() {
let key = format!("sample:{i}");
if !app.home.thumbs.contains_key(&key) {
// Decoded on the thumbnail thread where there is one; the card shows its placeholder
// until the picture arrives.
#[cfg(not(target_arch = "wasm32"))]
{
app.home.queued.push(format!("samplepic:{i}"));
app.home.thumbs.insert(key, None);
}
#[cfg(target_arch = "wasm32")]
install(&mut app.home, ui.ctx(), &key, sample_picture(i));
}
}
let recent = app.home.recent.clone();
for p in recent.iter().filter(|p| solidcraft_engine::io::vfs::exists(p)) {
want(&mut app.home, p);
ask_present(&mut app.home, ui.ctx(), &recent);
for p in &recent {
if app.home.present.get(p).copied().unwrap_or(true) {
want(&mut app.home, p);
}
}
let mut action: Option<Box<dyn FnOnce(&mut SolveApp)>> = None;
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| {
@@ -717,6 +786,9 @@ mod tests {
h.app.services.can_restore = Some(Box::new(|p| p.starts_with("/disk/known/")));
h.app.services.restore_file = Some(Box::new(move |p| sink.borrow_mut().push(p.to_string())));
assert!(available(&h.app, "/disk/known/Bracket.solidcraft"));
// A file is taken to be there until the background check says it is not.
assert!(available(&h.app, "/disk/other/Bracket.solidcraft"));
h.app.home.present.insert("/disk/other/Bracket.solidcraft".into(), false);
assert!(!available(&h.app, "/disk/other/Bracket.solidcraft"));
open_recent(&mut h.app, "/disk/known/Bracket.solidcraft");
assert_eq!(*asked.borrow(), ["/disk/known/Bracket.solidcraft"]);
@@ -870,15 +942,18 @@ mod tests {
}
#[test]
fn first_home_frame_shows_all_samples_without_building_any_models() {
fn first_home_frame_shows_placeholders_and_the_sample_pictures_arrive_without_building_any_model() {
let mut h = crate::scenario::Harness::new();
h.app.home.show(0);
h.frames(1);
assert!(h.app.home.ready_samples() < solidcraft_engine::sample::samples().len(), "the first frame decodes nothing");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.app.home.ready_samples() < solidcraft_engine::sample::samples().len() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
h.frames(1);
}
assert_eq!(h.app.home.ready_samples(), solidcraft_engine::sample::samples().len());
assert!(h.app.home.samples.is_empty());
assert!(h.app.home.queued.is_empty());
#[cfg(not(target_arch = "wasm32"))]
assert!(h.app.home.sample_rx.is_none() && h.app.home.rx.is_none());
assert!(h.app.home.samples.is_empty(), "no sample model was built");
}
#[cfg(not(target_arch = "wasm32"))]
+2
View File
@@ -197,6 +197,8 @@ fn home_page_renders_in_the_active_language() {
let rev = h.app.session.revision;
h.app.home.show(rev);
h.frames(2);
// The recent file counts as there until the background check says it is not.
h.app.home.wait_for_existence_checks();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let out = crate::test_support::frame(&h.ctx, egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui));
let mut texts = Vec::new();
-1
View File
@@ -642,7 +642,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.boxsel = None;
app.viewport.axis_drag = None;
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
crate::dim_view::drag_end();
crate::sketch3d::drag_end();
crate::drag_snap::clear();
+2 -2
View File
@@ -116,11 +116,11 @@ pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> {
/// starts in the new sketch.
pub fn press(app: &mut SolveApp, key: &str) {
if let Some(id) = command_for(app, key) {
app.start(id);
app.start(app.preferences.sketch_variants.resolve(id));
} else if let Some(id) = sketch_tool_for(app, key) {
app.start("sketch.create");
if let Some(d) = app.dialog.as_mut().filter(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)) {
d.then_start = Some(id);
d.then_start = Some(app.preferences.sketch_variants.resolve(id));
}
}
}
+19 -46
View File
@@ -238,7 +238,7 @@ use std::sync::mpsc::Receiver;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::render::{Camera, CameraAnim, StandardView};
use solidcraft_engine::render::{Camera, StandardView};
pub use control::{ControlRequest, ControlResponse};
@@ -353,8 +353,6 @@ pub struct SolveApp {
pub ui: UiState,
pub(crate) shell: shell::State,
pub cam: Camera,
/// A view change in progress (axis view, Home, Fit); any mouse navigation cancels it.
pub cam_anim: Option<CameraAnim>,
/// Seconds since start, as of this frame.
pub now: f64,
/// The view before the current sketch started (Finish Sketch returns to it).
@@ -403,7 +401,6 @@ pub struct SolveApp {
pub install_steps: Option<install::Steps>,
/// Toolbar buttons the user pinned, removed or reordered.
pub toolbar_custom: toolbar_custom::ToolbarCustom,
pub sketch_choices: sketch_families::Choices,
/// Preferences kept between runs, and their window.
pub preferences: prefs::Prefs,
pub prefs_window: prefs::PrefsWindow,
@@ -504,6 +501,8 @@ pub struct SolveApp {
applied_fonts_language: String,
/// The interface font the fonts were last set up with (`Prefs::ui_font`).
applied_ui_font: String,
/// The fonts installed include the system's fallback faces.
applied_system_fonts: bool,
}
impl SolveApp {
@@ -522,7 +521,6 @@ impl SolveApp {
ui: UiState::default(),
shell: Default::default(),
cam: Camera::default(),
cam_anim: None,
now: 0.0,
pre_sketch_cam: None,
pre_sketch_marker: None,
@@ -550,7 +548,6 @@ impl SolveApp {
updater: update::Updater::default(),
install_steps: None,
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
sketch_choices: sketch_families::Choices::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),
slicer_menu: dialogs_save_mesh::SlicerMenu::default(),
@@ -605,6 +602,7 @@ impl SolveApp {
docs: documents::Documents::default(),
applied_fonts_language: "en".into(),
applied_ui_font: String::new(),
applied_system_fonts: false,
}
}
@@ -795,7 +793,6 @@ impl SolveApp {
}
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
render_workspace::reset_document(self);
self.sketch_choices = sketch_families::Choices::default();
dim_view::cancel();
self.menu.props = None;
sketch_attachment_ui::clear(self);
@@ -933,7 +930,7 @@ impl SolveApp {
return;
}
if solidcraft_engine::find_command(id).is_some_and(|c| c.info(&self.session).enabled) {
self.sketch_choices.remember(id);
self.preferences.sketch_variants.remember(id);
}
if id == "parameters.change" {
params_dialog::open(self);
@@ -1202,7 +1199,6 @@ impl SolveApp {
/// (home) and frames the model.
pub fn set_up_axis(&mut self, up: solidcraft_engine::render::UpAxis) {
self.ui.up_axis = up;
self.cam_anim = None;
self.cam.up = up;
self.cam.set_view(StandardView::Iso);
self.fit_view();
@@ -1222,8 +1218,6 @@ impl SolveApp {
}
return;
}
self.cam_anim = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
let bounds = viewport::view_bounds(self);
self.pending_fit = Some((self.cam, bounds, false));
@@ -1233,7 +1227,6 @@ impl SolveApp {
/// Frame `bounds` at once, keeping the view direction (programmatic use).
pub fn fit_bounds(&mut self, bounds: solidcraft_engine::geom::Aabb3) {
self.cancel_view_animation();
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.pending_fit = Some((self.cam, bounds, false));
self.cam = self.fitted_bounds(self.cam, &bounds);
@@ -1270,32 +1263,19 @@ impl SolveApp {
self.animate_to(to);
} else {
self.cam = to;
self.cam_anim = None;
}
ctx.request_repaint();
}
}
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
/// Move the camera to `to` at once. Views never animate: nothing is left running that a
/// later mouse orbit, pan or zoom could lag behind.
pub fn animate_to(&mut self, to: Camera) {
self.pending_fit = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
if !to.is_valid() {
return;
}
// Preferences › Navigation: animated (at a chosen speed) or immediate.
if !self.preferences.look_at_anim {
if to.is_valid() {
self.cam = to;
self.cam_anim = None;
return;
}
let mut anim = CameraAnim::new(self.cam, to, self.now);
let speed = f64::from(self.preferences.look_at_speed);
if speed.is_finite() && speed > 0.0 {
anim.duration /= speed;
}
self.cam_anim = Some(anim);
}
/// Frame everything shown, keeping the orientation (animated; the selection is ignored).
@@ -1372,38 +1352,24 @@ impl SolveApp {
self.animate_to(to);
} else if to.is_valid() {
self.cancel_view_animation();
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.cam = to;
}
}
/// Stop any view animation where it is (the user took over the camera).
/// Drop a pending refit (the user took over the camera).
pub fn cancel_view_animation(&mut self) {
self.pending_fit = None;
self.cam_anim = None;
}
/// Hand the view to an external navigation device without a pending mouse orbit or spin
/// immediately taking it back on the next frame.
pub fn begin_external_navigation(&mut self) {
self.cancel_view_animation();
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.viewport.spin = false;
}
fn step_view_animation(&mut self, ctx: &egui::Context) {
let Some(anim) = self.cam_anim else { return };
let (cam, done) = anim.sample(self.now);
self.cam = if cam.is_valid() { cam } else { anim.to };
if done {
self.cam_anim = None;
} else {
ctx.request_repaint();
}
}
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
frame_watch::begin(self);
@@ -1433,7 +1399,6 @@ impl SolveApp {
if self.cam.up != self.ui.up_axis {
self.set_up_axis(self.ui.up_axis);
}
self.step_view_animation(ctx);
self.poll_control_jobs(ctx);
self.drain_control(ctx);
self.follow_view_request(true);
@@ -1502,10 +1467,18 @@ impl SolveApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
expression_complete::begin_frame(ui.ctx());
let t0 = now_ms();
if self.preferences.language != self.applied_fonts_language || self.preferences.ui_font != self.applied_ui_font {
// The first frames draw with the bundled faces while the system's CJK, Hangul and Thai
// faces are read on a thread; the fonts are installed again once they are there.
crate::fonts::load_system_fonts_in_background(ui.ctx());
let system_fonts = crate::fonts::system_fonts_ready();
if self.preferences.language != self.applied_fonts_language
|| self.preferences.ui_font != self.applied_ui_font
|| (system_fonts && !self.applied_system_fonts)
{
self.applied_fonts_language.clone_from(&self.preferences.language);
self.applied_ui_font.clone_from(&self.preferences.ui_font);
crate::fonts::setup_fonts_for(ui.ctx(), &self.preferences.language, &self.preferences.ui_font);
self.applied_system_fonts = system_fonts;
crate::fonts::setup_fonts_without_waiting(ui.ctx(), &self.preferences.language, &self.preferences.ui_font);
}
let _language = crate::i18n::scope(&self.preferences.language);
let mut report_input = None;
-2
View File
@@ -11,7 +11,6 @@ use crate::scenario::Harness;
fn far_sketch() -> (Harness, u64) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.preferences.look_at_anim = false;
h.app.run("sketch.create", json!({"plane": "XY", "offset": 8, "name": "Far"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0": [330, 475], "p1": [350, 505]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
@@ -53,7 +52,6 @@ fn look_at_on_the_active_sketch_keeps_a_wider_zoom() {
fn an_empty_sketch_keeps_its_planes_origin() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.preferences.look_at_anim = false;
h.app.run("sketch.create", json!({"plane": "XY", "offset": 8})).unwrap();
h.app.cam.distance = 123.0;
crate::dialogs::look_at_sketch(&mut h.app);
+2 -2
View File
@@ -575,7 +575,7 @@ fn named_views_save_restore_rename_and_delete() {
app.cam.set_view(solidcraft_engine::render::StandardView::Top);
let t = Target::NamedView { name: "Named View1".into() };
act(&mut app, &t, "Restore");
assert_eq!(app.cam_anim.map(|a| a.to), Some(saved), "animates back to the saved camera");
assert_eq!(app.cam, saved, "goes back to the saved camera at once");
act(&mut app, &t, "Rename");
assert!(context_menu::finish_rename(&mut app, Some("Front detail"), true));
let t = Target::NamedView { name: "Front detail".into() };
@@ -585,7 +585,7 @@ fn named_views_save_restore_rename_and_delete() {
let top = context_menu::items(&app, &Target::NamedView { name: "Top".into() });
assert!(!find(&top, "Delete").enabled);
context_menu::run_item(&mut app, &find(&top, "Restore"), pos2(0.0, 0.0));
assert!(app.cam_anim.is_some());
assert_ne!(app.cam, saved);
}
#[test]
+34 -113
View File
@@ -171,7 +171,6 @@ pub(crate) fn modifier_motion(
app.viewport.orbit_pivot = None;
app.viewport.orbit_from = None;
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
}
app.viewport.modifier_nav = next;
next.map(|(mode, p)| {
@@ -237,10 +236,7 @@ pub(crate) fn view_key(app: &mut SolveApp, ctx: &egui::Context, key: egui::Key,
} else if !repeat {
let degrees = if mods.shift { 90.0 } else { 15.0 };
app.viewport.key_hold = Some(KeyHold { key, action, shift: mods.shift, since: app.now });
// Turn from where a step in flight is going, so taps add up and the one animation is
// retargeted rather than a second started.
let from = app.cam_anim.map_or(app.cam, |a| a.to);
let _ = app.run("view.navigate", json!({"camera": from, "action": action, "degrees": degrees}));
let _ = app.run("view.navigate", json!({"camera": app.cam, "action": action, "degrees": degrees}));
}
true
}
@@ -270,7 +266,6 @@ pub(crate) fn step_key_hold(app: &mut SolveApp, ctx: &egui::Context) {
&& cam.is_valid()
{
app.cancel_view_animation();
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.cam = cam;
}
@@ -375,7 +370,6 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
let pinch = ui.input(pinch_factor);
let h = rect.height().max(1.0) as f64;
if inside && pinch != 1.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
// Spread zooms in: the camera distance shrinks by the pinch factor.
let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app));
@@ -392,7 +386,6 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
}
}
if inside && scroll != 0.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
let anchor = zoom_anchor(app, rect, hover, caps.orbit);
@@ -409,7 +402,6 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
}
// Two-finger scroll pans the model with the fingers.
if inside && pan != egui::Vec2::ZERO {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]}));
@@ -522,9 +514,6 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
};
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true);
} else {
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
app.viewport.orbit_snap = None;
}
if app.viewport.axis_drag.is_none() {
app.viewport.orbit_free = None;
}
@@ -567,15 +556,9 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
}
if !app.cam.is_valid() {
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
app.cam = Camera::default();
app.fit_view();
}
step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt)));
if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint();
}
CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode }
}
@@ -631,19 +614,13 @@ pub(crate) fn snapped_axis_camera(cam: Camera) -> Camera {
to
}
pub(crate) struct OrbitSnap {
pub(crate) target: Camera,
pub(crate) yaw_velocity: f64,
pub(crate) pitch_velocity: f64,
/// The pivot and where it lies in the camera's frame ([`Camera::frame_coords`]).
pub(crate) pivot: Option<(Vec3, Vec3)>,
}
/// Pointer motion accumulates independently of the spring preview, so many small deltas
/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker.
/// Orbit by a pointer drag. With `snap` the displayed view is the nearest standard or diagonal
/// direction at once; the unsnapped orbit is kept in `orbit_free` so many small deltas still
/// cross snap boundaries. A small angular margin avoids flicker at a boundary.
pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
let displayed = app.cam;
if let Some(free) = app.viewport.orbit_free.take() {
let previous_free = app.viewport.orbit_free.take();
if let Some(free) = previous_free {
app.cam = free;
}
if around_cursor {
@@ -653,62 +630,31 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
}
let free = app.cam;
app.viewport.orbit_free = Some(free);
if snap || !around_cursor || app.viewport.orbit_snap.is_some() {
let mut target = if snap { snapped_axis_camera(free) } else { free };
if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() {
let b = free.back();
if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) {
target.yaw = previous.target.yaw;
target.pitch = previous.target.pitch;
}
if !snap {
return;
}
let mut target = snapped_axis_camera(free);
// Keep the direction already chosen unless the orbit has clearly left it.
if previous_free.is_some_and(|p| p != displayed) {
let b = free.back();
if displayed.back().dot(b) + 0.025 >= target.back().dot(b) {
target.yaw = displayed.yaw;
target.pitch = displayed.pitch;
}
// The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no
// pivot (it orbits about the view centre, see `axis_view`).
let pivot = if around_cursor {
match app.preferences.orbit_around {
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
_ => app.viewport.orbit_pivot,
}
} else {
None
}
// The cursor and origin pivots stay still; the axis gizmo has no pivot (it orbits about the
// view centre, see `axis_view`).
let pivot = if around_cursor {
match app.preferences.orbit_around {
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
_ => app.viewport.orbit_pivot,
}
.map(|p| (p, displayed.frame_coords(p)));
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
spring.target = target;
spring.pivot = pivot;
app.cam = displayed;
}
}
/// A lightly damped spring: mostly settled in about a third of a second, with a small
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
pub(crate) fn step_orbit_snap(app: &mut SolveApp, _rect: Rect, dt: f64) {
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
let steps = (dt * 240.0).ceil().max(1.0) as usize;
let h = dt / steps as f64;
let omega = 16.0;
let damping = 0.82;
let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
for _ in 0..steps.min(12) {
spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h;
spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h;
app.cam.yaw += spring.yaw_velocity * h;
app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
}
app.cam.target = spring.target.target;
let done = yaw_error(app.cam.yaw).abs() < 1e-5
&& (spring.target.pitch - app.cam.pitch).abs() < 1e-5
&& spring.yaw_velocity.abs() < 1e-4
&& spring.pitch_velocity.abs() < 1e-4;
if done {
app.cam = spring.target;
}
if let Some((pivot, coords)) = spring.pivot {
app.cam.place(pivot, coords);
}
if !done {
app.viewport.orbit_snap = Some(spring);
} else {
None
};
app.cam = target;
if let Some(p) = pivot {
app.cam.place(p, displayed.frame_coords(p));
}
}
@@ -970,7 +916,6 @@ mod view_key_tests {
#[test]
fn arrow_keys_turn_and_pan_the_view() {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "front"}})).unwrap();
h.frames(3);
@@ -1013,22 +958,20 @@ mod view_key_tests {
fn front_view() -> Harness {
let mut h = Harness::new();
h.app.preferences.look_at_anim = true;
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "front"}})).unwrap();
h.settle();
h
}
/// Holding an arrow turns the view steadily, by frame time, whatever the key repeat does:
/// one animation at most, never a second one fighting the first.
/// Holding an arrow turns the view steadily, by frame time, whatever the key repeat does.
#[test]
fn a_held_arrow_turns_the_view_steadily_and_ignores_key_repeat() {
let hold = |repeats: bool| {
let mut h = front_view();
let mut last = h.app.cam.yaw;
h.app.synthetic.push(arrow(true, false));
h.frame();
let mut last = h.app.cam.yaw;
let log = h.app.session.log.len();
let mut deltas = Vec::new();
for frame in 0..60 {
@@ -1039,9 +982,8 @@ mod view_key_tests {
deltas.push(turn(last, h.app.cam.yaw));
last = h.app.cam.yaw;
}
assert!(h.app.cam_anim.is_none(), "the held turn runs on key state, not on a tween");
assert_eq!(h.app.session.log.len(), log, "turn frames do not flood the command log");
assert!(deltas.iter().all(|d| *d > 0.0), "the view never stops or reverses while held: {deltas:?}");
assert!(deltas.iter().all(|d| *d >= 0.0), "the view never reverses while held: {deltas:?}");
let steady = &deltas[20..];
let per_frame = 90f64.to_radians() / 60.0;
assert!(steady.iter().all(|d| (d - per_frame).abs() < 1e-3), "{steady:?}");
@@ -1054,34 +996,14 @@ mod view_key_tests {
assert!((hold(false) - hold(true)).abs() < 1e-9, "OS key repeat changes nothing");
}
/// A tap is still one animated step of 15°, with one animation.
/// A tap is one step of 15°, applied in the frame of the press, and a second tap adds to it.
#[test]
fn a_tap_is_one_animated_step() {
fn a_tap_is_one_instant_step() {
let mut h = front_view();
let before = h.app.cam.yaw;
tap(&mut h);
h.frames(2);
assert!(h.app.cam_anim.is_some(), "the step animates");
h.settle();
assert!(h.app.cam_anim.is_none());
assert!((turn(before, h.app.cam.yaw).abs() - 15f64.to_radians()).abs() < 1e-6);
}
/// A tap while a step is in flight retargets that animation: the turns add up and only one
/// animation exists, starting where the camera is.
#[test]
fn a_tap_during_a_step_retargets_the_one_animation() {
let mut h = front_view();
let before = h.app.cam.yaw;
tap(&mut h);
h.frames(5);
let mid = h.app.cam;
tap(&mut h);
h.frames(1);
let anim = h.app.cam_anim.expect("one animation");
assert!((turn(before, anim.to.yaw).abs() - 30f64.to_radians()).abs() < 1e-6, "retargeted past the first step");
assert!(turn(mid.yaw, anim.from.yaw).abs() < 15f64.to_radians(), "continues from the camera as it is");
h.settle();
assert!((turn(before, h.app.cam.yaw).abs() - 30f64.to_radians()).abs() < 1e-6);
}
@@ -1090,7 +1012,6 @@ mod view_key_tests {
#[test]
fn ctrl_8_again_looks_from_the_other_side() {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.frames(2);
let back_z = |h: &Harness| h.app.cam.back().z;
+18 -29
View File
@@ -272,7 +272,6 @@ mod tests {
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"navigator_style":"cube"}));
let ctx = crate::fonts::test_context();
h.app.preferences.look_at_anim = false;
for up in ["z", "y"] {
h.call("ui.prefs", json!({"up_axis":up}));
h.call("ui.view", json!({"view":"front"}));
@@ -509,43 +508,33 @@ mod tests {
assert_eq!(h.app.cam.distance, cam.distance);
}
/// A view-cube click puts the camera at the view at once and leaves nothing running: the
/// mouse orbit that follows moves the camera exactly as far as the pointer, in the same frame.
#[test]
fn cube_navigation_eases_through_intermediate_views_and_can_be_interrupted() {
fn a_mouse_orbit_after_a_cube_click_follows_the_pointer_one_to_one() {
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"navigator_style":"cube"}));
assert!(h.app.preferences.look_at_anim);
for (action, direction) in [(None, Some(Vec3::Z)), (Some("right"), None), (Some("clockwise"), None), (Some("home"), None)] {
let from = h.app.cam;
navigate(&mut h.app, action, direction);
let anim = h.app.cam_anim.expect("cube navigation must animate");
assert_eq!(h.app.cam, from, "starting a transition must not jump the camera");
let mut previous = from;
for frame in 1..=60 {
let now = anim.start + anim.duration * f64::from(frame) / 60.0 + if frame == 60 { 1e-9 } else { 0.0 };
let (cam, done) = anim.sample(now);
let angle = |a: Vec3, b: Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees();
assert!(angle(previous.back(), cam.back()).max(angle(previous.basis().0, cam.basis().0)) < 10.0);
assert_eq!(done, frame == 60);
if frame == 30 {
assert_ne!(cam.basis(), from.basis());
assert_ne!(cam.basis(), anim.to.basis());
}
previous = cam;
}
h.app.cam = anim.sample(anim.start + anim.duration / 2.0).0;
let intermediate = h.app.cam;
navigate(&mut h.app, Some("left"), None);
assert_eq!(h.app.cam_anim.unwrap().from, intermediate, "another click starts where the camera is");
h.app.cam = anim.to;
h.app.cancel_view_animation();
}
h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
h.frames(4);
let p = crate::scenario::handle_at("navigation_cube:top").unwrap();
h.call("ui.click", json!({"x":p.x,"y":p.y}));
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-6, "the click shows the top view in its own frame");
assert!(h.app.viewport.orbit_free.is_none());
let mut expected = h.app.cam;
let from = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle","shift":true,"steps":20}));
expected.orbit(50.0, 20.0);
assert_ne!(h.app.cam, from);
assert!((h.app.cam.yaw - expected.yaw).abs() < 1e-9 && (h.app.cam.pitch - expected.pitch).abs() < 1e-9, "{:?}", h.app.cam);
let done = h.app.cam;
h.frames(30);
assert_eq!(h.app.cam, done, "nothing keeps easing after the pointer stops");
}
#[test]
fn cube_faces_corners_roll_and_home_drive_the_shared_command() {
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"navigator_style":"cube"}));
h.app.preferences.look_at_anim = false;
let before = h.app.session.doc.to_json();
for (name, direction) in [("top", Vec3::Z), ("front", -Vec3::Y), ("right", Vec3::X), ("top_front_right", Vec3::Z - Vec3::Y + Vec3::X)] {
h.call("ui.view", json!({"view":"iso"}));
+138 -29
View File
@@ -281,13 +281,40 @@ pub enum OrbitAround {
Cursor,
}
/// Which program reads the 3D mouse on Linux when both spacenavd and the device itself (hidraw)
/// are there (Preferences: "3D mouse driver"). Read by the app's reader, not by this crate.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpaceMouseDriver {
/// spacenavd when it runs, else the device itself (how it always was).
#[default]
Automatic,
Spacenavd,
/// The hidraw device, bypassing spacenavd's settings.
Direct,
}
impl SpaceMouseDriver {
pub const ALL: [Self; 3] = [Self::Automatic, Self::Spacenavd, Self::Direct];
/// The choice as the reader thread shares it (an `AtomicU8`).
pub fn to_u8(self) -> u8 {
self as u8
}
/// Back from [`Self::to_u8`]; anything else reads as Automatic.
pub fn from_u8(value: u8) -> Self {
Self::ALL.into_iter().find(|driver| driver.to_u8() == value).unwrap_or_default()
}
}
/// The browser sidebar's first width, and the width [`Prefs::default`] restores; the panel
/// itself clamps what the user drags to its own range.
pub const DEFAULT_BROWSER_WIDTH: f32 = 250.0;
// The stored fields, their defaults, page resets and simple form controls share this table.
// Omitted pages keep fields such as grid_follows_view and added agents when restoring defaults.
// Accepted control-channel ranges may differ from slider ranges (look_at_speed).
// Accepted control-channel ranges may differ from slider ranges.
macro_rules! preference_settings {
($( $(#[$doc:meta])* $field:ident: $ty:ty = $default:expr $(, $page:expr)? $(, accept($accepted:expr))? $(=> $control:ident($($args:tt)*))? ;)*) => {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -374,6 +401,11 @@ preference_settings! {
space_mouse_reverse: [bool; 6] = [false; 6], 1;
/// Per physical axis of the cap, a multiplier on top of `space_mouse_speed`; 0 switches it off.
space_mouse_axis_speed: [f32; 6] = [1.0; 6], 1;
/// Per physical axis of the cap, which of its motions drives it (`space_mouse::reassigned`),
/// applied after calibration; each motion drives exactly one axis.
space_mouse_axis_source: [usize; 6] = crate::space_mouse::SAME_AXES, 1;
/// Which program reads the 3D mouse when spacenavd and the device itself are both there (Linux).
space_mouse_driver: SpaceMouseDriver = SpaceMouseDriver::default(), 1;
/// Per 3D mouse (`space_mouse::Input::device`), the calibration that puts its axes on
/// 3Dconnexion's. A device without one is read as it reports. Page resets keep calibrations.
#[serde(deserialize_with = "calibrations_that_read")]
@@ -433,9 +465,6 @@ preference_settings! {
trackpad_pan: bool = true, 1;
/// Horizontal trackpad and thumb-wheel scrolling pans the view the other way (#2007).
pan_reverse_x: bool = false, 1 => check("Reverse horizontal pan direction", "Horizontal two-finger scrolling and the thumb wheel pan the other way");
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
look_at_anim: bool = true, 1 => check("Animate view changes", "Axis view, Look At, Home and Fit");
look_at_speed: f32 = 1.0, 1, accept(0.25..=4.0) => enabled_slider(look_at_anim, "Animation speed", 0.5..=3.0, 0.25, |v| format!("{v:.2}×"));
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
up_axis: String = "z".into(), PAGE_DESIGN;
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
@@ -450,6 +479,9 @@ preference_settings! {
update: crate::update::Memo = crate::update::Memo::default();
/// Save as mesh: the last format (`stl` or `3mf`), whether it was sent to a slicer, which one
/// (an installed slicer's id or a program path), and the programs the user added (newest first).
/// The variant last started in each split tool family (Rectangle, Circle, Arc, Slot, Polygon, Line,
/// Spline): the family's button and shortcut start it again (`sketch_families`).
sketch_variants: crate::sketch_families::Choices = crate::sketch_families::Choices::default();
save_mesh_format: String = "stl".into();
/// Hidden edges in the current-view DXF export (false: omit, true: dashed).
view_dxf_dashed: bool = false;
@@ -1227,27 +1259,105 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
for (m, id, label) in MiddleDoubleClick::ALL { if ui_kit::list_picker(ui, id, crate::i18n::tr_label(label), current == m).clicked() { app.preferences.middle_double_click = id.into(); } }
}).on_hover_text(crate::i18n::tr_label("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing"));
});
setting::look_at_anim(app, ui);
setting::look_at_speed(app, ui);
setting::space_mouse_enabled(app, ui);
setting::space_mouse_speed(app, ui);
for (axis, name) in SPACE_MOUSE_AXES.iter().enumerate() {
ui_kit::row(ui, crate::i18n::tr_label(name), |ui| {
// Rows say what they do to the view, through the mapping navigation uses; the choices
// are the device's calibrated motions. Neither names a kind of device.
let mapping = app.space_mouse.mapping;
let rows: Vec<(String, bool)> = (0..6)
.map(|row| {
let label = crate::space_mouse::row_label(mapping, row);
let text = crate::i18n::Locale::for_language(&app.preferences.language).format(label, &[("n", &(row + 1).to_string())]);
(text, label == "ui.space_mouse_view_off")
})
.collect();
let (driven_by, driven_by_label) = (app.tr("ui.space_mouse_driven_by").to_owned(), app.tr("ui.space_mouse_driven_by_label").to_owned());
let off_tip = app.tr("ui.space_mouse_view_off_tip").to_owned();
let motions: Vec<String> = crate::space_mouse::MOTIONS.iter().map(|motion| app.tr(motion).to_owned()).collect();
ui_kit::row(ui, app.tr("ui.space_mouse_view_axis"), |ui| {
let header = format!("{} · {} · {}", crate::i18n::tr_label("Invert"), crate::i18n::tr_label("Speed"), driven_by_label);
ui_kit::text(ui, &header, ui_kit::Text::Note("space_mouse_rows"));
});
for (axis, (name, off)) in rows.iter().enumerate() {
let group = ui_kit::row(ui, name, |ui| {
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
ui.horizontal(|ui| {
if let Some(reverse) = app.preferences.space_mouse_reverse.get_mut(axis) {
ui_kit::checkbox(ui, crate::i18n::tr_label("Invert"), reverse);
// Invert and speed do nothing on a row the driver turns off. The motion
// choice below stays live, so a motion can still be swapped out of it.
ui.add_enabled_ui(!off, |ui| {
ui.horizontal(|ui| {
if let Some(reverse) = app.preferences.space_mouse_reverse.get_mut(axis) {
ui_kit::checkbox(ui, crate::i18n::tr_label("Invert"), reverse);
}
if let Some(speed) = app.preferences.space_mouse_axis_speed.get_mut(axis) {
ui_kit::slider(
ui,
speed,
0.0..=3.0,
ui_kit::Slider { step: Some(0.05), format: |v| format!("{v:.2}×"), ..Default::default() },
);
}
});
});
})
.response
});
if *off {
group.on_hover_text(&off_tip);
}
ui_kit::row(ui, &driven_by_label, |ui| {
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
let source = &mut app.preferences.space_mouse_axis_source;
let current = source.get(axis).copied().unwrap_or(axis);
let mut chosen = current;
ui_kit::dropdown_menu(ui, ("sc_space_mouse_source", axis), motions.get(current).map_or("", String::as_str), |ui| {
for (from, name) in motions.iter().enumerate() {
if ui_kit::list_picker(ui, name, name, chosen == from).clicked() {
chosen = from;
}
}
if let Some(speed) = app.preferences.space_mouse_axis_speed.get_mut(axis) {
ui_kit::slider(
ui,
speed,
0.0..=3.0,
ui_kit::Slider { step: Some(0.05), format: |v| format!("{v:.2}×"), ..Default::default() },
);
})
.on_hover_text(&driven_by);
if chosen != current {
crate::space_mouse::reassign(source, axis, chosen);
}
});
});
}
ui_kit::row(ui, "", |ui| {
let reassigned = app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES;
ui.add_enabled_ui(app.preferences.space_mouse_enabled && reassigned, |ui| {
if ui_kit::button(ui, app.tr("ui.space_mouse_reset_axes"), ui_kit::Button::Normal).clicked() {
app.preferences.space_mouse_axis_source = crate::space_mouse::SAME_AXES;
}
});
});
// Only where the reader has seen the choice: Linux, with spacenavd and the device both there.
if let Some(present) = app.space_mouse.drivers.filter(|present| present.spacenavd || present.direct) {
let [label, auto, direct, only_spacenavd, only_direct] =
["driver", "driver_auto", "driver_direct", "driver_only_spacenavd", "driver_only_direct"]
.map(|key| app.tr(&format!("ui.space_mouse_{key}")).to_owned());
let driver = &mut app.preferences.space_mouse_driver;
ui_kit::row(ui, &label, |ui| match (present.spacenavd, present.direct) {
(true, true) => {
let name = |driver| match driver {
SpaceMouseDriver::Automatic => auto.as_str(),
SpaceMouseDriver::Spacenavd => "spacenavd",
SpaceMouseDriver::Direct => direct.as_str(),
};
ui_kit::dropdown_menu(ui, "sc_space_mouse_driver", name(*driver), |ui| {
for choice in SpaceMouseDriver::ALL {
if ui_kit::list_picker(ui, name(choice), name(choice), *driver == choice).clicked() {
*driver = choice;
}
}
});
});
}
(true, false) => {
ui_kit::text(ui, &only_spacenavd, ui_kit::Text::Note("space_mouse_driver"));
}
(false, _) => {
ui_kit::text(ui, &only_direct, ui_kit::Text::Note("space_mouse_driver"));
}
});
}
ui_kit::row(ui, app.tr("ui.space_mouse_calibration"), |ui| {
@@ -1633,10 +1743,6 @@ fn current_theme_file(app: &SolveApp) -> serde_json::Value {
}
}
/// The cap's physical axes, in the order of `Prefs::space_mouse_reverse`.
pub(crate) const SPACE_MOUSE_AXES: [&str; 6] =
["Push left / right", "Push forward / back", "Lift / press", "Tilt forward / back", "Tilt left / right", "Twist"];
/// Fold a setting an older release kept in its own field into the one this version reads.
/// `orbit_cursor` (`true` = the point under the cursor) became `orbit_around`. A 3D mouse
/// calibration that can't be used as it is (a hand edit) is dropped, so the device is read as it
@@ -1646,6 +1752,10 @@ pub fn migrate(mut p: Prefs) -> Prefs {
p.orbit_around = OrbitAround::Cursor;
p.orbit_cursor = false;
}
if !crate::space_mouse::drives_each_once(p.space_mouse_axis_source) {
log::warn!("preferences: the 3D mouse axis assignment {:?} doesn't drive each axis once; using the default", p.space_mouse_axis_source);
p.space_mouse_axis_source = crate::space_mouse::SAME_AXES;
}
p.space_mouse_calibrations.retain(|device, calibration| {
let usable = calibration.is_usable();
if !usable {
@@ -1732,8 +1842,8 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
if new.disabled_plugins.len() > crate::plugins::MAX_DISABLED || new.disabled_plugins.iter().any(|id| !crate::plugins::valid_id(id)) {
return Err(format!("`disabled_plugins` must be at most {} ids of lowercase letters, digits and hyphens", crate::plugins::MAX_DISABLED));
}
if !accepted::ui_scale(&new) || !accepted::look_at_speed(&new) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
if !accepted::ui_scale(&new) {
return Err("`ui_scale` must be 0.5…2.5".into());
}
if solidcraft_engine::doc::display_quality::DisplayQuality::parse(&new.display_quality).is_none() {
return Err("`display_quality` must be coarse, normal, fine or ultra".into());
@@ -2052,7 +2162,7 @@ mod tests {
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes);
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube);
h.call("ui.prefs", json!({"navigator_style":"axes", "look_at_anim":false}));
h.call("ui.prefs", json!({"navigator_style":"axes"}));
h.call("ui.view", json!({"view":"front"}));
let axis = crate::scenario::handle_at("axis_view:-y").unwrap();
h.call("ui.click", json!({"x":axis.x,"y":axis.y}));
@@ -2280,7 +2390,7 @@ mod tests {
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
control(&mut app, &json!({"open": "Navigation", "nav": "alias"})).unwrap();
control(&mut app, &json!({"button": "apply"})).unwrap();
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
control(&mut app, &json!({"button": "cancel"})).unwrap();
@@ -2291,12 +2401,11 @@ mod tests {
c
};
app.animate_to(to);
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
assert!(app.cam == to, "the view jumps");
control(&mut app, &json!({"open": "Navigation"})).unwrap();
control(&mut app, &json!({"button": "defaults"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
assert!(app.preferences.look_at_anim);
control(&mut app, &json!({"button": "ok"})).unwrap();
assert!(!app.prefs_window.open);
@@ -588,7 +588,6 @@ fn render_workspace_view_keys_double_middle_click_and_navigators_work() {
for style in [crate::prefs::NavigatorStyle::Axes, crate::prefs::NavigatorStyle::Cube] {
let mut h = Harness::new();
h.app.preferences.navigator_style = style;
h.app.preferences.look_at_anim = false;
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap();
enter(&mut h.app, true).unwrap();
h.frames(3);
+3 -44
View File
@@ -22,8 +22,7 @@
//! into `$SOLIDCRAFT_SCENARIO_SHOTS` when that is set; `{"debug": 1}`
//! prints the UI state (`"widgets"`: also every widget's text; `"handles"`: every published
//! handle, browser rows as `row:<label>` and their fold arrows as `fold:<label>`)
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]),
//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow)
@@ -269,7 +268,7 @@ impl Harness {
pub fn settle(&mut self) {
for _ in 0..2000 {
// Queued input used up and the view no longer moving.
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
if self.app.synthetic.is_empty() {
break;
}
self.frame();
@@ -378,36 +377,6 @@ impl Harness {
}
}
/// Animate the view to `v` (as the axis view does), sampling the camera every frame: no
/// frame may turn the view (its right or back vector) by more than `max_deg`, the last
/// included, so the move ends without a jump.
fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> {
let basis = |c: &solidcraft_engine::render::Camera| c.basis();
let mut prev = basis(&self.app.cam);
self.app.animate_view(v);
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
return Err(format!("view_anim {v}: no animation started"));
}
let mut worst = (0.0f64, 0usize);
for i in 0..240 {
self.frame();
let now = basis(&self.app.cam);
let ang = |a: solidcraft_engine::geom::Vec3, b: solidcraft_engine::geom::Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees();
let d = ang(prev.0, now.0).max(ang(prev.2, now.2));
if d > worst.0 {
worst = (d, i);
}
prev = now;
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
if worst.0 > max_deg {
return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0));
}
return Ok(());
}
}
Err(format!("view_anim {v}: still animating after 240 frames"))
}
/// Run one step.
pub fn step(&mut self, s: &Value) -> Result<(), String> {
let flag = |k: &str| s.get(k).and_then(Value::as_bool).unwrap_or(false);
@@ -515,13 +484,10 @@ impl Harness {
if let Some(p) = c.get("pitch").and_then(Value::as_f64) {
self.app.cam.pitch = p;
}
self.app.cam_anim = None;
self.frames(2);
// Where `camera.panned` and `camera.zoomed` checks measure from.
self.cam_mark = Some(self.app.cam);
return Ok(());
} else if let Some(v) = s.get("view_anim").and_then(Value::as_str) {
return self.view_anim(v, s.get("max_deg").and_then(Value::as_f64).unwrap_or(10.0));
} else if s.get("autosave").is_some() {
self.start_autosave();
return Ok(());
@@ -687,14 +653,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
}
"camera" => {
// {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera
// looks from (yaw and pitch in its up-axis frame, ±0.5°),
// once any view animation has finished.
for _ in 0..240 {
if h.app.cam_anim.is_none() {
break;
}
h.frame();
}
// looks from (yaw and pitch in its up-axis frame, ±0.5°).
let (yaw, pitch) = (h.app.cam.yaw.to_degrees(), h.app.cam.pitch.to_degrees());
let near = |got: f64, want: &Value| want.as_f64().is_none_or(|w| ((got - w + 540.0).rem_euclid(360.0) - 180.0).abs() < 0.5);
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
+52 -5
View File
@@ -44,21 +44,34 @@ pub fn family(id: &str) -> Option<&'static Family> {
FAMILIES.iter().find(|f| f.commands.contains(&id))
}
/// UI state only, swapped with document tabs; never dirties the model or its undo history.
#[derive(Clone, Debug, Default)]
pub struct Choices(BTreeMap<&'static str, &'static str>);
/// The variant last started in each family. A user preference, so it survives new and opened
/// designs and restarts; it never dirties the model or its undo history. Keys are family labels,
/// values command ids; anything that is not a variant of its family is ignored when read.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Choices(BTreeMap<String, String>);
impl Choices {
pub fn remember(&mut self, id: &str) {
if let Some(f) = family(id)
&& let Some(id) = f.commands.iter().find(|c| **c == id)
{
self.0.insert(f.label, id);
self.0.insert(f.label.to_string(), (*id).to_string());
}
}
pub fn selected(&self, f: &Family) -> Option<&'static CommandSpec> {
self.0.get(f.label).copied().or_else(|| f.commands.first().copied()).and_then(solidcraft_engine::find_command)
let saved = self.0.get(f.label).and_then(|s| f.commands.iter().find(|c| **c == s.as_str()));
saved.or_else(|| f.commands.first()).copied().and_then(solidcraft_engine::find_command)
}
/// What a family's main button or shortcut starts: its remembered variant when `id` is the
/// family's default command (the one carrying the shortcut), otherwise `id` itself.
pub fn resolve(&self, id: &'static str) -> &'static str {
match family(id) {
Some(f) if f.commands.first().copied() == Some(id) => self.selected(f).map_or(id, |c| c.id),
_ => id,
}
}
}
@@ -93,4 +106,38 @@ mod tests {
assert_eq!(choices.selected(family(id).unwrap()).unwrap().id, expected);
}
}
#[test]
fn rectangle_comes_back_in_the_variant_last_used_and_it_survives_a_prefs_reload() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", serde_json::json!({"plane":"XY"})).unwrap();
h.app.start("sketch.rectangle.three_point");
h.settle();
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.rectangle.three_point"));
h.app.tool = None;
// The main button and the shortcut both start the family's default command.
let rect = family("sketch.rectangle.two_point").unwrap();
assert_eq!(h.app.preferences.sketch_variants.selected(rect).unwrap().id, "sketch.rectangle.three_point");
assert_eq!(h.app.preferences.sketch_variants.resolve("sketch.rectangle.two_point"), "sketch.rectangle.three_point");
h.app.start(h.app.preferences.sketch_variants.resolve("sketch.rectangle.two_point"));
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.rectangle.three_point"));
// Another family is untouched, and a non-default variant is never rewritten.
assert_eq!(h.app.preferences.sketch_variants.resolve("sketch.circle.center"), "sketch.circle.center");
assert_eq!(h.app.preferences.sketch_variants.resolve("sketch.rectangle.center"), "sketch.rectangle.center");
// A new design keeps it; so does a restart.
let mut restored = crate::SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&h.app.prefs());
assert_eq!(restored.preferences.sketch_variants.resolve("sketch.rectangle.two_point"), "sketch.rectangle.three_point");
}
#[test]
fn old_or_damaged_prefs_fall_back_to_each_familys_first_variant() {
let mut app = crate::SolveApp::new(Default::default(), Default::default());
app.load_prefs(r#"{"preferences":{"save_mesh_format":"3mf"}}"#);
assert_eq!(app.preferences.sketch_variants.resolve("sketch.rectangle.two_point"), "sketch.rectangle.two_point");
app.load_prefs(r#"{"preferences":{"sketch_variants":{"Rectangle":"sketch.circle.center","Gone":"x"}}}"#);
assert_eq!(app.preferences.sketch_variants.resolve("sketch.rectangle.two_point"), "sketch.rectangle.two_point");
app.load_prefs(r#"{"preferences":{"sketch_variants":7}}"#);
assert_eq!(app.preferences.sketch_variants.resolve("sketch.polygon.inscribed"), "sketch.polygon.inscribed");
}
}
@@ -10,7 +10,6 @@ fn step(h: &mut Harness, value: Value) {
}
fn fixture() -> Harness {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.line", json!({"points":[[-30,10],[-15,10]]})),
@@ -8,7 +8,6 @@ use solidcraft_engine::geom::{Plane, Vec3};
fn sketch_from(yaw: f64, pitch: f64, name: &str) -> (Harness, Vec3, Plane) {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
h.app.cam.yaw = yaw;
h.app.cam.pitch = pitch;
let eye = h.app.cam.eye();
@@ -73,7 +72,6 @@ fn a_flipped_sketch_extrudes_toward_the_viewer_and_reopens_the_same() {
#[test]
fn a_construction_plane_seen_from_below_gets_a_sketch_facing_down() {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
h.app.run("construct.plane.offset", json!({"base":"XY","offset":"5 mm","name":"P"})).unwrap();
h.app.cam.yaw = -0.6;
h.app.cam.pitch = -0.5;
+286 -1
View File
@@ -51,6 +51,18 @@ pub struct Input {
pub raw: Axes,
/// The guided calibration while its window is open, and the device it is for.
pub calibrating: Option<(String, solidcraft_space_mouse::Wizard)>,
/// The driver mapping navigation used last frame, which says what each axis does to the view.
pub mapping: Option<DriverMapping>,
/// Which of the Linux readers exist, as the app's reader last saw them; none where there is
/// only one way to read the device.
pub drivers: Option<DriversPresent>,
}
/// What the Linux reader found: the spacenavd socket and a direct (hidraw) 3D mouse.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DriversPresent {
pub spacenavd: bool,
pub direct: bool,
}
/// Seconds after the cap last moved that wheel events are still taken for 3DxWare's: one can be
@@ -173,6 +185,69 @@ pub fn calibrated(axes: Axes, calibration: Option<Calibration>) -> Axes {
calibration.map_or(axes, |calibration| calibration.apply(axes.into()).into())
}
/// The device's six calibrated motions, in the order of a reading: what each axis can be driven
/// by. They name no kind of device, since calibration defined them: Translation X is whatever the
/// user did for "slide right".
pub const MOTIONS: [&str; 6] = [
"ui.space_mouse_motion_translation_x",
"ui.space_mouse_motion_translation_y",
"ui.space_mouse_motion_translation_z",
"ui.space_mouse_motion_rotation_x",
"ui.space_mouse_motion_rotation_y",
"ui.space_mouse_motion_rotation_z",
];
/// What each logical axis does to the view, as `navigation::device_camera` applies it.
const VIEW_AXES: [&str; 6] = [
"ui.space_mouse_view_pan_sideways",
"ui.space_mouse_view_zoom",
"ui.space_mouse_view_pan_up_down",
"ui.space_mouse_view_orbit_up_down",
"ui.space_mouse_view_orbit_sideways",
"ui.space_mouse_view_roll",
];
/// The label of axis row `row`: what it does to the view through `mapping`, the mapping
/// navigation really uses, or "off" when that mapping leaves it out. Without a mapping (a build
/// with no 3D mouse reader, since the apps run navigation every frame before drawing), a plain
/// "Axis {n}": neither a guess at the view nor a motion name, which the dropdown already uses.
/// Two rows a driver profile sends to the same output both get its label; that's what they do.
pub fn row_label(mapping: Option<DriverMapping>, row: usize) -> &'static str {
match mapping.and_then(|mapping| mapping.axes.get(row).copied()) {
Some(rule) if rule.enabled => VIEW_AXES.get(rule.output).copied().unwrap_or("ui.space_mouse_view_off"),
Some(_) => "ui.space_mouse_view_off",
None => "ui.space_mouse_view_unmapped",
}
}
/// Which of the cap's motions drives each of its axes (`Prefs::space_mouse_axis_source`): row
/// `r` reads motion `source[r]`. Each motion drives exactly one row.
pub const SAME_AXES: [usize; 6] = [0, 1, 2, 3, 4, 5];
/// The reading with its rows reassigned: row `r` takes motion `source[r]`. A `source` that
/// isn't one motion per row reads as [`SAME_AXES`].
pub fn reassigned(axes: Axes, source: [usize; 6]) -> Axes {
let source = if drives_each_once(source) { source } else { SAME_AXES };
let raw: [i16; 6] = axes.into();
source.map(|from| raw.get(from).copied().unwrap_or(0)).into()
}
/// Makes `row` read motion `from`. The row that read `from` before takes this row's old motion,
/// so each motion still drives exactly one row.
pub fn reassign(source: &mut [usize; 6], row: usize, from: usize) {
if let Some(other) = source.iter().position(|&s| s == from)
&& row < source.len()
{
source.swap(row, other);
}
}
/// Whether each of the six motions drives exactly one row.
pub fn drives_each_once(source: [usize; 6]) -> bool {
let mut seen = [false; 6];
source.iter().all(|&from| seen.get_mut(from).is_some_and(|seen| !std::mem::replace(seen, true)))
}
/// Hands one frame of 3D mouse input, `seconds` after the previous frame, to the page showing
/// the camera. Nothing happens with navigation switched off, on the start page or while a file
/// picker is open, or while the reading is inside the dead zone (after calibration, which takes
@@ -182,6 +257,7 @@ pub fn calibrated(axes: Axes, calibration: Option<Calibration>) -> Axes {
/// calibration window is open, the reading goes to it instead and the view stays put.
pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
app.space_mouse.raw = axes;
app.space_mouse.mapping = Some(mapping);
if let Some((device, wizard)) = &mut app.space_mouse.calibrating {
if app.space_mouse.device.as_ref() == Some(device) {
wizard.feed(axes.into(), seconds);
@@ -191,7 +267,9 @@ pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMa
// Another device is being read now, or none: what was recorded isn't its.
app.space_mouse.calibrating = None;
}
let axes = calibrated(axes, calibration(app));
// Calibrated first, so the calibration puts the device's axes in order, then the user's choice
// of which motion drives which axis. Invert and speed then go with the axis, not the motion.
let axes = reassigned(calibrated(axes, calibration(app)), app.preferences.space_mouse_axis_source);
// Only the window with focus: with two windows open, the cap moves the one being used, not both.
if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || !axes.moving() || !ctx.input(|i| i.focused) {
return false;
@@ -374,6 +452,26 @@ mod tests {
assert_eq!(DriverMapping::object_mode().apply(all), DriverMapping::default().apply(all));
}
#[test]
fn the_driver_choice_is_saved_and_an_older_file_reads_automatic() {
use crate::prefs::SpaceMouseDriver;
let mut saved = serde_json::to_value(crate::prefs::Prefs::default()).unwrap();
assert_eq!(saved["space_mouse_driver"], "automatic");
for driver in SpaceMouseDriver::ALL {
saved["space_mouse_driver"] = serde_json::to_value(driver).unwrap();
assert_eq!(crate::prefs::lenient(&saved).space_mouse_driver, driver);
assert_eq!(SpaceMouseDriver::from_u8(driver.to_u8()), driver);
}
saved["space_mouse_driver"] = "direct".into();
assert_eq!(crate::prefs::lenient(&saved).space_mouse_driver, SpaceMouseDriver::Direct);
// One saved before the setting existed, or with a driver this version doesn't know.
saved.as_object_mut().unwrap().remove("space_mouse_driver");
assert_eq!(crate::prefs::lenient(&saved).space_mouse_driver, SpaceMouseDriver::Automatic);
saved["space_mouse_driver"] = "evdev".into();
assert_eq!(crate::prefs::lenient(&saved).space_mouse_driver, SpaceMouseDriver::Automatic);
assert_eq!(SpaceMouseDriver::from_u8(200), SpaceMouseDriver::Automatic);
}
#[test]
fn the_dead_zone_is_rest() {
assert!(!Axes::default().moving());
@@ -628,4 +726,191 @@ mod tests {
saved["space_mouse_calibrations"]["hid 1234:5678"] = older;
assert_eq!(crate::prefs::lenient(&saved), prefs);
}
#[test]
fn picking_a_motion_another_axis_uses_swaps_the_two() {
let mut source = SAME_AXES;
reassign(&mut source, 1, 2);
assert_eq!(source, [0, 2, 1, 3, 4, 5], "Y and Z swapped");
reassign(&mut source, 4, 5);
assert_eq!(source, [0, 2, 1, 3, 5, 4], "then roll and twist");
reassign(&mut source, 1, 1);
assert_eq!(source, [0, 1, 2, 3, 5, 4], "back again");
assert!(drives_each_once(source));
// A row or a motion that doesn't exist changes nothing.
reassign(&mut source, 9, 0);
reassign(&mut source, 0, 9);
assert_eq!(source, [0, 1, 2, 3, 5, 4]);
assert!(!drives_each_once([0, 0, 2, 3, 4, 5]) && !drives_each_once([0, 1, 2, 3, 4, 9]));
// A value that doesn't drive each axis once is read as the default.
let reading = Axes { translation: [1, 2, 3], rotation: [4, 5, 6] };
assert_eq!(reassigned(reading, [0, 2, 1, 3, 5, 4]), Axes { translation: [1, 3, 2], rotation: [4, 6, 5] });
assert_eq!(reassigned(reading, [0, 0, 2, 3, 4, 5]), reading);
}
#[test]
fn a_reassigned_motion_drives_its_new_axis_and_invert_stays_with_the_axis() {
let ctx = window(true);
let press = Axes { translation: [0, 0, 350], rotation: [0; 3] };
// With Y and Z swapped, pressing the cap does what pushing it back would.
let mut reference = app();
frame(&mut reference, &ctx, DriverMapping::default(), PUSH, 0.02);
let mut swapped = app();
swapped.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5];
assert!(frame(&mut swapped, &ctx, DriverMapping::default(), press, 0.02));
assert_eq!(swapped.cam, reference.cam);
// Inverting the Y axis inverts whatever motion drives it now, not the Y motion.
let mut inverted = app();
inverted.preferences.space_mouse_reverse[1] = true;
frame(&mut inverted, &ctx, DriverMapping::default(), PUSH, 0.02);
let mut swapped_inverted = app();
swapped_inverted.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5];
swapped_inverted.preferences.space_mouse_reverse[1] = true;
frame(&mut swapped_inverted, &ctx, DriverMapping::default(), press, 0.02);
assert_eq!(swapped_inverted.cam, inverted.cam);
assert_ne!(swapped_inverted.cam, reference.cam);
// The same for the axis's speed.
let mut slowed = app();
slowed.preferences.space_mouse_axis_speed[1] = 0.5;
frame(&mut slowed, &ctx, DriverMapping::default(), PUSH, 0.02);
let mut swapped_slowed = app();
swapped_slowed.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5];
swapped_slowed.preferences.space_mouse_axis_speed[1] = 0.5;
frame(&mut swapped_slowed, &ctx, DriverMapping::default(), press, 0.02);
assert_eq!(swapped_slowed.cam, slowed.cam);
assert_ne!(swapped_slowed.cam, reference.cam);
}
#[test]
fn a_motion_swapped_into_a_row_the_driver_turns_off_moves_nothing_until_swapped_back() {
let ctx = window(true);
let mut pan_up_down_off = DriverMapping::default();
pan_up_down_off.axes[2].enabled = false;
let mut slowed = app();
slowed.preferences.space_mouse_axis_speed[1] = 0.5;
frame(&mut slowed, &ctx, pan_up_down_off, PUSH, 0.02);
// Picking the push motion in the off row swaps the off row's motion into row 1.
let mut app = app();
app.preferences.space_mouse_axis_speed[1] = 0.5;
reassign(&mut app.preferences.space_mouse_axis_source, 2, 1);
assert_eq!(app.preferences.space_mouse_axis_source, [0, 2, 1, 3, 4, 5]);
let still = app.cam;
assert_ne!(slowed.cam, still);
frame(&mut app, &ctx, pan_up_down_off, PUSH, 0.02);
assert_eq!(app.cam, still);
// Picking it back in row 1 recovers it, at the speed row 1 kept.
reassign(&mut app.preferences.space_mouse_axis_source, 1, 1);
assert_eq!(app.preferences.space_mouse_axis_source, SAME_AXES);
frame(&mut app, &ctx, pan_up_down_off, PUSH, 0.02);
assert_eq!(app.cam, slowed.cam);
}
#[test]
fn the_choice_is_between_calibrated_motions_on_a_device_whose_calibration_shuffles_its_channels() {
// `homemade()` reads Y from raw channel 4, reversed, and Ry from raw 1. With Y and Z
// swapped, the choices are calibrated motions: pressing the cap (calibrated Z, raw 2)
// drives Y, and pushing it back (calibrated Y, raw 4 the other way) drives Z.
let ctx = window(true);
let device = "hid 1234:5678";
let setup = |source: [usize; 6]| {
let mut app = app();
app.space_mouse.device = Some(device.into());
app.preferences.space_mouse_calibrations.insert(device.into(), homemade());
app.preferences.space_mouse_axis_source = source;
app
};
// Mixed motion: a press and a push back together, on the device's own channels.
let mixed = Axes { translation: [0, 0, 1750], rotation: [0, -3500, 0] };
let expected = Axes { translation: [0, 175, 350], rotation: [0; 3] };
let mut reference = app();
frame(&mut reference, &ctx, DriverMapping::default(), expected, 0.02);
let mut swapped = setup([0, 2, 1, 3, 4, 5]);
assert!(frame(&mut swapped, &ctx, DriverMapping::default(), mixed, 0.02));
assert_eq!(swapped.cam, reference.cam, "calibrated first, then the motions swapped, once");
// Saved and loaded, it's the same choice; reset, the device reads as calibrated.
let saved = serde_json::to_value(&swapped.preferences).unwrap();
assert_eq!(crate::prefs::lenient(&saved).space_mouse_axis_source, [0, 2, 1, 3, 4, 5]);
let mut unswapped_reference = app();
frame(&mut unswapped_reference, &ctx, DriverMapping::default(), Axes { translation: [0, 350, 175], rotation: [0; 3] }, 0.02);
let mut reset = setup(SAME_AXES);
frame(&mut reset, &ctx, DriverMapping::default(), mixed, 0.02);
assert_eq!(reset.cam, unswapped_reference.cam);
}
#[test]
fn rows_say_what_they_do_to_the_view_through_the_mapping_navigation_uses() {
let labels = |mapping| (0..6).map(|row| row_label(mapping, row)).collect::<Vec<_>>();
assert_eq!(labels(Some(DriverMapping::default())), VIEW_AXES);
// Linux, and Windows without a 3DxWare profile: object mode swaps the last two rows'
// effect, so their labels swap with it.
let object = labels(Some(DriverMapping::object_mode()));
assert_eq!((object[4], object[5]), (VIEW_AXES[5], VIEW_AXES[4]));
// An axis the mapping switches off says so.
let mut off = DriverMapping::default();
off.axes[2].enabled = false;
assert_eq!(row_label(Some(off), 2), "ui.space_mouse_view_off");
// Without a mapping, rows claim no view effect and name none of the dropdown's motions.
assert_eq!(labels(None), ["ui.space_mouse_view_unmapped"; 6]);
}
#[test]
fn each_frame_records_the_mapping_it_navigated_with() {
let ctx = window(true);
let mut app = app();
assert!(app.space_mouse.mapping.is_none());
frame(&mut app, &ctx, DriverMapping::object_mode(), Axes::default(), 0.02);
assert_eq!(app.space_mouse.mapping, Some(DriverMapping::object_mode()));
// A changed profile shows on the next frame, calibrating or not; a reassignment changes
// which motion drives a row, never the row's label.
app.space_mouse.device = Some("hid 1234:5678".into());
app.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5];
crate::space_mouse_calibration::start(&mut app);
frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.02);
assert_eq!(app.space_mouse.mapping, Some(DriverMapping::default()));
assert_eq!(row_label(app.space_mouse.mapping, 1), VIEW_AXES[1]);
}
#[test]
fn calibrating_records_the_device_before_the_axis_choice() {
// With Y and Z swapped, a new calibration still learns the device's own channels.
// Recorded after the swap, it would bake the swap in, and the swap would then apply twice.
let ctx = window(true);
let mut app = app();
app.space_mouse.device = Some("hid 1234:5678".into());
app.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5];
crate::space_mouse_calibration::start(&mut app);
let feed = |app: &mut SolveApp, axes: Axes, seconds: f64| {
for _ in 0..(seconds / 0.02).round() as usize {
frame(app, &ctx, DriverMapping::default(), axes, 0.02);
}
};
feed(&mut app, Axes::default(), 1.6);
for step in solidcraft_space_mouse::STEPS {
let mut raw = [0_i16; 6];
raw[step.axis] = if step.positive { 300 } else { -300 };
feed(&mut app, raw.into(), 0.5);
feed(&mut app, Axes::default(), 0.1);
}
let (_, wizard) = app.space_mouse.calibrating.as_ref().unwrap();
let calibration = wizard.result().unwrap().unwrap();
assert!(calibration.axes.iter().enumerate().all(|(c, axis)| axis.source == c && !axis.reversed), "{calibration:?}");
}
#[test]
fn a_saved_axis_assignment_that_doesnt_drive_each_axis_once_loads_as_the_default() {
let mut saved = serde_json::to_value(crate::prefs::Prefs::default()).unwrap();
saved["space_mouse_axis_source"] = serde_json::json!([0, 2, 1, 3, 4, 5]);
assert_eq!(crate::prefs::lenient(&saved).space_mouse_axis_source, [0, 2, 1, 3, 4, 5]);
saved["space_mouse_axis_source"] = serde_json::json!([1, 1, 2, 3, 4, 5]);
assert_eq!(crate::prefs::lenient(&saved).space_mouse_axis_source, SAME_AXES);
// An older file without it, or one with the wrong length, loads with the default and
// keeps every other setting.
saved["space_mouse_speed"] = serde_json::json!(2.0);
saved.as_object_mut().unwrap().remove("space_mouse_axis_source");
let older = crate::prefs::lenient(&saved);
assert_eq!((older.space_mouse_axis_source, older.space_mouse_speed), (SAME_AXES, 2.0));
saved["space_mouse_axis_source"] = serde_json::json!([0, 1, 2, 3, 4]);
let short = crate::prefs::lenient(&saved);
assert_eq!((short.space_mouse_axis_source, short.space_mouse_speed), (SAME_AXES, 2.0));
}
}
@@ -98,9 +98,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
(Stage::Done, Some(Ok(_))) => "ui.space_mouse_calibration_done",
(Stage::Done, _) => "ui.space_mouse_calibration_no_response",
};
// The axis that barely moved, as Preferences names it.
// The calibrated motion that barely moved, as Preferences names it.
let weak = match result {
Some(Err(Problem::NoResponse(axis))) => crate::prefs::SPACE_MOUSE_AXES.get(axis).copied(),
Some(Err(Problem::NoResponse(axis))) => crate::space_mouse::MOTIONS.get(axis).copied(),
_ => None,
};
let counter = match stage {
@@ -116,7 +116,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
ui.label(app.tr(prompt));
if let Some(axis) = weak {
ui.label(egui::RichText::new(app.tr_label(axis)).strong());
ui.label(egui::RichText::new(app.tr(axis)).strong());
}
ui.add_space(6.0);
crate::ui_kit::progress(ui, "space_mouse_calibration", progress as f32);
@@ -128,6 +128,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// The device's six raw axes as they come, so the jitter at rest is there to see.
let reading = raw.map(|value| format!("{value:>6}")).join(" ");
ui.label(egui::RichText::new(format!("{}: {reading}", app.tr("ui.space_mouse_reading"))).monospace());
// A reassignment made in Preferences stays on top of whatever this records.
if app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES {
ui.label(egui::RichText::new(app.tr("ui.space_mouse_axes_reassigned")).small());
}
ui.horizontal(|ui| {
ui.label(app.tr("ui.space_mouse_dead_zone"));
dead_zone_slider(ui, &mut dead_zone).on_hover_text(app.tr("ui.space_mouse_dead_zone_tip"));
+3 -7
View File
@@ -1080,7 +1080,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
// Only the family's default pin follows the part's choice. Explicitly pinned
// variants remain exact commands, including in customised toolbars.
let family = crate::sketch_families::family(c.id).filter(|f| sketch_create && f.commands.first().copied() == Some(c.id));
let selected = family.and_then(|f| app.sketch_choices.selected(f)).unwrap_or(c);
let selected = family.and_then(|f| app.preferences.sketch_variants.selected(f)).unwrap_or(c);
let whole = Rect::from_min_size(pos2(button_x, top + 2.0), vec2(button_width, BUTTON_HEIGHT));
button_x += button_width + BUTTON_GAP;
let mut br = whole;
@@ -1450,12 +1450,8 @@ fn assistant_button(app: &mut SolveApp, ui: &mut egui::Ui, at: Rect) {
ui.painter().circle_filled(c, 4.0, color);
}
AssistantMark::Busy => {
let phase = if app.preferences.look_at_anim {
ui.ctx().request_repaint();
ui.input(|i| i.time) as f32 * 3.0
} else {
0.0
};
ui.ctx().request_repaint();
let phase = ui.input(|i| i.time) as f32 * 3.0;
ui.painter().circle_filled(c, 6.0, ring);
let points = (0..=24)
.map(|i| {
-1
View File
@@ -13,7 +13,6 @@ fn moved_block(h: &mut Harness) {
h.app.run("solid.extrude", json!({"distance": 5})).unwrap();
let body = h.app.session.world_state().bodies[0].name.clone();
h.app.run("solid.move", json!({"bodies": [body], "translate": [500, 0, 0]})).unwrap();
h.app.preferences.look_at_anim = false;
h.settle();
}
+14 -62
View File
@@ -12,7 +12,7 @@ use solidcraft_engine::view::{GridPlane, colors, grid_plane, grid_step, sketch_l
use std::hash::{Hash, Hasher};
use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback};
use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag};
use crate::navigation::{CameraInput, NavCaps, orbit_drag};
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -132,8 +132,6 @@ pub struct ViewportState {
pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
pub orbit_free: Option<Camera>,
/// Spring toward the chosen orientation, including after the drag ends.
pub(crate) orbit_snap: Option<OrbitSnap>,
pub build_ms: f64,
/// Adaptive supersampling (#1288): the view drawn last (its view-projection and pixels) and
/// since when it has held still (app time, s).
@@ -2427,9 +2425,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|| (primary_nav.is_some() && primary_down)
|| mode.is_some()
|| app.viewport.axis_drag.is_some()
|| app.viewport.spin
|| app.viewport.orbit_snap.is_some()
|| app.cam_anim.is_some();
|| app.viewport.spin;
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
// The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a
// corner drawn from the origin is the fixed origin, which ends no two curves.
@@ -3751,14 +3747,7 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
if frame.z.abs() > 0.5 {
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
if app.preferences.look_at_anim {
app.cancel_view_animation();
app.viewport.orbit_free = None;
app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None });
ui.ctx().request_repaint();
} else {
app.animate_to(to);
}
app.animate_to(to);
}
}
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
@@ -5275,14 +5264,6 @@ mod tests {
app.viewport.spin = false;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
app.cam_anim = Some(solidcraft_engine::render::CameraAnim::new(app.cam, app.cam, 0.0));
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none());
}
app.cam_anim = None;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
}
/// After a close-up refines a body (#1320), the model draws it from the refined plan (on the
@@ -6255,17 +6236,13 @@ mod tests {
let before = h.app.cam;
for _ in 0..100 {
orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
}
for _ in 0..120 {
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
}
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false);
h.settle();
assert!((h.app.cam.yaw - 1.6).abs() < 1e-6);
h.frames(2);
assert!(h.app.viewport.orbit_free.is_none());
}
@@ -6286,49 +6263,33 @@ mod tests {
}
#[test]
fn camera_resets_cancel_pending_axis_motion() {
fn camera_resets_drop_the_unsnapped_orbit() {
for reset in 0..3 {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
let rect = h.app.viewport.rect.unwrap();
orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false);
assert!(h.app.viewport.orbit_snap.is_some());
assert!(h.app.viewport.orbit_free.is_some());
match reset {
0 => h.app.fit_view(),
1 => h.app.set_up_axis(solidcraft_engine::render::UpAxis::Y),
_ => crate::documents::new_design(&mut h.app),
}
let before = h.app.cam;
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
assert_eq!(h.app.cam.yaw, before.yaw);
assert_eq!(h.app.cam.pitch, before.pitch);
assert_eq!(h.app.cam.target, before.target);
assert!(h.app.viewport.orbit_snap.is_none());
assert!(h.app.viewport.orbit_free.is_none());
}
}
#[test]
fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() {
for fps in [30, 60, 120] {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view":"front"}));
let rect = h.app.viewport.rect.unwrap();
orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false);
assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera");
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25);
for _ in 0..fps * 2 {
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small");
}
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6);
assert!(h.app.viewport.orbit_snap.is_none());
}
fn axis_snap_is_applied_at_once() {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view":"front"}));
let rect = h.app.viewport.rect.unwrap();
orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false);
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6, "the snapped view shows in the same frame");
}
/// The origin pivot holds through a Shift drag that snaps to an axis: the spring eases the
/// view round without letting the origin slide on screen.
/// The origin pivot holds through a Shift drag that snaps to an axis: the snapped
/// view turns without letting the origin slide on screen.
#[test]
fn snapping_keeps_the_origin_still() {
let mut h = crate::scenario::Harness::new();
@@ -6338,15 +6299,9 @@ mod tests {
let rect = h.app.viewport.rect.unwrap();
let at = rect.center() + vec2(50.0, -20.0);
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), true, true);
assert_eq!(
h.app.viewport.orbit_snap.as_ref().and_then(|s| s.pivot).map(|(p, _)| p),
Some(Vec3::ZERO),
"the origin is the pivot while snapping"
);
let before = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
for _ in 0..30 {
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
let after = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
}
@@ -6444,7 +6399,6 @@ mod tests {
let before = projection(&h.app, rect).to_screen(pivot).unwrap();
for _ in 0..20 {
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
assert!(after.distance(before) < 2.0);
}
@@ -6454,7 +6408,6 @@ mod tests {
let before = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
assert!((h.app.cam.yaw - 1.76).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
assert!(h.app.viewport.orbit_snap.is_none());
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
assert_eq!(h.app.cam.yaw, before.yaw);
@@ -6476,7 +6429,6 @@ mod tests {
assert!((h.app.cam.pitch - expected.pitch).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
assert!(h.app.viewport.orbit_snap.is_none());
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"right","shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
@@ -116,12 +116,10 @@ fn fit_home_animation_and_manual_takeover_use_one_layout_solution() {
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.settle();
for view in ["fit", "home"] {
h.app.preferences.look_at_anim = false;
h.app.animate_view(view);
h.settle();
let immediate = h.app.cam;
h.app.cam.pan(50.0, 10.0, 400.0);
h.app.preferences.look_at_anim = true;
h.app.animate_view(view);
h.settle();
assert_eq!(h.app.cam, immediate);
@@ -143,7 +141,6 @@ fn measured_navigator_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
let mut h = Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.call("ui.prefs", json!({"navigator_style":style}));
h.app.preferences.look_at_anim = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.call("ui.prefs", json!({"ui_scale":scale}));
@@ -24,7 +24,6 @@ fn compass_keeps_signed_targets_hover_and_clicks_at_both_scales() {
let mut h = Harness::new();
h.call("ui.set", json!({"layout": mode}));
h.call("ui.prefs", json!({"ui_scale": scale}));
h.app.preferences.look_at_anim = false;
h.settle();
let outer = crate::scenario::rect_of("axis_view:outer").unwrap();
assert_eq!(outer.size(), vec2(116.0, 116.0));
@@ -1,74 +0,0 @@
[
{
"note": "Animating to Top or Bottom from iso, front or any orbit turns the view smoothly to the end: no frame (the last included) turns it more than 10 degrees."
},
{
"start": "solid.box"
},
{
"key": "Escape"
},
{
"view_anim": "top"
},
{
"view_anim": "home"
},
{
"view_anim": "bottom"
},
{
"call": "ui.view",
"params": {
"view": "front"
}
},
{
"view_anim": "top"
},
{
"call": "ui.view",
"params": {
"view": "front"
}
},
{
"view_anim": "bottom"
},
{
"camera": {
"yaw": 2.3,
"pitch": 0.35
}
},
{
"view_anim": "top"
},
{
"camera": {
"yaw": -1.1,
"pitch": -0.4
}
},
{
"view_anim": "top"
},
{
"camera": {
"yaw": 2.3,
"pitch": 0.35
}
},
{
"view_anim": "bottom"
},
{
"camera": {
"yaw": 0.4,
"pitch": 1.2
}
},
{
"view_anim": "front"
}
]
@@ -19,7 +19,7 @@
{"call":"ui.documents","params":{"action":"new"}},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"click":{"handle":"button:sketch.rectangle.two_point"}},
{"expect":{"tool":"sketch.rectangle.two_point"}},
{"expect":{"tool":"sketch.rectangle.center"}},
{"key":"Escape"},
{"call":"ui.documents","params":{"action":"switch","index":0}},
{"click":{"handle":"button:sketch.rectangle.two_point"}},
@@ -1,5 +1,5 @@
[
{"call":"ui.prefs","params":{"look_at_anim":false}},
{"call":"ui.prefs","params":{}},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-30,10],[-15,10]]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-15,10],[-15,25]]}}},
+3 -2
View File
@@ -134,9 +134,10 @@ Imported themes are kept with your preferences, so they work in the browser app
vision modes still apply on top of any theme.
Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or
trackpad event immediately, with no smoothing tail. It is independent of **Animate view
changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during
trackpad event immediately, with no smoothing tail. Re-enabling smoothing during
a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed.
Every other view change (axis view, view cube, Look At, Home, Fit, named views, arrow keys)
and every orbit, pan and zoom drag applies at once, with no animation.
Navigation › **Alias** uses Alias's camera keys: Shift+Alt+left drag orbits, Shift+Alt+middle
drag pans and Shift+Alt+right drag zooms (drag down to move away, up to move closer). Without
+2 -2
View File
@@ -174,7 +174,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|---|---|---|
| `ui.inspect` | — | UI state, pending file picker (`file_picker_busy`; compatibility alias `file_dialog_open`, no path), what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) |
| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, `layout`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints`, `hiddenFolders` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme; `layout: "floating"` (default) or `"docked"` selects the transient shell layout without saving a preference. Scenarios use `{"call":"ui.set","params":{"layout":"docked"}}` |
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | |
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit` | |
| `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing |
| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes |
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input |
@@ -205,7 +205,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them), `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id), `theme_remove` (an imported theme's id), `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them), `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id), `theme_remove` (an imported theme's id), `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
-3
View File
@@ -56,9 +56,7 @@
| Angle decimals | 角度の小数点以下の桁数 | `ui.angle_decimals` |
| angle, height | 角度, 高さ | `angle, height` |
| Animate | アニメーション | `Animate` |
| Animate view changes | 視点変更をアニメーション表示 | `ui.animate_view_changes` |
| Animation | アニメーション | `ui.animation` |
| Animation speed | アニメーション速度 | `ui.animation_speed` |
| Anti-aliasing | アンチエイリアス | `ui.anti_aliasing` |
| Anti-aliasing applies the next time SolidCraft starts | アンチエイリアスは次回 SolidCraft を起動したときに適用されます | `settings.msaa_applies_on_restart` |
| Appearance | 外観 | `cmd.appearance_assign` |
@@ -86,7 +84,6 @@
| Axis Through Edge | エッジを通る軸 | `cmd.construct_axis_edge` |
| Axis Through Two Planes | 2平面を通る軸 | `cmd.construct_axis_two_planes` |
| Axis Through Two Points | 2点を通る軸 | `cmd.construct_axis_two_points` |
| Axis view, Look At, Home and Fit | 軸ビュー、正面視、ホーム、全体表示 | `ui.look_at_anim_desc` |
| Base point | 基点 | `ui.base_point` |
| Bend Line Position | 曲げ線位置 | `Bend Line Position` |
| Bend Position | 曲げ位置 | `Bend Position` |