Polish Linux startup and compact layouts; add managed local installation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
Executable
+116
@@ -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)"
|
||||
Reference in New Issue
Block a user