Polish Linux startup and compact layouts; add managed local installation

This commit is contained in:
2026-09-09 19:25:02 +02:00
parent 8ca2f91b88
commit 1beb15dbc7
14 changed files with 1837 additions and 418 deletions
+49 -159
View File
@@ -1,176 +1,66 @@
# VernierCAD
A parametric solid modeller for Linux: sketch on a plane or a face, constrain
the sketch, extrude it, and edit any of it afterwards without the model falling
apart.
VernierCAD is a local Linux parametric solid modeller using Rust, an OCCT
kernel, and a native Wayland-capable winit window. It is GPL-3.0-or-later.
GPL-3.0-or-later. Rust workspace over an OCCT kernel reached through a thin C++
façade.
## Install a local release
> **Status, 2026-09-06.** Executing milestone M0 of `docs/PLAN_2026-09-06_shapr3d-competitor.md`. The core loop works end to end from the GUI —
> draw with a mouse, constrain, extrude, save, export — and this file says
> exactly what that does and does not include. It is written to be believed:
> where something is missing it says so rather than leaving you to find out.
> `PROJECT_BRIEF.md` is the design record and the authority on intent;
> `NAMING.md` is the topological-naming rules; `MISTAKES.md` is the ledger of
> what has already gone wrong; `SOLVER.md` covers the constraint solver;
> `FUSION_LOG.md` tracks what Fusion 360 and Shapr3D express that this cannot.
---
## Running it
Provide a directory containing executable `vernier-app` and `vernier-worker`,
then install it without modifying the source checkout:
```sh
cargo build --release -p vernier-app --bins
WINIT_UNIX_BACKEND=x11 cargo run --release -p vernier-app
scripts/install-local.sh /path/to/release 0.0.1-alpha "$(git rev-parse HEAD)"
~/.local/bin/vernier
```
**The `WINIT_UNIX_BACKEND=x11` is not optional if anything other than a human
will touch the window.** Under a native Wayland backend the window is
*invisible to `xdotool`* — no window id, no synthetic clicks, no screenshots —
so every automated or scripted GUI check silently has nothing to drive. It
looks exactly like the app failing to start. Set it and the window is an X11
client under XWayland, which those tools can see. Launching by hand for your own
use works either way; set it anyway so the two cases do not diverge.
The installer stores binaries under
`~/.local/share/vernier/releases/<commit>`, atomically updates `current`, keeps
the prior target as `previous`, and writes a desktop entry. It checks executable
bits and missing shared libraries with `ldd`. Existing user data and releases
owned by another commit are preserved. Use `--dry-run` to inspect destinations;
use `--prefix DIR` for a self-contained staging prefix.
Headless checks need no display at all:
The launcher does not depend on the checkout or a working directory. It keeps
the current log and one rotated log under
`${XDG_STATE_HOME:-~/.local/state}/vernier/logs`. It does not force an obsolete
X11 backend; native Wayland is the intended path.
## Basic workflow
The Projects dashboard opens on launch. Choose New for an empty managed project,
or Open to register an existing native document. Choose a sketch plane, draw a
closed profile, and extrude it. Select faces or edges for available modelling operations, then use
Save to write the native document. Open restores a saved document. Switching projects uses Save/Discard/Cancel;
Cancel leaves the current document untouched. Duplicate creates an independent
copy, and Archive hides a library entry without deleting its files. Recovery state is offered after an interrupted
session through the app's recovery controls.
The current alpha covers sketching, constraints, extrude, editing, direct
modelling, and the delivered multi-body core. Loft is end-to-end in the GUI;
advanced guided lofts, G1/G2 endpoint matching and periodic closure are not available. Back up both the `.vernier` document and its `.vernier.names` naming sidecar.
Managed projects and recovery stay under
`${XDG_STATE_HOME:-~/.local/state}/vernier/{projects,recovery}`; installation and
rollback do not move them.
To roll back, stop VernierCAD, inspect `~/.local/share/vernier/previous`, and
replace `current` with that managed target atomically, for example:
```sh
cargo test --workspace # every crate
cargo run --release -p vernier-cli -- --selftest # 45 end-to-end checks, JSON
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
root=${XDG_DATA_HOME:-$HOME/.local/share}/vernier
ln -sfn "$(readlink "$root/previous")" "$root/current.rollback"
mv -Tf "$root/current.rollback" "$root/current"
```
### Prerequisites
The same paths under `DIR/share/vernier` apply to a `--prefix DIR` install.
- Rust 1.96+ (edition 2024).
- OCCT development headers and libraries, plus a C++ toolchain — the
`vernier-occt-sys` crate compiles a façade against them.
- A Vulkan driver. The viewport is `wgpu`; the target is AMD RDNA3 via
Mesa/RADV, with lavapipe as the software rasterizer for CI.
## Development checks
---
For a source checkout, use the workspace's normal Rust commands:
## What you can actually do today
```sh
cargo test --workspace
cargo run --release -p vernier-cli -- --selftest
```
Everything below is reachable **from the GUI**, with no CLI and no hand-edited
files. The step-by-step walkthrough lives on kanban card `67509e31`; this is the
summary.
**Sketching.** Pick a planar face (or nothing, for world XY) and press
*Sketch*. A banner tells you that you are sketching, which tool is armed, and
that Escape leaves. Five tools:
| Tool | Clicks |
|---|---|
| Select | click a curve or a point to select it — without leaving the sketch |
| Line | click, click, click; end on the first point to close the outline |
| Circle | centre, then a point on the rim |
| Arc | centre, start, end — counter-clockwise; start on the chain to join it |
| Point | one click, one point |
Sketch geometry is **drawn in the viewport** and clickable there. Ctrl extends a
selection, exactly as it does for faces.
**Constraints — all seventeen are reachable.** Horizontal and Vertical on one
line; Parallel, Perpendicular, Equal and an Angle dimension on two; a Length
dimension on one line; Coincident, a Distance dimension and Point-on-line using
picked points; Lock and Unlock to pin a point at typed coordinates; Radius on a
circle; Midpoint, Collinear, Concentric and Symmetric; and tangency between a
line and a circle or between two circles/arcs. Dimensions **commit when you press the button**, not on Enter or on
blur — the same rule as every other numeric field in the app.
**Solids.** A closed outline — a circle, a rectangle, or a mixed line-and-arc
shape with rounded ends — reports itself closed and can be extruded. Then
push/pull a face, fillet or chamfer an edge, shell, draft, pattern, mirror or
section, each on a plane you choose.
**Documents.** Save and open the native JSON format; export binary STL or STEP.
Undo and redo, on buttons and on Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y. The title bar
shows an asterisk while there is unsaved work, and *Open* asks before it
discards any.
### Navigation
Right-drag orbits, middle-drag pans, the wheel zooms, and **left-click only
ever selects**. A left-drag does nothing to the camera; that is deliberate, not
a hang.
---
## What you cannot do yet
Stated plainly, because finding these by trial costs more than reading them:
- **No file browser.** Save, Open and the exports take a path typed into a text
field. A native dialog is a dependency decision nobody has made yet.
- **No Ctrl+S.** File actions are buttons only; undo is the one thing with key
bindings.
- **Undo never clears the unsaved marker**, even if you step back to exactly
the state you saved. Knowing it had would need a marker on the undo stack;
erring toward "unsaved" is the safe direction.
- **Arcs cannot be dimensioned**, and an arc's own radius is derived from its
points rather than being a constraint you can type.
- **Over-constraint reporting names the conflict, not the intent.** An
impossible constraint is refused whole, nothing changes, and the message now
lists the dependent constraints the solver found — by their position in the
sketch's constraint list, not by a friendly label
(`crates/vernier-doc/src/sketch.rs:2805-2814`). What it cannot tell you is
which of them you *meant*: the set is mutually inconsistent and the solver
has no way to rank one member of it as the mistake.
- **The dogfooding milestones (L0–L2) have not been driven end to end by a
person.** Automated coverage is good; that is a different claim.
---
## Repository map
| Crate | What it owns |
|---|---|
| `vernier-occt-sys` | The C++ façade over OCCT and its `cxx` bridge. Opaque integer handles, never `Handle<T>`. |
| `vernier-kernel` | Safe Rust over the façade: feature evaluation, booleans, the topological-naming tables. |
| `vernier-doc` | The document — features, the sketch model, commands, undo/redo. No kernel, no GPU. |
| `vernier-solver` | The constraint solver: sparse Jacobian, Levenberg–Marquardt, seventeen constraints. |
| `vernier-ui` | The recompute orchestrator, the egui shell, the sketch canvas, picking. Sees doc and kernel; owns no window. |
| `vernier-render` | `wgpu` viewport: grid, shaded part, sketch overlay, ID-buffer picking, headless render. |
| `vernier-tess` | Tessellation and STL writing. |
| `vernier-app` | The winit window, the document server thread, and the event loop that ties it together. |
| `vernier-cli` | Headless entry points, including `--selftest`. |
The dependency arrows point downward: `vernier-ui` may see both `vernier-doc`
and `vernier-kernel`; neither may see the other.
---
## The two ideas worth knowing before reading the code
**Topological naming is the whole point.** Entity ids are allocated by the
*document* and persisted in naming tables, so a fillet still names the same edge
after the sketch beneath it is edited. `NAMING.md` is the rulebook. The tables
are keyed by feature identity and are purely additive, which is why undo needs
no separate rollback of them — measured, not assumed: a recompile against a
*fresh* store renumbers every face.
**The kernel never blocks the render thread.** Edits go to a document server on
its own thread and come back as a scene snapshot. A slow boolean cannot freeze
the viewport.
---
## Tests and gates
`cargo test --workspace` runs everything; `--selftest` runs 45 end-to-end
checks with a JSON report, including golden geometry and a recompute-cost
gauge. Screenshot and picking tests render through the production pipeline and
read the framebuffer back.
Two conventions this project has paid for and keeps:
- **Assert the geometry, not that a command returned `Ok`.** A constraint that
is stored and ignored returns `Ok`, adds a row, grows the document and leaves
the sketch unchanged. Only numbers tell those apart.
- **Every volume assertion measures the UNION, not the profile.** An extrude
fuses with the existing body, so a profile drawn over it contributes only the
parts sticking out. Put test geometry clear of the starter block, or expect a
plausible wrong number.
The OCCT development libraries, a C++ toolchain, and a Vulkan driver are needed
for a full build and viewport run.
+14
View File
@@ -158,7 +158,21 @@ impl Graphics {
let readback = std::env::var_os("VERNIER_READBACK").map(std::path::PathBuf::from);
let attributes = Window::default_attributes()
.with_title("VernierCAD")
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
.with_min_inner_size(winit::dpi::LogicalSize::new(1000.0, 640.0))
.with_visible(readback.is_none());
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
attributes,
"vernier",
"VernierCAD",
);
#[cfg(target_os = "linux")]
let attributes = winit::platform::x11::WindowAttributesExtX11::with_name(
attributes,
"vernier",
"VernierCAD",
);
let window = Arc::new(event_loop.create_window(attributes)?);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let surface = instance.create_surface(Arc::clone(&window))?;
+2 -1
View File
@@ -331,8 +331,9 @@ impl Headless {
/// [`HeadlessError::WorkerTimeout`] if the first compile overruns
/// [`DEFAULT_BUDGET`](Self::DEFAULT_BUDGET).
pub fn new(width: u32, height: u32) -> Result<Self, HeadlessError> {
let config =
let mut config =
crate::process_worker::WorkerConfig::packaged().map_err(HeadlessError::Worker)?;
config.initial = crate::process_worker::InitialState::Starter;
Self::build(width, height, config)
}
@@ -605,3 +605,19 @@ fn interrupted_step_import_restores_only_acknowledged_work_then_rebuilds() {
stop(&worker);
fs::remove_file(source).unwrap();
}
#[test]
fn packaged_startup_has_no_demonstration_geometry() {
let mut packaged = WorkerConfig::packaged().unwrap();
packaged.helper = config(None).helper;
let worker = ProcessWorker::spawn(packaged, || {}).unwrap();
let (_, initial) = ready(&worker);
assert!(initial.view.timeline.is_empty());
assert!(initial.view.bodies.is_empty());
assert!(!initial.view.unsaved);
let ServerReply::Scene(rebuilt) = reply(&worker, Edit::Recompute) else {
panic!("rebuild must return a scene");
};
assert!(rebuilt.error.is_none());
assert!(rebuilt.meshes.is_empty() && rebuilt.view.timeline.is_empty());
}
+1 -1
View File
@@ -64,7 +64,7 @@ impl WorkerConfig {
.parent()
.unwrap_or(Path::new("."))
.join("vernier-worker"),
initial: InitialState::Starter,
initial: InitialState::Empty,
policy: WorkerPolicy::default(),
fault: None,
})
+50
View File
@@ -1344,6 +1344,56 @@ impl Runner {
}
}
Target::BodyTool { label } => {
if !self
.headless
.shell()
.body_tool_controls
.iter()
.any(|hit| hit.control == *label)
{
if matches!(
label.as_str(),
"items:new folder name" | "items:new folder" | "items:move selected"
) && self.last.unique_text("Folder actions").is_none()
{
let rect = self.last.unique_text("Organise items").ok_or_else(|| {
refuse("Organise items disclosure is not uniquely painted".into())
})?;
self.click(
to_pixel(rect.center()),
Button::Primary,
false,
false,
false,
)?;
self.step(vec![])?;
}
let disclosure = match label.as_str() {
"items:new folder name" | "items:new folder" => Some("Folder actions"),
"items:group name" | "items:save group" => Some("Selection groups"),
_ => None,
};
if let Some(caption) = disclosure.filter(|_| {
!self
.headless
.shell()
.body_tool_controls
.iter()
.any(|hit| hit.control == *label)
}) {
let rect = self.last.unique_text(caption).ok_or_else(|| {
refuse(format!("{caption} disclosure is not uniquely painted"))
})?;
self.click(
to_pixel(rect.center()),
Button::Primary,
false,
false,
false,
)?;
self.step(vec![])?;
}
}
let hits: Vec<_> = self
.headless
.shell()
+260 -215
View File
@@ -390,80 +390,107 @@ pub(super) fn panel(
egui::Frame::NONE
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
let root_drop = ui.button("Move to root");
if root_drop.clicked() && state.items.dragging.is_none() {
state.items.drop_folder = None;
state.items.new_folder_parent = None;
}
if root_drop.hovered()
&& ui.input(|input| input.pointer.any_released())
&& let Some(drag) = state.items.dragging.take()
{
let edit = match drag {
DragItem::Item(item) => OrganisationEdit::PlaceItem { item, parent: None },
DragItem::Folder(id) => OrganisationEdit::MoveFolder { id, parent: None },
};
submit(state, response, edit);
}
ui.horizontal(|ui| {
ui.label("Search");
ui.add(egui::TextEdit::singleline(&mut state.items.search).desired_width(100.0));
egui::ComboBox::from_id_salt("items-filter")
.selected_text(format!("{:?}", state.items.filter))
.show_ui(ui, |ui| {
ui.selectable_value(&mut state.items.filter, ItemFilter::All, "All");
ui.selectable_value(&mut state.items.filter, ItemFilter::Bodies, "Bodies");
ui.selectable_value(
&mut state.items.filter,
ItemFilter::Sketches,
"Sketches",
);
ui.selectable_value(&mut state.items.filter, ItemFilter::Planes, "Planes");
});
});
ui.horizontal(|ui| {
let name = ui.add(
egui::TextEdit::singleline(&mut state.items.new_folder_name)
.desired_width(100.0),
);
record(ui, state, "items:new folder name", &name);
let create = ui.button("New folder");
record(ui, state, "items:new folder", &create);
if create.clicked() {
let name = state.items.new_folder_name.trim().to_owned();
if !name.is_empty() {
submit(
state,
response,
OrganisationEdit::CreateFolder {
name,
parent: state.items.new_folder_parent,
},
);
state.items.new_folder_name.clear();
egui::CollapsingHeader::new("Organise items")
.default_open(false)
.show(ui, |ui| {
let root_drop = ui.button("Move to root");
if root_drop.clicked() && state.items.dragging.is_none() {
state.items.drop_folder = None;
state.items.new_folder_parent = None;
}
}
});
let move_button = ui.button("Move selected into folder");
record(ui, state, "items:move selected", &move_button);
if move_button.clicked() {
let item = if state.body_selection_active {
state.selected_body.map(ItemRef::Body)
} else {
state.selected_feature.map(ItemRef::Sketch)
};
if let Some(item) = item {
submit(
state,
response,
OrganisationEdit::PlaceItem {
item,
parent: state.items.drop_folder,
},
);
}
}
if root_drop.hovered()
&& ui.input(|input| input.pointer.any_released())
&& let Some(drag) = state.items.dragging.take()
{
let edit = match drag {
DragItem::Item(item) => {
OrganisationEdit::PlaceItem { item, parent: None }
}
DragItem::Folder(id) => {
OrganisationEdit::MoveFolder { id, parent: None }
}
};
submit(state, response, edit);
}
ui.horizontal(|ui| {
ui.label("Search");
ui.add(
egui::TextEdit::singleline(&mut state.items.search)
.desired_width(100.0),
);
egui::ComboBox::from_id_salt("items-filter")
.selected_text(format!("{:?}", state.items.filter))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut state.items.filter,
ItemFilter::All,
"All",
);
ui.selectable_value(
&mut state.items.filter,
ItemFilter::Bodies,
"Bodies",
);
ui.selectable_value(
&mut state.items.filter,
ItemFilter::Sketches,
"Sketches",
);
ui.selectable_value(
&mut state.items.filter,
ItemFilter::Planes,
"Planes",
);
});
});
egui::CollapsingHeader::new("Folder actions")
.default_open(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
let name = ui.add(
egui::TextEdit::singleline(&mut state.items.new_folder_name)
.desired_width(100.0),
);
record(ui, state, "items:new folder name", &name);
let create = ui.button("New folder");
record(ui, state, "items:new folder", &create);
if create.clicked() {
let name = state.items.new_folder_name.trim().to_owned();
if !name.is_empty() {
submit(
state,
response,
OrganisationEdit::CreateFolder {
name,
parent: state.items.new_folder_parent,
},
);
state.items.new_folder_name.clear();
}
}
});
});
let move_button = ui.button("Move selected into folder");
record(ui, state, "items:move selected", &move_button);
if move_button.clicked() {
let item = if state.body_selection_active {
state.selected_body.map(ItemRef::Body)
} else {
state.selected_feature.map(ItemRef::Sketch)
};
if let Some(item) = item {
submit(
state,
response,
OrganisationEdit::PlaceItem {
item,
parent: state.items.drop_folder,
},
);
}
}
});
egui::ScrollArea::vertical()
.max_height(180.0)
.show(ui, |ui| {
@@ -506,128 +533,134 @@ pub(super) fn panel(
}
}
});
ui.separator();
ui.label("Selection groups");
let group_name = ui.add(
egui::TextEdit::singleline(&mut state.items.group_name)
.desired_width(180.0)
.hint_text("Group name"),
);
record(ui, state, "items:group name", &group_name);
for group in &view.organisation.groups {
ui.horizontal(|ui| {
ui.label(&group.name);
if ui.button("Apply").clicked() {
let available: Vec<_> = group
.items
.iter()
.copied()
.filter(|item| match item {
ItemRef::Body(id) => view.bodies.iter().any(|row| {
row.id == *id && row.status == BodyStatus::Live && row.visible
}),
ItemRef::Sketch(id) | ItemRef::Plane(id) => {
!view.needs_rebuild
&& view.timeline.iter().any(|row| row.id == *id)
&& view
.history
.rows
.iter()
.find(|row| row.feature == *id)
.is_none_or(|row| row.state == HistoryStatus::Live)
&& view.organisation.visible(*item)
egui::CollapsingHeader::new("Selection groups")
.default_open(false)
.show(ui, |ui| {
let group_name = ui.add(
egui::TextEdit::singleline(&mut state.items.group_name)
.desired_width(180.0)
.hint_text("Group name"),
);
record(ui, state, "items:group name", &group_name);
for group in &view.organisation.groups {
ui.horizontal(|ui| {
ui.label(&group.name);
if ui.button("Apply").clicked() {
let available: Vec<_> = group
.items
.iter()
.copied()
.filter(|item| match item {
ItemRef::Body(id) => view.bodies.iter().any(|row| {
row.id == *id
&& row.status == BodyStatus::Live
&& row.visible
}),
ItemRef::Sketch(id) | ItemRef::Plane(id) => {
!view.needs_rebuild
&& view.timeline.iter().any(|row| row.id == *id)
&& view
.history
.rows
.iter()
.find(|row| row.feature == *id)
.is_none_or(|row| {
row.state == HistoryStatus::Live
})
&& view.organisation.visible(*item)
}
})
.collect();
state.items.selected_items = available.clone();
if available
.iter()
.all(|item| matches!(item, ItemRef::Body(_)))
{
let live: Vec<_> = available
.iter()
.filter_map(|item| {
if let ItemRef::Body(id) = item {
Some(*id)
} else {
None
}
})
.collect();
state.selected_body = live.first().copied();
state.body_tools = live.iter().skip(1).copied().collect();
state.body_selection_active = !live.is_empty();
state.selected_feature = None;
state.mirror_extra.clear();
} else {
state.body_selection_active = false;
let sketches: Vec<_> = available
.iter()
.filter_map(|item| match item {
ItemRef::Sketch(id) | ItemRef::Plane(id) => Some(*id),
_ => None,
})
.collect();
state.selected_feature = sketches.first().copied();
state.mirror_extra = sketches.iter().skip(1).copied().collect();
}
})
.collect();
state.items.selected_items = available.clone();
if available
.iter()
.all(|item| matches!(item, ItemRef::Body(_)))
{
let live: Vec<_> = available
.iter()
.filter_map(|item| {
if let ItemRef::Body(id) = item {
Some(*id)
state.picked_faces.clear();
state.picked_edges.clear();
state.picked_vertices.clear();
state.picked_curves.clear();
state.picked_points.clear();
state.picked_dimension = None;
response.selection_changed = true;
}
if ui.button("Delete").clicked() {
submit(
state,
response,
OrganisationEdit::DeleteGroup { id: group.id },
);
}
});
}
let save_group = ui.button("Save current selection group");
record(ui, state, "items:save group", &save_group);
if save_group.clicked() {
let items = if state.body_selection_active {
state
.selected_body
.into_iter()
.map(ItemRef::Body)
.chain(state.body_tools.iter().copied().map(ItemRef::Body))
.collect()
} else {
state
.selected_feature
.into_iter()
.chain(state.mirror_extra.iter().copied())
.filter(|id| {
view.timeline
.iter()
.any(|row| row.id == *id && row.kind == FeatureKind::Sketch)
})
.map(|id| {
if state.items.selected_items.contains(&ItemRef::Plane(id)) {
ItemRef::Plane(id)
} else {
None
ItemRef::Sketch(id)
}
})
.collect();
state.selected_body = live.first().copied();
state.body_tools = live.iter().skip(1).copied().collect();
state.body_selection_active = !live.is_empty();
state.selected_feature = None;
state.mirror_extra.clear();
} else {
state.body_selection_active = false;
let sketches: Vec<_> = available
.iter()
.filter_map(|item| match item {
ItemRef::Sketch(id) | ItemRef::Plane(id) => Some(*id),
_ => None,
})
.collect();
state.selected_feature = sketches.first().copied();
state.mirror_extra = sketches.iter().skip(1).copied().collect();
}
state.picked_faces.clear();
state.picked_edges.clear();
state.picked_vertices.clear();
state.picked_curves.clear();
state.picked_points.clear();
state.picked_dimension = None;
response.selection_changed = true;
}
if ui.button("Delete").clicked() {
.collect()
};
submit(
state,
response,
OrganisationEdit::DeleteGroup { id: group.id },
OrganisationEdit::SaveGroup {
id: None,
name: state.items.group_name.trim().into(),
items,
},
);
}
});
}
let save_group = ui.button("Save current selection group");
record(ui, state, "items:save group", &save_group);
if save_group.clicked() {
let items = if state.body_selection_active {
state
.selected_body
.into_iter()
.map(ItemRef::Body)
.chain(state.body_tools.iter().copied().map(ItemRef::Body))
.collect()
} else {
state
.selected_feature
.into_iter()
.chain(state.mirror_extra.iter().copied())
.filter(|id| {
view.timeline
.iter()
.any(|row| row.id == *id && row.kind == FeatureKind::Sketch)
})
.map(|id| {
if state.items.selected_items.contains(&ItemRef::Plane(id)) {
ItemRef::Plane(id)
} else {
ItemRef::Sketch(id)
}
})
.collect()
};
submit(
state,
response,
OrganisationEdit::SaveGroup {
id: None,
name: state.items.group_name.trim().into(),
items,
},
);
}
});
if ui.input(|input| input.pointer.any_released()) {
state.items.dragging = None;
@@ -642,37 +675,49 @@ mod tests {
#[test]
fn default_items_panel_paints_items_and_a_body_row() {
let (open, painted_body) = crate::testing::driven("items panel default", || {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let body = vernier_doc::BodyId::from_entity(serde_json::from_str("1").unwrap());
let view = SceneView {
bodies: vec![BodyRow {
id: body,
name: "body 1".to_owned(),
colour: [180, 180, 180],
own_visible: true,
visible: true,
status: BodyStatus::Live,
}],
..SceneView::default()
};
let mut state = ShellState::default();
let mut response = ShellResponse::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(262.0, 500.0));
let output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(screen),
..egui::RawInput::default()
},
|ui| {
panel(ui, &view, &mut state, &mut response);
},
);
output.drop_without_applying_deltas();
(state.items.open, !state.body_controls.is_empty())
});
let (open, painted_body, folder_editor_hidden) =
crate::testing::driven("items panel default", || {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let body = vernier_doc::BodyId::from_entity(serde_json::from_str("1").unwrap());
let view = SceneView {
bodies: vec![BodyRow {
id: body,
name: "body 1".to_owned(),
colour: [180, 180, 180],
own_visible: true,
visible: true,
status: BodyStatus::Live,
}],
..SceneView::default()
};
let mut state = ShellState::default();
let mut response = ShellResponse::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(262.0, 500.0));
let output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(screen),
..egui::RawInput::default()
},
|ui| {
panel(ui, &view, &mut state, &mut response);
},
);
output.drop_without_applying_deltas();
(
state.items.open,
!state.body_controls.is_empty(),
!state
.body_tool_controls
.iter()
.any(|control| control.control == "items:new folder name"),
)
});
assert!(open, "Items is open by default");
assert!(painted_body, "the default Items panel paints a body row");
assert!(
folder_editor_hidden,
"collapsed folder actions must not consume the default sidebar"
);
}
}
+2 -2
View File
@@ -3473,7 +3473,7 @@ fn title_bar(
ui.spacing_mut().item_spacing.x = 10.0;
brand_tile(ui, theme);
ui.label(
egui::RichText::new("vernier")
egui::RichText::new("VernierCAD")
.font(theme::caption(13.0))
.color(theme::INK),
);
@@ -3481,7 +3481,7 @@ fn title_bar(
// row after it is a control; this is the only decoration, so
// it is what a narrow window loses first rather than letting
// the row overlap itself.
if ui.available_width() > 620.0 {
if ui.max_rect().width() > 1500.0 {
ui.label(
egui::RichText::new(concat!(env!("CARGO_PKG_VERSION"), " · alpha"))
.font(theme::mono(11.0))
+35 -38
View File
@@ -1722,48 +1722,45 @@ pub(crate) fn status_line(
.frame(frame)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
ui.spacing_mut().item_spacing.x = 12.0;
let width = ui.available_width();
let path = if state.file_path.trim().is_empty() {
"~".to_owned()
"Unsaved document"
} else {
state.file_path.clone()
&state.file_path
};
ui.label(
egui::RichText::new("nils@vernier")
.font(theme::mono(11.0))
.color(theme.accent),
);
ui.label(
egui::RichText::new(path)
.font(theme::mono(11.0))
.color(theme::MUTE),
);
if !state.readout.is_empty() {
ui.label(
egui::RichText::new(&state.readout)
.font(theme::mono(11.0))
.color(theme::SLATE),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// WARNINGS IN THE ACCENT, and they are the one thing on
// this line that is allowed to be loud.
if view.warnings.is_empty() {
ui.label(
egui::RichText::new("no warnings")
let warnings = if view.warnings.is_empty() {
"no warnings".to_owned()
} else {
view.warnings.join("; ")
};
let warning_width = (width * 0.25).min(260.0);
let path_width = (width * 0.25).min(320.0);
let readout_width = (width - path_width - warning_width - 24.0).max(0.0);
for (text, span, colour) in [
(path, path_width, theme::MUTE),
(state.readout.as_str(), readout_width, theme::SLATE),
(
warnings.as_str(),
warning_width,
if view.warnings.is_empty() {
theme::MUTE
} else {
theme.accent
},
),
] {
ui.add_sized(
[span, ui.available_height()],
egui::Label::new(
egui::RichText::new(text)
.font(theme::mono(11.0))
.color(theme::MUTE),
);
} else {
for warning in &view.warnings {
ui.label(
egui::RichText::new(warning)
.font(theme::mono(11.0))
.color(theme.accent),
);
}
}
});
.color(colour),
)
.truncate(),
)
.on_hover_text(text);
}
});
});
}
@@ -76,7 +76,7 @@ pub(crate) fn settings_screen(ui: &mut egui::Ui, state: &mut ShellState) -> bool
ui.spacing_mut().item_spacing.x = 10.0;
brand_tile(ui, theme);
ui.label(
egui::RichText::new("vernier")
egui::RichText::new("VernierCAD")
.font(theme::caption(13.0))
.color(theme::INK),
);
+1 -1
View File
@@ -149,7 +149,7 @@ pub(super) fn saved_panel(
}
egui::ScrollArea::vertical()
.id_salt("saved-working-views")
.max_height(160.0)
.max_height(120.0)
.show(ui, |ui| {
for saved in &view.organisation.views {
ui.push_id(saved.id, |ui| {
+4
View File
@@ -321,3 +321,7 @@ and cargo run -q -p vernier-drive -- scripts/drive/align-replace-face.json \
--out target/drive/align-replace-face --require-adapter RADV
and cargo run -q -p vernier-drive -- scripts/drive/cubic-spline-edit.json \
--out target/drive/cubic-spline-edit --require-adapter RADV
# Local Linux modelling and project release workflow at laptop resolution.
and cargo run -q -p vernier-drive -- scripts/drive/local-linux-release.json \
--out target/drive/local-linux-release --require-adapter RADV
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
usage() { echo "usage: $0 [--dry-run] [--prefix DIR] RELEASE_DIR VERSION HEX_COMMIT" >&2; exit 2; }
dry_run=false; prefix=
while (($#)); do
case "$1" in
--dry-run) dry_run=true; shift;;
--prefix) (($# >= 2)) || usage; prefix=$2; shift 2;;
--help|-h) usage;;
-*) usage;;
*) break;;
esac
done
(( $# == 3 )) || usage
release_dir=$1; version=$2; commit=$3
[[ -d "$release_dir" && -f "$release_dir/vernier-app" && -f "$release_dir/vernier-worker" ]] || { echo "release directory must contain vernier-app and vernier-worker" >&2; exit 1; }
[[ "$commit" =~ ^[0-9a-fA-F]{7,64}$ ]] || { echo "commit must be a 7-64 character hexadecimal commit" >&2; exit 1; }
[[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || { echo "invalid version" >&2; exit 1; }
command -v ldd >/dev/null || { echo "ldd is required" >&2; exit 1; }
command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; }
declare -A hashes
for binary in vernier-app vernier-worker; do
source_bin=$release_dir/$binary
[[ -x "$source_bin" ]] || { echo "not executable: $source_bin" >&2; exit 1; }
set +e; ldd_output=$(ldd "$source_bin" 2>&1); ldd_status=$?; set -e
(( ldd_status == 0 )) || { echo "ldd failed for $binary: $ldd_output" >&2; exit 1; }
[[ "$ldd_output" != *"not found"* ]] || { echo "missing runtime library for $binary" >&2; exit 1; }
hashes[$binary]=$(sha256sum "$source_bin" | awk '{print $1}')
done
if [[ -n "$prefix" ]]; then
[[ "$prefix" = /* ]] || { echo "--prefix must be absolute" >&2; exit 1; }
data_root=$prefix/share; state_root=$prefix/state; bin_root=$prefix/bin; app_root=$prefix/share/applications
else
data_root=${XDG_DATA_HOME:-$HOME/.local/share}; state_root=${XDG_STATE_HOME:-$HOME/.local/state}; bin_root=${XDG_BIN_HOME:-$HOME/.local/bin}; app_root=$data_root/applications
fi
for directory in "$data_root" "$state_root" "$bin_root"; do
[[ "$directory" = /* && "$directory" != *$'\n'* ]] || { echo "installation paths must be absolute single-line paths" >&2; exit 1; }
done
root=$data_root/vernier; releases=$root/releases; target=$releases/$commit; current=$root/current; previous=$root/previous; launcher=$bin_root/vernier; desktop=$app_root/vernier.desktop
check_link() {
local link=$1 value
if [[ -e "$link" || -L "$link" ]]; then
[[ -L "$link" ]] || { echo "refusing non-symlink: $link" >&2; exit 1; }
value=$(readlink "$link")
[[ "$value" =~ ^releases/[0-9a-fA-F]{7,64}$ && -d "$root/$value" && ! -L "$root/$value" ]] || { echo "refusing unowned symlink: $link" >&2; exit 1; }
grep -Fqx "commit=${value#releases/}" "$root/$value/.vernier-release" || { echo "refusing unowned release pointer: $link" >&2; exit 1; }
fi
}
managed_launcher() { [[ -f "$1" && ! -L "$1" ]] && grep -Fqx '# VernierCAD managed launcher' "$1"; }
managed_desktop() { [[ -f "$1" && ! -L "$1" ]] && grep -Fqx 'X-VernierCAD-Managed=true' "$1"; }
check_link "$current"; check_link "$previous"
if [[ -e "$launcher" || -L "$launcher" ]]; then managed_launcher "$launcher" || { echo "refusing unowned launcher: $launcher" >&2; exit 1; }; fi
if [[ -e "$desktop" || -L "$desktop" ]]; then managed_desktop "$desktop" || { echo "refusing unowned desktop entry: $desktop" >&2; exit 1; }; fi
if [[ -e "$target" || -L "$target" ]]; then
[[ -d "$target" && ! -L "$target" && -f "$target/.vernier-release" ]] || { echo "refusing unowned release: $target" >&2; exit 1; }
grep -Fqx "commit=$commit" "$target/.vernier-release" || { echo "release commit ownership mismatch" >&2; exit 1; }
for binary in vernier-app vernier-worker; do
saved=$(awk -F= -v key="sha256_$binary" '$1==key {print $2}' "$target/.vernier-release")
[[ -f "$target/$binary" && -x "$target/$binary" && ! -L "$target/$binary" ]] || { echo "invalid installed binary: $binary" >&2; exit 1; }
actual=$(sha256sum "$target/$binary" | awk '{print $1}')
[[ "$actual" == "$saved" && "$saved" == "${hashes[$binary]}" ]] || { echo "existing release hash mismatch: $binary" >&2; exit 1; }
done
fi
if $dry_run; then echo "would install $release_dir as $target"; echo "would activate $current and preserve $previous"; exit 0; fi
mkdir -p "$releases" "$bin_root" "$app_root" "$state_root/vernier/logs"
stage=$(mktemp -d "$releases/.staging-$commit.XXXXXXXX"); trap 'rm -rf "$stage"' EXIT
if [[ ! -e "$target" ]]; then
mkdir "$stage/release"
cp -p "$release_dir/vernier-app" "$stage/release/vernier-app"; cp -p "$release_dir/vernier-worker" "$stage/release/vernier-worker"; chmod 755 "$stage/release/vernier-app" "$stage/release/vernier-worker"
for binary in vernier-app vernier-worker; do
staged_hash=$(sha256sum "$stage/release/$binary" | awk '{print $1}')
[[ "$staged_hash" == "${hashes[$binary]}" ]] || { echo "source changed while staging $binary" >&2; exit 1; }
done
printf 'version=%s\ncommit=%s\nsha256_vernier-app=%s\nsha256_vernier-worker=%s\n' "$version" "$commit" "${hashes[vernier-app]}" "${hashes[vernier-worker]}" >"$stage/release/.vernier-release"
fi
mkdir -p "$stage/bin" "$stage/applications"
cat >"$stage/bin/vernier" <<EOF
#!/usr/bin/env bash
# VernierCAD managed launcher
set -euo pipefail
current=$(printf '%q' "$current/vernier-app")
log_dir=$(printf '%q' "$state_root/vernier/logs")
[[ -x "\$current" ]] || { echo "VernierCAD is not installed" >&2; exit 1; }
mkdir -p "\$log_dir"
[[ ! -e "\$log_dir/vernier.log" ]] || mv -f "\$log_dir/vernier.log" "\$log_dir/vernier.log.1"
exec "\$current" "\$@" >>"\$log_dir/vernier.log" 2>&1
EOF
chmod 755 "$stage/bin/vernier"
# Desktop Exec uses desktop-entry quoting, not shell quoting.
desktop_exec=${launcher//\\/\\\\}
desktop_exec=${desktop_exec//\"/\\\"}
desktop_exec=${desktop_exec//\$/\\\$}
desktop_exec=${desktop_exec//\`/\\\`}
desktop_exec=${desktop_exec//%/%%}
# Escape the Exec value again for the desktop-file string layer.
desktop_exec=${desktop_exec//\\/\\\\}
cat >"$stage/applications/vernier.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=VernierCAD
Comment=Parametric solid modeller
Exec="$desktop_exec"
Icon=applications-graphics
StartupWMClass=vernier
Terminal=false
Categories=Graphics;3DGraphics;
X-VernierCAD-Managed=true
EOF
if [[ ! -e "$target" ]]; then mv "$stage/release" "$target"; fi
mv "$stage/bin/vernier" "$launcher.new.$$"; mv -f "$launcher.new.$$" "$launcher"
mv "$stage/applications/vernier.desktop" "$desktop.new.$$"; mv -f "$desktop.new.$$" "$desktop"
if [[ -L "$current" && "$(readlink "$current")" != "releases/$commit" ]]; then ln -sfn "$(readlink "$current")" "$previous.new.$$"; mv -Tf "$previous.new.$$" "$previous"; fi
ln -sfn "releases/$commit" "$current.new.$$"; mv -Tf "$current.new.$$" "$current"
rm -rf "$stage"
trap - EXIT
echo "installed VernierCAD $version ($commit)"