Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 649656b092 Fix #369: restart the web build's graphics after a lost device or context
When the browser dropped the page's WebGPU device or WebGL context, the
editor kept running but nothing it drew reached the screen: Chrome and
WebKit showed a frozen, stale canvas and Firefox a blank page, while
edits and Save went on.

- ui-egui: new `graphics` module. `GraphicsLoss` carries a loss report
  from any thread (wgpu's device-lost callback, the canvas's
  `webglcontextlost` event) to the UI thread. `VectorcraftApp::
  graphics_lost` writes Data Recovery copies of the modified documents at
  once. The app now notices when the host hands it a new egui context and
  uploads everything again (fonts and theme, the canvas, place cursor
  thumbnails); every per-thread texture cache (layer, symbol, swatch,
  style, asset and glyph thumbnails, previews, font samples, Navigator,
  Flattener Preview) is a `TexCache` that empties for a new context.
  Without this, the moved app drew stale or wrong textures and egui
  panicked on its missing font families.
- web: the shell watches both loss signals; on a loss it puts a fresh
  canvas in place of the dead one and starts the same `WebRunner` again,
  handing over the same app (documents, undo, panels), with services made
  for the new context and a status-bar note. If no new graphics can be had
  (or after three losses) it removes the stale canvas, lets go of the
  recovery copies and says to reload.
- engine: `recovery::leave` lets go of a session's area but keeps its
  copies, so the reloaded page offers them at once instead of after the
  heartbeat goes stale.
- desktop: eframe can't give a window a new device, so a lost device
  (driver reset) is logged, the copies are written and the status bar says
  to save and restart; closing then skips the Save Changes question it
  can't show.
- Tests: loss reporting across threads, `TexCache` emptying, the app
  uploading the canvas, a cached preview, theme and fonts again in a new
  context (fails without the fix), copies written on loss, and
  `recovery::leave` offering copies at once with both lock and heartbeat
  stores. docs/development.md (web section) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:18:32 +07:00
BFlat 04643e1109 Merge pull request #404 from storytold/fix/issue-381
Fix #381: round single corners with Direct Selection (per-corner Live Corners)
2026-10-08 07:12:24 +07:00
BFlat 43c0df66a3 Merge pull request #401 from storytold/fix/issue-372
Part of #372: PDF/.ai import keeps strokes live and hidden and nested layers
2026-10-08 07:12:04 +07:00
BFlat a9ae2fd513 Merge pull request #402 from storytold/fix/issue-374
Fix #374: Shape Builder cuts regions with open paths and lines
2026-10-08 07:11:49 +07:00
BFlat 0a0357143d Merge pull request #407 from storytold/fix/batch-rollback-layer-rows
Engine: a failed batch gives back the highlighted Layers panel rows
2026-10-08 07:11:31 +07:00
bflatasticandClaude Opus 5.5 b05c14f8eb Fix #381: round single corners with Direct Selection (per-corner Live Corners)
A live rectangle stored four corner radii but drew them all with the largest,
and the corner widgets always rounded all four corners, so one corner could not
be rounded on its own.

- geom: rectangle_with_corners draws each corner with its own radius and kind
  (Round, Inverted Round, Chamfer); a sharp corner is one anchor, a cut one two.
  Uniform corners keep the exact rounded-rectangle anchors.
- doc: LiveShape::Rectangle gains `kinds` (saved only when a corner isn't round;
  older files read as round), plus helpers mapping anchors to corners.
- engine: object.setLiveShape takes `corners` and `kind`; without `corners` it
  edits the Direct-Selected corners, else all four, and keeps the selected
  corners selected as their anchors change.
- tools: with Direct Selection, only the selected corners show widgets and a
  drag rounds just those; Alt-click cycles the corner kind; double-click opens
  the new Corners dialog (also `ui.corners`).
- ui: Properties/Transform radius fields show and set the selected corners;
  CSS export keeps per-corner radii and skips cut-in corners.
- docs: mcp.md (Live Corners), control-protocol.md (Corners dialog), ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:56:32 +07:00
47 changed files with 1408 additions and 185 deletions

No files matched your search

+6 -6
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372) and per-corner Live Corners (#381); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
### Where we're lacking (in priority order)
@@ -113,7 +113,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
@@ -149,7 +149,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369).
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
@@ -158,7 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
@@ -190,7 +190,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type, Live Corners on plain paths and polygons (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
+219 -54
View File
@@ -1,12 +1,14 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
//! The browser shell: web `Services`, drag-and-drop, the eframe web runner, and starting again
//! with new graphics when the page loses them (#369).
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{Hold, RecoveryStore};
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
@@ -23,58 +25,123 @@ type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
/// How many times the page starts again with new graphics after losing them before it gives up
/// (a GPU that keeps failing).
const MAX_RESTARTS: u32 = 3;
/// What the page keeps while the app runs, across graphics restarts.
#[derive(Clone)]
struct Host {
runner: eframe::WebRunner,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
/// restart keeps them, so the tab stays the owner of its recovery area.
locks: Option<WebLocks>,
/// Graphics restarts so far.
restarts: u32,
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
};
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let drag = track_drag(&canvas);
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
place_inbox: Arc::default(),
drag: DragPos::default(),
locks,
restarts: 0,
};
run(host, canvas, None).await;
});
}
/// An app whose graphics were lost, on its way to new ones.
struct Moving {
app: VectorcraftApp,
/// Its unsaved changes are kept in recovery copies ([`VectorcraftApp::graphics_lost`]).
kept: bool,
}
/// Run the app on `canvas`: the `moving` one when starting again after losing the graphics, else
/// a new one.
async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Moving>) {
track_drag(&canvas, &host.drag);
let kept = moving.as_ref().map(|m| m.kept);
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let result = runner
.start(
canvas,
web_options(),
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
let loss = GraphicsLoss::default();
watch_canvas(&page, &loss, ctx);
if let Some(rs) = &cc.wgpu_render_state {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
let (loss, ctx) = (loss.clone(), ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
app
}
let inbox: Inbox = Arc::default();
let place_inbox: PlaceInbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone(), locks));
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>VectorCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
None => VectorcraftApp::new(Session::new(), services),
};
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
}),
)
.await;
match (result, kept) {
(Ok(()), _) => {
if let Some(el) = element(LOADING_ID) {
el.remove();
}
}
});
(Err(e), None) => notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]),
(Err(e), Some(kept)) => {
log::error!("vectorcraft-web: couldn't restart the graphics: {}", js_err(e));
give_up(slot.borrow_mut().take(), kept);
}
}
}
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
fn web_options() -> eframe::WebOptions {
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
options
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
/// get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
let pos = DragPos::default();
fn element(id: &str) -> Option<web_sys::Element> {
web_sys::window()?.document()?.get_element_by_id(id)
}
/// Follow file drags over `canvas` into `pos`: drag events carry the pointer position, which egui
/// doesn't get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement, pos: &DragPos) {
let p = pos.clone();
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
@@ -84,29 +151,123 @@ fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
log::error!("couldn't follow {kind} events: {e:?}");
}
}
// The listener lives as long as the page.
// The listener lives as long as the canvas.
on_drag.forget();
pos
}
/// Report the loss of `canvas`'s WebGL context to `loss` (WebGPU devices report theirs through
/// wgpu).
fn watch_canvas(canvas: &web_sys::HtmlCanvasElement, loss: &GraphicsLoss, ctx: &egui::Context) {
let (loss, ctx) = (loss.clone(), ctx.clone());
let on_lost = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
loss.report(&ctx, "the WebGL context was lost");
});
if let Err(e) = canvas.add_event_listener_with_callback("webglcontextlost", on_lost.as_ref().unchecked_ref()) {
log::error!("couldn't watch for a lost WebGL context: {e:?}");
}
// The listener lives as long as the canvas.
on_lost.forget();
}
/// A new canvas in place of `old`, whose graphics were lost (a canvas whose WebGL context is lost
/// can't get another).
fn fresh_canvas(old: &web_sys::HtmlCanvasElement) -> Result<web_sys::HtmlCanvasElement, String> {
let new: web_sys::HtmlCanvasElement = old.clone_node().map_err(js_err)?.dyn_into().map_err(|_| "not a canvas")?;
old.replace_with_with_node_1(&new).map_err(js_err)?;
Ok(new)
}
/// The graphics are lost for good: the dead canvas goes (it would show a stale picture), the app
/// (if still here) lets go of its recovery copies so the reloaded page offers them at once, and
/// the page says what to do.
fn give_up(app: Option<VectorcraftApp>, kept: bool) {
if let Some(mut app) = app {
recovery::leave(&mut app.session);
}
if let Some(canvas) = element(CANVAS_ID) {
canvas.remove();
}
notice(&[
"The graphics were lost and couldn't be restarted.",
if kept {
"Your unsaved changes are kept in this browser: reload the page to get them back."
} else {
"Reload the page to go on (changes not saved yet may be lost)."
},
]);
}
/// Show `lines` in the middle of the page (where the loading message was).
fn notice(lines: &[&str]) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let el = match document.get_element_by_id(LOADING_ID) {
Some(el) => el,
None => {
let Ok(el) = document.create_element("div") else { return };
el.set_id(LOADING_ID);
let Some(body) = document.body() else { return };
if let Err(e) = body.append_child(&el) {
log::error!("couldn't show a message: {e:?}");
}
el
}
};
el.set_text_content(None);
for line in lines {
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
// Best effort: the log has the message too.
el.append_child(&p).ok();
}
}
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
app: VectorcraftApp,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
/// `None` once handed on.
app: Option<VectorcraftApp>,
host: Host,
canvas: web_sys::HtmlCanvasElement,
loss: GraphicsLoss,
}
impl WebShell {
/// The graphics were lost (`why`): keep recovery copies, then start again on a new canvas
/// with the same app once this frame is over (the runner is busy with it until then).
fn restart(&mut self, why: &str) {
let Some(mut app) = self.app.take() else { return };
let kept = app.graphics_lost(why);
let mut host = self.host.clone();
host.restarts += 1;
let canvas =
if host.restarts > MAX_RESTARTS { Err(format!("the graphics were lost {MAX_RESTARTS} times")) } else { fresh_canvas(&self.canvas) };
match canvas {
Ok(canvas) => wasm_bindgen_futures::spawn_local(run(host, canvas, Some(Moving { app, kept }))),
Err(e) => {
log::error!("vectorcraft-web: giving up on the graphics: {e}");
let runner = host.runner;
wasm_bindgen_futures::spawn_local(async move { runner.destroy() });
give_up(Some(app), kept);
}
}
}
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(why) = self.loss.take() {
self.restart(&why);
}
let Some(app) = &mut self.app else { return };
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.drag.take();
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
let target = app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
@@ -124,15 +285,19 @@ impl eframe::App for WebShell {
});
}
}
self.app.logic(ctx);
app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
if let Some(app) = &mut self.app {
app.ui(ui);
}
}
}
+46 -17
View File
@@ -25,45 +25,69 @@ mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App {
app: VectorcraftApp,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
self.app.status(if self.graphics_lost {
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
} else {
"The graphics device was lost: save your documents and restart VectorCraft"
});
}
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
// The window can't show the Save Changes question: it closes, and the next launch
// offers the changes back.
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
}
if let Some(m) = &mut self.1 {
m.poll(&mut self.0);
if let Some(m) = &mut self.menu {
m.poll(&mut self.app);
}
open_files(&mut self.0, open_documents::take());
open_files(&mut self.app, open_documents::take());
}
self.0.logic(ctx);
window::track(ctx, &mut self.0.ui.window);
if self.0.ui.status == "quit" {
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
#[cfg(target_os = "macos")]
if self.0.take_ime_discard() {
if self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
save_prefs(&self.0);
save_prefs(&self.app);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
save_prefs(&self.0);
save_prefs(&self.app);
}
}
@@ -363,11 +387,14 @@ fn main() -> eframe::Result {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
let graphics_loss = GraphicsLoss::default();
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
@@ -382,11 +409,13 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
open_files(&mut app, files);
Ok(Box::new(App(
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
None,
)))
menu: None,
}))
}),
)
}
+85 -14
View File
@@ -1,14 +1,17 @@
//! Document nodes.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::BlendMode;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::selection::AnchorRef;
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@@ -82,6 +85,9 @@ pub enum LiveShape {
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
radii: [f64; 4],
/// Corner kinds, in the same order (Live Corners' corner types).
#[serde(default, skip_serializing_if = "all_round")]
kinds: [CornerKind; 4],
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
@@ -118,15 +124,8 @@ impl LiveShape {
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, xf } => {
let r = Rect::new(0.0, 0.0, *w, *h);
let p = if radii.iter().all(|x| (x - radii[0]).abs() < 1e-9) {
shapes::rounded_rectangle(r, radii[0])
} else {
// Per-corner radii: generate uniformly then adjust (approximation until M2.5).
shapes::rounded_rectangle(r, radii.iter().cloned().fold(0.0, f64::max))
};
p.transformed(*xf)
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
@@ -144,7 +143,7 @@ impl LiveShape {
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { w, h, radii, xf } => {
LiveShape::Rectangle { w, h, radii, xf, .. } => {
let m = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
@@ -185,6 +184,44 @@ impl LiveShape {
_ => false,
}
}
/// The corner (radii order) of each anchor of a rectangle's path; empty for other shapes.
pub fn anchor_corners(&self) -> Vec<usize> {
match self {
LiveShape::Rectangle { w, h, radii, .. } => shapes::rectangle_anchor_corners(Rect::new(0.0, 0.0, *w, *h), *radii),
_ => vec![],
}
}
/// The corners of a rectangle that hold one of `anchors` (of its only subpath).
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> [bool; 4] {
let map = self.anchor_corners();
let mut out = [false; 4];
for k in anchors.iter().filter(|(si, _)| *si == 0).filter_map(|(_, ai)| map.get(*ai)) {
if let Some(c) = out.get_mut(*k) {
*c = true;
}
}
out
}
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
/// shape is partly selected (Direct Selection), else all four.
pub fn picked_corners(&self, partial: Option<&BTreeSet<AnchorRef>>) -> [bool; 4] {
let picked = partial.map_or([true; 4], |a| self.corners_of(a));
if picked.contains(&true) { picked } else { [true; 4] }
}
/// The anchors of a rectangle's `corners` (to keep them selected as its path changes).
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
}
/// The radius and kind the `corners` of a rectangle share (each `None` when they differ).
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) };
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
let first = it.next()?;
it.all(|x| same(x, first)).then_some(first)
}
(shared(*radii, corners, |a, b| (a - b).abs() < 1e-9), shared(*kinds, corners, |a, b| a == b))
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
match self {
@@ -197,6 +234,10 @@ impl LiveShape {
}
}
fn all_round(kinds: &[CornerKind; 4]) -> bool {
kinds.iter().all(|k| *k == CornerKind::Round)
}
/// What a transform scales besides geometry, by its mean scale (the square root of its
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
@@ -1200,14 +1241,44 @@ mod tests {
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], xf: Affine::translate((5.0, 5.0)) };
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn rectangle_corners_map_to_their_anchors() {
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
assert_eq!(l.anchor_corners(), [0, 1, 1, 2, 3]);
assert_eq!(l.to_path().anchor_count(), 5);
let set = |v: &[usize]| v.iter().map(|ai| (0, *ai)).collect::<BTreeSet<AnchorRef>>();
assert_eq!(l.corners_of(&set(&[2, 4])), [false, true, false, true]);
assert_eq!(l.corners_of(&set(&[9])), [false; 4], "out of range");
assert_eq!(l.corner_anchors([false, true, false, true]), set(&[1, 2, 4]));
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
assert_eq!(l.picked_corners(Some(&set(&[0]))), [true, false, false, false]);
assert_eq!(l.picked_corners(None), [true; 4]);
assert_eq!(l.picked_corners(Some(&set(&[]))), [true; 4]);
assert_eq!(l.corner_style([false, true, false, false]), (Some(10.0), Some(CornerKind::Round)));
assert_eq!(l.corner_style([true, true, false, false]), (None, Some(CornerKind::Round)));
assert_eq!(l.corner_style([true, false, true, true]), (Some(0.0), Some(CornerKind::Round)));
// Corner kinds save only when some corner isn't round, and older files read as round.
let json = serde_json::to_value(&l).unwrap();
assert!(json.get("kinds").is_none(), "{json}");
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
if let LiveShape::Rectangle { kinds, .. } = &mut l {
kinds[1] = CornerKind::Chamfer;
}
let json = serde_json::to_value(&l).unwrap();
assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"]));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
assert!(LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }.anchor_corners().is_empty());
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
@@ -1216,7 +1287,7 @@ mod tests {
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
+7 -1
View File
@@ -166,7 +166,13 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii: [radius; 4], xf: Affine::translate(r.origin().to_vec2()) };
let live = LiveShape::Rectangle {
w: r.width(),
h: r.height(),
radii: [radius; 4],
kinds: Default::default(),
xf: Affine::translate(r.origin().to_vec2()),
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
}
+1 -1
View File
@@ -658,7 +658,7 @@ pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
}
let rot = Affine::rotate(e0.y.atan2(e0.x));
let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
}
let b = path.bounds()?;
let c = b.center();
+55 -5
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
@@ -180,7 +181,15 @@ pub fn specs() -> Vec<CommandSpec> {
set_bounds
),
cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape),
cmd!("object.setLiveShape", "Live Shape Properties", [], None, "{id?, radius?: pt (all corners), sides?: n}", has_selection, set_live_shape),
cmd!(
"object.setLiveShape",
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [0..3…], sides?: n} (Live Corners: radius and kind set a rectangle's corners: `corners` (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), else the corners with a Direct-Selected anchor, else all four; sides: a polygon's)",
has_selection,
set_live_shape
),
]
}
@@ -962,15 +971,50 @@ fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
/// The `corners` of `object.setLiveShape`: a mask of corner indices 0–3.
fn corners_param(p: &Value) -> Result<Option<[bool; 4]>> {
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
let err = || {
bad(
"object.setLiveShape",
format!("`corners` must list corner indices 0–3 (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), not {v}"),
)
};
let mut mask = [false; 4];
for k in v.as_array().ok_or_else(err)? {
let slot = k.as_u64().and_then(|k| mask.get_mut(usize::try_from(k).ok()?)).ok_or_else(err)?;
*slot = true;
}
Ok(Some(mask))
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit("Live Shape", |d, _| {
let corners = corners_param(p)?;
let kind = p
.get("kind")
.filter(|v| !v.is_null())
.map(|v| {
serde_json::from_value::<CornerKind>(v.clone())
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let partial = sel.anchors.get_mut(id);
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
if let Some(r) = p.get("radius").and_then(Value::as_f64) {
*radii = [r.max(0.0); 4];
vectorcraft_doc::LiveShape::Rectangle { radii, kinds, .. } => {
for ((_, r), k) in picked.iter().zip(radii.iter_mut()).zip(kinds.iter_mut()).filter(|((on, _), _)| **on) {
if let Some(radius) = radius {
*r = radius.max(0.0);
}
if let Some(kind) = kind {
*k = kind;
}
}
}
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
@@ -981,6 +1025,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
*path = live.to_path();
// Direct-Selected corners stay selected as they gain or lose anchors.
if let (Some(anchors), Some(selected)) = (partial, selected)
&& live.anchor_corners() != layout
{
*anchors = live.corner_anchors(selected);
}
}
Ok(())
})?;
+16
View File
@@ -487,6 +487,22 @@ pub fn release(s: &mut Session) {
store.remove_area(&area);
}
/// Let go of this session's area but leave its copies, as a crash would, so the next app started
/// offers them at once instead of waiting for the heartbeat to go stale: for an app that can't go
/// on (the web page lost its graphics and has to be reloaded). Its documents keep no copies.
pub fn leave(s: &mut Session) {
for st in &mut s.docs {
st.recovery = None;
}
let Some(Own { store, area, hold, beats }) = s.recovery.own.take() else { return };
if beats {
// Best effort: if the heartbeat stays, the copies are offered once it is stale.
let _ = store.remove(&format!("{area}/{BEAT}"));
}
// The lock or announcement goes too.
drop(hold);
}
/// A document's recovery copy: its entry and the document it holds.
#[derive(Clone, Debug)]
pub struct RecoveryCopy {
+2
View File
@@ -1305,6 +1305,8 @@ mod tests_liquify;
#[cfg(test)]
mod tests_live;
#[cfg(test)]
mod tests_livecorners;
#[cfg(test)]
mod tests_maskview;
#[cfg(test)]
mod tests_menucmds;
+107
View File
@@ -0,0 +1,107 @@
//! Live Corners on live rectangles: `object.setLiveShape` rounds the given corners, the
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use super::*;
/// A 100 × 60 pt live rectangle at (10, 10), selected.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
(s, NodeId(id))
}
fn run(s: &mut Session, p: Value) {
s.execute("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
fn corners(s: &Session, id: NodeId) -> ([f64; 4], [CornerKind; 4], usize) {
let n = s.doc().unwrap().doc.node(id).unwrap();
let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), path, .. } = &n.kind else { panic!("not a live rectangle") };
(*radii, *kinds, path.anchor_count())
}
fn selected_anchors(s: &Session, id: NodeId) -> Option<BTreeSet<AnchorRef>> {
s.doc().unwrap().selection.partial(id).cloned()
}
fn anchors(v: &[usize]) -> BTreeSet<AnchorRef> {
v.iter().map(|ai| (0, *ai)).collect()
}
#[test]
fn a_whole_rectangle_rounds_all_corners_and_given_corners_alone() {
let (mut s, id) = session();
run(&mut s, json!({"radius": 8}));
assert_eq!(corners(&s, id), ([8.0; 4], [CornerKind::Round; 4], 8));
run(&mut s, json!({"id": id.0, "radius": 0}));
// One corner: only it rounds, and the path gains one anchor (one undo step).
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, json!({"ids": [id.0], "corners": [1], "radius": 12}));
assert_eq!(corners(&s, id), ([0.0, 12.0, 0.0, 0.0], [CornerKind::Round; 4], 5));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x1 - 110.0).abs() < 1e-9 && (b.y0 - 10.0).abs() < 1e-9, "the shape keeps its bounds: {b:?}");
// Kinds alone, then both; the others stay as they were.
run(&mut s, json!({"corners": [1, 3], "kind": "chamfer"}));
run(&mut s, json!({"corners": [3], "radius": 5, "kind": "invertedRound"}));
assert_eq!(corners(&s, id), ([0.0, 12.0, 0.0, 5.0], [CornerKind::Round, CornerKind::Chamfer, CornerKind::Round, CornerKind::InvertedRound], 6));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(corners(&s, id).1[3], CornerKind::Chamfer);
}
#[test]
fn bad_corners_and_kinds_are_errors() {
let (mut s, id) = session();
for p in
[json!({"corners": [4], "radius": 5}), json!({"corners": ["a"]}), json!({"corners": 1}), json!({"corners": [-1]}), json!({"kind": "wavy"})]
{
assert!(s.execute("object.setLiveShape", &p).is_err(), "{p}");
}
assert_eq!(corners(&s, id), ([0.0; 4], [CornerKind::Round; 4], 4), "nothing changed");
}
#[test]
fn direct_selected_corners_round_alone_and_stay_selected() {
let (mut s, id) = session();
// Direct Selection picks the top-right and bottom-left corners.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 3]], "mode": "set"})).unwrap();
run(&mut s, json!({"radius": 10}));
assert_eq!(corners(&s, id), ([0.0, 10.0, 0.0, 10.0], [CornerKind::Round; 4], 6));
// Anchors: top-left, top-right's two, bottom-right, bottom-left's two.
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])));
// The same corners again (a field in the Properties panel), now with their two anchors each.
run(&mut s, json!({"kind": "chamfer"}));
assert_eq!(corners(&s, id).1, [CornerKind::Round, CornerKind::Chamfer, CornerKind::Round, CornerKind::Chamfer]);
run(&mut s, json!({"radius": 0}));
assert_eq!(corners(&s, id).2, 4);
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 3])));
// Undo brings back the selection with the shape.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])));
// Explicit corners win over the selection, which follows its own corners.
run(&mut s, json!({"corners": [0], "radius": 4}));
assert_eq!(corners(&s, id).0, [4.0, 10.0, 0.0, 10.0]);
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])), "the top-left corner comes first and last");
}
#[test]
fn a_drag_previews_one_corner_and_commits_one_step() {
let (mut s, id) = session();
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.begin_interaction("Corner Radius").unwrap();
for r in [5.0, 9.0, 14.0] {
s.preview("object.setLiveShape", &json!({"id": id.0, "radius": r, "corners": [2]})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(corners(&s, id), ([0.0, 0.0, 14.0, 0.0], [CornerKind::Round; 4], 5));
assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3])));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
+17
View File
@@ -269,6 +269,23 @@ fn announced_areas_stay_held_while_their_app_is_paused() {
assert!(offered(&mut c).is_empty());
}
#[test]
fn an_app_that_leaves_has_its_copies_offered_at_once() {
for store in [MemoryStore::without_locks(), MemoryStore::announcing(), MemoryStore::default()] {
let store = Arc::new(store);
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let mut b = relaunch(&store);
assert!(offered(&mut b).is_empty(), "A still runs");
// A can't go on (its page lost its graphics): its copies stay, for the next app at once.
cmd::recovery::leave(&mut a);
assert!(a.doc().unwrap().recovery.is_none());
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
assert_eq!(offered(&mut b), ["Untitled-1"]);
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
}
}
#[test]
fn folder_areas_are_locked_by_running_apps() {
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
+126 -17
View File
@@ -3,6 +3,7 @@
use std::f64::consts::{PI, TAU};
use kurbo::{Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
@@ -17,25 +18,85 @@ pub fn rectangle(r: Rect) -> PathData {
/// Rounded rectangle (8 anchors). The radius is clamped to half the shorter side.
pub fn rounded_rectangle(r: Rect, radius: f64) -> PathData {
let r = r.abs();
let rad = radius.max(0.0).min(r.width() / 2.0).min(r.height() / 2.0);
if rad <= 1e-9 {
return rectangle(r);
rectangle_with_corners(r, [radius; 4], [CornerKind::Round; 4])
}
/// How a rectangle's corner is cut (Live Corners).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CornerKind {
/// A quarter circle, bulging outwards.
#[default]
Round,
/// A quarter circle cut into the shape, centred on the corner.
InvertedRound,
/// A straight bevel.
Chamfer,
}
impl CornerKind {
pub const ALL: [CornerKind; 3] = [CornerKind::Round, CornerKind::InvertedRound, CornerKind::Chamfer];
/// The kind after this one (Alt-clicking a corner widget cycles through them).
pub fn next(self) -> Self {
match self {
CornerKind::Round => CornerKind::InvertedRound,
CornerKind::InvertedRound => CornerKind::Chamfer,
CornerKind::Chamfer => CornerKind::Round,
}
}
let k = rad * KAPPA;
let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
let a = |p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
let anchors = vec![
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
}
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
/// top-left corner, its anchor on the top side first.
pub fn rectangle_with_corners(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> PathData {
PathData::single(SubPath::new(corner_anchors(r, radii, kinds).into_iter().map(|(_, a)| a).collect(), true))
}
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
corner_anchors(r, radii, [CornerKind::Round; 4]).into_iter().map(|(k, _)| k).collect()
}
/// The anchors of [`rectangle_with_corners`], each with its corner.
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
let r = r.abs();
let max = r.width().min(r.height()) / 2.0;
// Each corner, with the directions of the side arriving at it and of the side leaving it.
let corners = [
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
(Point::new(r.x1, r.y0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)),
(Point::new(r.x1, r.y1), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)),
(Point::new(r.x0, r.y1), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0)),
];
PathData::single(SubPath::new(anchors, true))
let mut out = Vec::with_capacity(8);
let mut top_left_start = None;
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
let rad = rad.max(0.0).min(max);
if rad <= 1e-9 {
out.push((k, Anchor::corner(c)));
continue;
}
// The cut runs from `start` on the arriving side to `end` on the leaving one.
let (start, end, h) = (c - din * rad, c + dout * rad, rad * KAPPA);
let (h_out, h_in) = match kind {
CornerKind::Round => (start + din * h, end - dout * h),
CornerKind::InvertedRound => (start + dout * h, end - din * h),
CornerKind::Chamfer => (start, end),
};
let start = Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner };
let end = Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner };
if k == 0 {
top_left_start = Some(start);
} else {
out.push((k, start));
}
out.push((k, end));
}
out.extend(top_left_start.map(|a| (0, a)));
out
}
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
@@ -203,6 +264,54 @@ mod tests {
assert_eq!(rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0).anchor_count(), 4);
}
#[test]
fn uniform_corners_keep_the_rounded_rectangle_layout() {
// The 8 anchors a rounded rectangle always had, from the top side's first one.
let (r, rad) = (Rect::new(10.0, 20.0, 110.0, 70.0), 7.5);
let (x0, y0, x1, y1, k) = (10.0, 20.0, 110.0, 70.0, rad * KAPPA);
let a =
|p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
let want = vec![
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
];
assert_eq!(rounded_rectangle(r, rad).subpaths[0].anchors, want);
assert_eq!(rectangle_anchor_corners(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
assert_eq!(rounded_rectangle(r, 0.0), rectangle(r));
}
#[test]
fn each_corner_takes_its_own_radius_and_kind() {
let r = Rect::new(0.0, 0.0, 100.0, 60.0);
// Only the top-right corner rounded: 5 anchors, the corner's two on its sides.
let radii = [0.0, 10.0, 0.0, 0.0];
let p = rectangle_with_corners(r, radii, [CornerKind::Round; 4]);
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts, [Point::new(0.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 10.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]);
assert_eq!(rectangle_anchor_corners(r, radii), [0, 1, 1, 2, 3]);
let quarter = PI * 25.0;
assert!((p.subpaths[0].area() - (6000.0 - 100.0 + quarter)).abs() < 0.1, "a quarter circle off one corner");
// The top-left corner rounded: its top-side anchor first, its left-side one last.
assert_eq!(rectangle_anchor_corners(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
// Inverted round cuts a quarter circle out; a chamfer cuts a triangle.
let inverted = rectangle_with_corners(r, radii, [CornerKind::InvertedRound; 4]);
assert!((inverted.subpaths[0].area() - (6000.0 - quarter)).abs() < 0.1);
let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]);
assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9);
assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight");
// Radii clamp to half the shorter side; negative and NaN radii are none.
let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]);
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0));
assert_eq!(p.anchor_count(), 5);
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
}
#[test]
fn polygon_first_vertex_up() {
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
+5 -1
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind};
use vectorcraft_effects::RasterFx;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::export::sanitize_id;
@@ -600,7 +601,10 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
b.abs() < 1e-9 && c.abs() < 1e-9
};
match live {
Some(LiveShape::Rectangle { radii, xf, .. }) if upright(xf) => {
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
Some(LiveShape::Rectangle { radii, kinds, xf, .. })
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
{
let [a, _, _, d, _, _] = xf.as_coeffs();
let s = (a * d).abs().sqrt();
if radii.iter().all(|r| *r <= 0.0) {
+19 -2
View File
@@ -6,7 +6,8 @@
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Justify, LiveShape, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_svg::{CssOptions, CssRule, CssUnits, ExportOptions, Styling, css_rules, export};
fn doc() -> Document {
@@ -29,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No
Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0),
);
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], xf: Affine::translate((x, y)) });
*live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) });
}
n
}
@@ -69,6 +70,22 @@ fn rectangle_gives_background_border_and_radius() {
);
}
#[test]
fn live_corners_give_per_corner_radii_and_only_round_ones() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0);
if let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind {
*radii = [0.0, 8.0, 0.0, 4.0];
kinds[0] = CornerKind::Chamfer;
}
let id = add(&mut d, n);
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter");
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
}
#[test]
fn ellipses_round_fully_and_options_change_units_and_names() {
let mut d = doc();
+118 -21
View File
@@ -1,12 +1,19 @@
//! Live Corners: the widgets inside each corner of a selected live rectangle. Dragging one rounds
//! (or sharpens) all four corners together. The Selection and Direct Selection tools share this,
//! and the canvas draws the widgets from the same geometry.
//! Live Corners: the widgets inside the corners of a selected live rectangle. Dragging one rounds
//! (or sharpens) the corners whose widgets show together: all four when the whole shape is
//! selected, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget
//! cycles their corner kind (round, inverted round, chamfer); double-clicking one opens the
//! Corners dialog. The Selection and Direct Selection tools share this, and the canvas draws the
//! widgets from the same geometry.
use serde_json::json;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, Point, Vec2};
use crate::{Action, Overlay, ToolContext};
use crate::{Action, Overlay, PointerEvent, ToolContext};
/// The dialog a double-click on a corner widget opens: `{id, corners}`.
pub const DIALOG: &str = "corners";
/// Widgets sit at least this far inside their corner (screen px), further in once the radius is.
const MIN_INSET_PX: f64 = 10.0;
@@ -24,9 +31,12 @@ pub struct CornerWidgets {
w: f64,
h: f64,
radii: [f64; 4],
kinds: [CornerKind; 4],
xf: Affine,
/// Widget centres in document coordinates, in radii order.
pub points: [Point; 4],
/// The corners whose widgets show (and which a drag edits).
pub shown: [bool; 4],
}
impl CornerWidgets {
@@ -36,10 +46,9 @@ impl CornerWidgets {
if !doc.is_editable(id) {
return None;
}
let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. }) = doc.node(id).map(|n| &n.kind) else {
return None;
};
let (w, h, radii, xf) = (*w, *h, *radii, *xf);
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live else { return None };
let (w, h, radii, kinds, xf) = (*w, *h, *radii, *kinds, *xf);
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
let c = xf.as_coeffs();
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
@@ -52,7 +61,7 @@ impl CornerWidgets {
let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0);
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
});
Some(Self { id, w, h, radii, xf, points })
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
}
/// The widgets the active tool can drag (View → Show Corner Widget on).
@@ -63,9 +72,32 @@ impl CornerWidgets {
Self::of(cx.doc, cx.selection, cx.zoom)
}
/// Index of the widget nearest to `p` within `tol` (document units).
/// The centres of the widgets that show.
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
self.points.iter().zip(self.shown).filter(|(_, on)| *on).map(|(p, _)| *p)
}
/// The indices of the shown corners.
fn shown_indices(&self) -> Value {
(0..4).filter(|k| self.shown[*k]).collect()
}
/// Index of the shown widget nearest to `p` within `tol` (document units).
pub fn hit(&self, p: Point, tol: f64) -> Option<usize> {
(0..4).filter(|k| self.points[*k].distance(p) <= tol).min_by(|a, b| self.points[*a].distance(p).total_cmp(&self.points[*b].distance(p)))
let d = |k: &usize| self.points[*k].distance(p);
(0..4).filter(|k| self.shown[*k] && d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
}
/// `object.setLiveShape` params setting `key` on the shown corners (all four unless some are
/// hidden, which `corners` then leaves out).
fn command(&self, key: &str, value: Value) -> Value {
let mut p = Map::new();
p.insert("id".into(), json!(self.id.0));
p.insert(key.into(), value);
if self.shown != [true; 4] {
p.insert("corners".into(), self.shown_indices());
}
Value::Object(p)
}
}
@@ -74,23 +106,32 @@ pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
CornerWidgets::for_tool(cx).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
}
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal.
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
let w = CornerWidgets::for_tool(cx)?;
w.hit(p, cx.tol(5.0))?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.shown_indices() })))
}
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal. An
/// Alt-click (no drag) cycles the corner kind instead.
#[derive(Clone, Copy, Debug)]
pub struct CornerDrag {
widgets: CornerWidgets,
corner: usize,
start: Point,
began: bool,
alt: bool,
radius: f64,
at: Point,
}
impl CornerDrag {
/// Start a drag when `p` is on a widget of the selection.
pub fn hit(cx: &ToolContext, p: Point) -> Option<Self> {
let widgets = CornerWidgets::for_tool(cx)?;
/// Start a drag when the press `ev` is on a widget of the selection.
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
let corner = widgets.hit(p, cx.tol(5.0))?;
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
@@ -115,12 +156,19 @@ impl CornerDrag {
}
self.radius = self.radius_at(p);
self.at = p;
out.push(Action::Preview("object.setLiveShape".into(), json!({ "id": self.widgets.id.0, "radius": self.radius })));
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out
}
pub fn finish(self) -> Vec<Action> {
if self.began { vec![Action::Commit] } else { vec![] }
if self.began {
vec![Action::Commit]
} else if self.alt {
let kind = self.widgets.kinds[self.corner].next();
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(kind)))]
} else {
vec![]
}
}
/// The radius readout next to the pointer.
@@ -136,7 +184,7 @@ impl CornerDrag {
mod tests {
use super::*;
use crate::testutil::*;
use crate::{PointerEvent, PointerKind};
use crate::{Mods, PointerKind};
use vectorcraft_doc::{Appearance, Node};
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
@@ -144,7 +192,7 @@ mod tests {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], xf };
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
@@ -221,6 +269,55 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
}
#[test]
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
// Direct Selection picked the bottom-right anchor.
let mut s = selected(id);
s.anchors.insert(id, [(0, 2)].into());
let p = paint();
let cx = cx(&d, &s, &p);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
assert_eq!(w.shown, [false, false, true, false]);
assert_eq!(w.visible().collect::<Vec<_>>(), [Point::new(190.0, 190.0)]);
assert!(!over_widget(&cx, Point::new(110.0, 110.0)), "the other corners hide theirs");
let mut t = crate::create("directSelection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
assert_eq!(a[1], Action::Preview("object.setLiveShape".into(), json!({"id": id.0, "radius": 15.0, "corners": [2]})));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]);
}
#[test]
fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let whole = selected(id);
let mut part = selected(id);
part.anchors.insert(id, [(0, 0), (0, 7)].into());
for (s, extra) in [(&whole, json!({})), (&part, json!({"corners": [0]}))] {
let cx = cx(&d, s, &p);
let mut want = json!({"id": id.0, "kind": "invertedRound"});
want.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
for tool in ["selection", "directSelection"] {
let mut t = crate::create(tool);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 110.0).with_mods(alt)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 110.0).with_mods(alt));
assert_eq!(a, vec![Action::Exec("object.setLiveShape".into(), want.clone())], "{tool}");
}
}
// A double-click on a widget opens Corners for the shown corners; elsewhere it doesn't.
let cx1 = cx(&d, &part, &p);
let a = crate::create("directSelection").pointer(&cx1, &PointerEvent::new(PointerKind::DoubleClick, 110.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0]}))]);
let cx2 = cx(&d, &whole, &p);
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
}
#[test]
fn widgets_off_or_elsewhere_keep_the_tools_behaviour() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
+6 -3
View File
@@ -3,7 +3,9 @@
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners.
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
//! the Corners dialog).
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
@@ -23,7 +25,7 @@ use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{CornerDrag, over_widget};
use crate::corners::{self, CornerDrag, over_widget};
use crate::meshedit::MeshEdit;
use crate::select::{is_area_type, matrix_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
@@ -257,8 +259,9 @@ impl Tool for DirectSelectionTool {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
}
}
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p).into_iter().collect(),
(PointerKind::Down, _) => {
if let Some(c) = CornerDrag::hit(cx, p) {
if let Some(c) = CornerDrag::hit(cx, ev) {
self.state = State::Corner(c);
return vec![];
}
+7 -3
View File
@@ -1,6 +1,7 @@
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode.
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
//! scaling its type: the text reflows at its size.
@@ -11,7 +12,7 @@ use vectorcraft_doc::{NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::corners::{CornerDrag, over_widget};
use crate::corners::{self, CornerDrag, over_widget};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
#[derive(Clone, Debug, Default)]
@@ -108,6 +109,9 @@ impl Tool for SelectionTool {
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
if let Some(a) = corners::double_click(cx, p) {
return vec![a];
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
let top = h.top_object(cx.isolation);
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
@@ -123,7 +127,7 @@ impl Tool for SelectionTool {
}
(PointerKind::Down, _) => {
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
if let Some(c) = CornerDrag::hit(cx, p) {
if let Some(c) = CornerDrag::hit(cx, ev) {
self.state = State::Corner(c);
return vec![];
}
+1 -1
View File
@@ -1239,7 +1239,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.points.map(|q| xf.to_screen(q)) {
for sp in w.visible().map(|q| xf.to_screen(q)) {
p.circle_filled(sp, 3.0, Color32::WHITE);
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
p.circle_filled(sp, 1.0, color);
+199
View File
@@ -0,0 +1,199 @@
//! Corners: double-clicking a Live Corners widget (or `ui.corners`) sets the corner kind and
//! radius of a live rectangle's corners: those whose widgets show (the Direct-Selected ones, else
//! all four). OK runs `object.setLiveShape` for them, one undo step.
//!
//! Fields: `id`, `corners` (indices 0–3: top-left, top-right, bottom-right, bottom-left), `kind`
//! ("round", "invertedRound" or "chamfer") and `radius` (pt). While the corners differ in kind
//! or radius, that field is absent and OK leaves it as it is.
use serde_json::{Value, json};
use vectorcraft_engine::doc::{NodeId, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use super::swatch_options::{grid, label};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Corners (the corner widgets' double-click).
pub const KIND: &str = vectorcraft_tools::corners::DIALOG;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").into(), body, confirm, min_width: 280.0, ..DialogSpec::FORM };
/// Open Corners for `{id?, corners?}`: the live rectangle `id` (default: the selected one) and its
/// `corners` (default: the selected corners), filled in with their kind and radius.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(id) => NodeId(id),
None => match st.selection.objects[..] {
[id] => id,
_ => return Err("select one live rectangle, or give its `id`".into()),
},
};
let Some(NodeKind::Path { live: Some(live @ vectorcraft_engine::doc::LiveShape::Rectangle { .. }), .. }) = st.doc.node(id).map(|n| &n.kind)
else {
return Err("Corners edits a live rectangle".into());
};
let corners = match p.get("corners").and_then(Value::as_array) {
Some(a) => std::array::from_fn(|k| a.iter().any(|v| v.as_u64() == Some(k as u64))),
None => live.picked_corners(st.selection.partial(id)),
};
if !corners.contains(&true) {
return Err("give corners 0–3".into());
}
let (radius, kind) = live.corner_style(corners);
let mut fields = json!({"id": id.0, "corners": (0..4).filter(|k| corners[*k]).collect::<Vec<_>>()});
if let Some(k) = kind {
fields["kind"] = json!(k);
}
if let Some(r) = radius {
fields["radius"] = json!(r);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
let current = d.fields.get("kind").and_then(|k| serde_json::from_value::<CornerKind>(k.clone()).ok());
grid(ui, |ui| {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
});
ui.end_row();
label(ui, tl!("Radius:"));
form::length(ui, d, "radius", unit, 120.0);
ui.end_row();
});
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {
p["kind"] = k.clone();
}
if d.fields.contains_key("radius") {
p["radius"] = json!(d.f64("radius", 0.0));
}
run_and_close(app, "object.setLiveShape", p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::LiveShape;
use vectorcraft_tools::corners::CornerWidgets;
use vectorcraft_tools::{PointerEvent, PointerKind};
/// A selected 100 pt live square at (100, 100).
fn app() -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
(app, id)
}
fn live(app: &VectorcraftApp, id: u64) -> LiveShape {
let Some(NodeKind::Path { live: Some(l), .. }) = app.session.active().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else {
panic!("not live")
};
l
}
fn corners(app: &VectorcraftApp, id: u64) -> ([f64; 4], [CornerKind; 4]) {
let LiveShape::Rectangle { radii, kinds, .. } = live(app, id) else { panic!("not a rectangle") };
(radii, kinds)
}
/// The radius the Properties panel's Corner Radius field shows.
fn panel_radius(app: &VectorcraftApp, id: u64) -> Option<f64> {
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
crate::panels::corner_radius(app, &n, &live(app, id))
}
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// A new window lays itself out in its first frame and paints in the next.
ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear();
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn double_clicking_a_direct_selected_corner_edits_it_alone() {
let (mut app, id) = app();
// Direct Selection picks the bottom-right anchor: only its widget shows.
app.run("select.anchors", json!({"id": id, "anchors": [[0, 2]], "mode": "set"})).unwrap();
app.select_tool("directSelection");
let view = app.view_info();
let st = app.session.active().unwrap();
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom).unwrap().visible().collect();
assert_eq!(at.len(), 1);
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, at[0].x, at[0].y), view);
let d = app.ui.dialog.clone().expect("Corners opened");
assert_eq!(
(d.kind.as_str(), d.fields.get("corners"), d.fields.get("kind"), d.f64("radius", -1.0)),
(KIND, Some(&json!([2])), Some(&json!("round")), 0.0)
);
let texts = shown(&mut app);
for s in ["Corners", "Corner:", "Round", "Inverted Round", "Chamfer", "Radius:"] {
assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}");
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("kind".into(), json!("chamfer"));
d.fields.insert("radius".into(), json!(12));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(corners(&app, id), ([0.0, 0.0, 12.0, 0.0], [CornerKind::Round, CornerKind::Round, CornerKind::Chamfer, CornerKind::Round]));
// The panels' radius field shows the selected corner's radius, blank for the whole
// shape (its corners differ).
assert_eq!(panel_radius(&app, id), Some(12.0));
app.run("select.set", json!({"ids": [id]})).unwrap();
assert_eq!(panel_radius(&app, id), None);
}
#[test]
fn ui_corners_opens_on_the_selection_and_leaves_mixed_values_alone() {
let (mut app, id) = app();
app.run("object.setLiveShape", json!({"corners": [0], "radius": 5, "kind": "invertedRound"})).unwrap();
app.run("ui.corners", json!({})).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!(d.fields.get("corners"), Some(&json!([0, 1, 2, 3])));
assert!(d.fields.get("kind").is_none() && d.fields.get("radius").is_none(), "mixed: {d:?}");
let before = corners(&app, id);
super::super::confirm(&mut app).unwrap();
assert_eq!(corners(&app, id), before);
// A radius for all four keeps their kinds.
app.run("ui.corners", json!({"id": id})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("radius".into(), json!("3 pt"));
super::super::confirm(&mut app).unwrap();
assert_eq!(corners(&app, id), ([3.0; 4], before.1));
// Only live rectangles, and only real corners.
assert!(app.run("ui.corners", json!({"id": 9999})).is_err());
assert!(app.run("ui.corners", json!({"corners": [7]})).is_err());
}
}
+2
View File
@@ -15,6 +15,7 @@ pub mod color_guide_options;
mod color_picker;
mod command;
pub mod confirm;
pub mod corners;
mod document_setup;
pub mod dxf_import;
pub mod dxf_options;
@@ -230,6 +231,7 @@ registry! {
SpotColors: [spot_colors::KIND] => spot_colors::SPEC,
TransformEach: [transform_each::KIND] => transform_each::SPEC,
WidthPoint: [width_point::KIND] => width_point::SPEC,
Corners: [corners::KIND] => corners::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
+7
View File
@@ -70,6 +70,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
}
return;
}
// Double-clicking a Live Corners widget: Corners.
super::corners::KIND => {
if let Err(e) = super::corners::open(app, &p) {
app.status(e);
}
return;
}
// Double-clicking a slice with the Slice Selection tool: Slice Options.
super::slices::OPTIONS => {
if let Err(e) = super::slices::open_options(app) {
+1 -2
View File
@@ -6,7 +6,6 @@
//! only those. Preferences › Type › Enable in-menu font previews and Font Preview Size turn the
//! samples off or size the rows.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
@@ -480,7 +479,7 @@ impl Samples {
}
thread_local! {
static SAMPLES: RefCell<Samples> = RefCell::new(Samples::default());
static SAMPLES: crate::graphics::TexCache<Samples> = crate::graphics::TexCache::default();
}
/// The sample of `family` for a row `height` points high: the selected text set in it (else a
+231
View File
@@ -0,0 +1,231 @@
//! Losing the graphics device, and the textures uploaded to it (#369).
//!
//! A window can lose its graphics device: a driver reset or a GPU hang on the desktop, the browser
//! dropping the page's WebGPU device or WebGL context. Everything uploaded to it is gone and
//! nothing it draws reaches the screen any more, while the app itself goes on.
//!
//! - The host reports the loss through a [`GraphicsLoss`] (from wgpu's device-lost callback, the
//! canvas's `webglcontextlost` event), and the app keeps recovery copies of the modified
//! documents at once ([`VectorcraftApp::graphics_lost`]).
//! - The web host then starts again with new graphics and a new egui context, handing it the same
//! app. The app notices the new context and makes everything it uploaded to the old one again:
//! its fonts and theme, the canvas, and every texture cache ([`TexCache`]).
use std::cell::{Cell, Ref, RefCell, RefMut};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::VectorcraftApp;
/// Raised by the host when the window's graphics device is lost (from any thread), read on the UI
/// thread. Cheap to clone; the clones share the report.
#[derive(Clone, Default)]
pub struct GraphicsLoss(Arc<Mutex<Option<String>>>);
impl GraphicsLoss {
/// The graphics device was lost (`why`, for the log): the next frame of `ctx` handles it. A
/// second report before then adds nothing.
pub fn report(&self, ctx: &egui::Context, why: impl Into<String>) {
let why = why.into();
log::error!("vectorcraft: the graphics device was lost: {why}");
self.0.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert(why);
ctx.request_repaint();
}
/// The loss reported since the last call, if any.
pub fn take(&self) -> Option<String> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
/// A number naming egui context `ctx`, the same every call and different from every other
/// context's.
pub(crate) fn context_tag(ctx: &egui::Context) -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(1);
let id = egui::Id::new("vectorcraft-context-tag");
if let Some(tag) = ctx.data(|d| d.get_temp::<u64>(id)) {
return tag;
}
ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || NEXT.fetch_add(1, Ordering::Relaxed)))
}
thread_local! {
/// Bumped when the UI moves to a new egui context: [`TexCache`]s made before are emptied.
static EPOCH: Cell<u64> = const { Cell::new(0) };
}
/// The UI moved to a new egui context: every [`TexCache`] of this thread empties at its next use.
pub(crate) fn forget_textures() {
EPOCH.with(|e| e.set(e.get().wrapping_add(1)));
}
/// A per-thread cache holding textures (thumbnails, previews, samples). Textures belong to the
/// egui context they were uploaded to, so the cache goes back to `T::default()` when the UI moves
/// to another one ([`forget_textures`]). Used like the `RefCell` it wraps.
#[derive(Default)]
pub(crate) struct TexCache<T> {
epoch: Cell<u64>,
value: RefCell<T>,
}
impl<T: Default> TexCache<T> {
/// Empty the cache if it was filled for another context.
fn refresh(&self) {
let now = EPOCH.with(Cell::get);
if self.epoch.replace(now) != now {
*self.value.borrow_mut() = T::default();
}
}
pub(crate) fn borrow(&self) -> Ref<'_, T> {
self.refresh();
self.value.borrow()
}
pub(crate) fn borrow_mut(&self) -> RefMut<'_, T> {
self.refresh();
self.value.borrow_mut()
}
}
impl VectorcraftApp {
/// The window's graphics device was lost (`why`, from [`GraphicsLoss::take`]): recovery copies
/// of the modified documents are written now, since the window may never draw again (even
/// with Data Recovery turned off: they go when the documents are saved or closed) → whether
/// every modified document has an up-to-date copy (none modified counts).
pub fn graphics_lost(&mut self, why: &str) -> bool {
if let Err(e) = self.session.execute("file.recovery.save", &serde_json::json!({})) {
log::warn!("vectorcraft: no recovery copies after losing the graphics ({why}): {e}");
}
self.session.documents().iter().filter(|st| st.is_dirty()).all(|st| st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)))
}
/// Textures live in one egui context. When the host hands the app a new one (it started again
/// after losing its graphics), everything uploaded to the old one is made again: the fonts and
/// theme, the canvas, the place cursor's thumbnails and every [`TexCache`].
pub(crate) fn adopt_context(&mut self, ctx: &egui::Context) {
let tag = context_tag(ctx);
if self.context == tag {
return;
}
if self.context != 0 {
log::info!("vectorcraft: new graphics context: uploading the UI again");
forget_textures();
self.styled = false;
self.fonts_ready = false;
self.ui_fonts = Default::default();
self.canvas.texture = None;
self.canvas.key = None;
self.place.forget_textures();
}
self.context = tag;
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore};
use super::*;
use crate::Services;
/// One headless frame of the whole window (800×600) in `ctx`.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
/// The name of texture `id` in `ctx`, if `ctx` has it.
fn texture_name(ctx: &egui::Context, id: egui::TextureId) -> Option<String> {
ctx.tex_manager().read().meta(id).map(|m| m.name.clone())
}
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
app
}
#[test]
fn a_loss_is_reported_once_from_any_thread() {
let (loss, ctx) = (GraphicsLoss::default(), egui::Context::default());
let (l, c) = (loss.clone(), ctx.clone());
std::thread::spawn(move || {
l.report(&c, "Unknown: driver reset");
l.report(&c, "Destroyed");
})
.join()
.unwrap();
assert_eq!(loss.take().as_deref(), Some("Unknown: driver reset"), "the first report wins");
assert_eq!(loss.take(), None, "handled once");
}
#[test]
fn a_tex_cache_empties_when_the_ui_moves_to_a_new_context() {
thread_local! {
static CACHE: TexCache<Vec<u32>> = TexCache::default();
}
CACHE.with(|c| c.borrow_mut().push(7));
assert_eq!(CACHE.with(|c| c.borrow().clone()), [7]);
forget_textures();
assert!(CACHE.with(|c| c.borrow().is_empty()), "emptied for the new context");
CACHE.with(|c| c.borrow_mut().push(8));
assert_eq!(CACHE.with(|c| c.borrow().clone()), [8], "and filled again");
}
#[test]
fn the_app_uploads_everything_again_in_a_new_context() {
let mut app = app();
let old = egui::Context::default();
// Fonts, then the canvas.
for _ in 0..3 {
frame(&mut app, &old);
}
let canvas = app.canvas.texture.as_ref().map(egui::TextureHandle::id).expect("the canvas is drawn");
assert_eq!(texture_name(&old, canvas).as_deref(), Some("canvas"));
let preview = |ctx: &egui::Context| {
let mut tex = None;
ctx.run_ui(Default::default(), |ui| {
tex = crate::widgets::doc_preview(ui, "loss", egui::vec2(8.0, 8.0), |w, h| Some(vectorcraft_doc::Document::new(w, h)))
})
.textures_delta
.clear();
tex.expect("a preview").id()
};
assert_eq!(texture_name(&old, preview(&old)).as_deref(), Some("preview-8x8@1:loss"));
// The host lost its graphics and started again: same app, new context.
let new = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &new);
}
let canvas = app.canvas.texture.as_ref().map(egui::TextureHandle::id).expect("the canvas is drawn again");
assert_eq!(texture_name(&new, canvas).as_deref(), Some("canvas"), "uploaded to the new context");
assert_eq!(texture_name(&new, preview(&new)).as_deref(), Some("preview-8x8@1:loss"), "cached previews are made again");
assert_eq!(new.global_style().visuals.panel_fill, old.global_style().visuals.panel_fill, "the theme is applied again");
assert!(new.fonts(|f| f.definitions().font_data.contains_key("SourceSans3")), "and the fonts installed");
}
#[test]
fn losing_the_graphics_keeps_recovery_copies_at_once() {
let store = Arc::new(MemoryStore::default());
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
assert!(app.graphics_lost("test"), "nothing modified: nothing to lose");
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
app.session.prefs.autosave_recovery = false;
assert!(app.graphics_lost("test"), "copied even with Data Recovery off");
assert_eq!(store.list().unwrap().iter().filter(|n| n.ends_with(".vectorcraft")).count(), 1);
// Without a store the work isn't safe.
assert!(!self::app().graphics_lost("test"));
}
}
+5
View File
@@ -466,6 +466,11 @@
Corner Radius: Radio de esquina:
Corner: Esquina:
Corners Esquinas
Chamfer Chaflán
Corners… Esquinas…
Inverted Round Redondeada invertida
Radius: Radio:
Round Redondeada
Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -940,6 +940,11 @@
Corner Radius: 角丸の半径:
Corner: 角の形状:
Corners コーナー
Round 丸型
Radius: 半径:
Inverted Round 反転した丸型
Corners… コーナー…
Chamfer 面取り
Create Clipping Mask クリッピングマスクを作成
Create Document ドキュメントを作成
Create From: 作成元:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -322,6 +322,7 @@
Cascade Em Cascata
Center Artwork Centralizar Arte
Centimeters Centímetros
Chamfer Chanfro
Change Alterar
Change All Alterar Tudo
Change Case Alterar Maiúsculas/Minúsculas
@@ -477,6 +478,7 @@
Corner Radius: Raio do Canto:
Corner: Canto:
Corners Cantos
Corners… Cantos…
Create Criar
Create Clipping Mask Criar Máscara de Recorte
Create Document Criar Documento
@@ -1085,6 +1087,7 @@
Invert Inverter
Invert Colors Inverter Cores
Invert Mask Inverter Máscara
Inverted Round Arredondado Invertido
Isolate Isolar
Isolate Blending Isolar Mesclagem
Isolate Group Isolar Grupo
@@ -1703,6 +1706,7 @@
Radial Radial
Radial Glows Brilhos Radiais
Radial Gradient Gradiente Radial
Radius: Raio:
Rainbow Arco-íris
Random Aleatório
Randomize Colors Randomizar Cores
@@ -1871,6 +1875,7 @@
Rotate… Girar…
Rotation Rotação
Roughen… Enrugar…
Round Arredondado
Round Cap Extremidade Redonda
Round Corners… Cantos Arredondados…
Round Join Junção Redonda
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -467,6 +467,10 @@
Corner Radius: 圆角半径:
Corner: 转角:
Corners 转角
Round 圆角
Inverted Round 反向圆角
Corners… 转角…
Chamfer 倒角
Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e}
Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e}
Couldn't save recovery data: {e} 无法存储恢复数据:{e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -480,6 +480,11 @@
Corner Radius: 圓角半徑:
Corner: 轉角:
Corners 轉角
Round 圓角
Radius: 半徑:
Inverted Round 反向圓角
Corners… 轉角…
Chamfer 倒角
Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
Couldn't save recovery data: {e} 無法儲存復原資料:{e}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -24,6 +24,7 @@ pub mod dialogs;
pub mod dock;
pub mod find_font;
pub mod font_menu;
pub mod graphics;
pub mod i18n;
pub mod icon_data;
pub mod icons;
@@ -280,6 +281,9 @@ pub struct VectorcraftApp {
pub synthetic: Vec<egui::Event>,
styled: bool,
fonts_ready: bool,
/// The egui context the UI's textures were uploaded to (0: none yet; see
/// [`Self::adopt_context`]).
context: u64,
/// Installed fonts added to the UI's for characters its own fonts lack (CJK names…).
ui_fonts: ui_fonts::UiFonts,
frame: u64,
@@ -369,6 +373,7 @@ impl VectorcraftApp {
synthetic: vec![],
styled: false,
fonts_ready: false,
context: 0,
ui_fonts: Default::default(),
frame: 0,
last_time: 0.0,
@@ -689,6 +694,7 @@ impl VectorcraftApp {
fn logic_frame(&mut self, ctx: &egui::Context) {
i18n::set_current(self.ui_language());
self.adopt_context(ctx);
if !self.styled {
theme::install_fonts(ctx);
theme::apply(ctx, self.ui.brightness);
+7
View File
@@ -441,6 +441,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{id, index} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove",
),
(
"ui.corners",
"Corners…",
"",
"{id?, corners?: [0..3…]} open Corners for live rectangle `id` (default: the selected one) and its corners (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else all four) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
),
(
"ui.colorGuideLimit",
"Limit Color Guide to Library",
@@ -1126,6 +1132,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
},
},
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.corners" => crate::dialogs::corners::open(app, p),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
"file.exportAs" if s("path").is_none() => {
+1 -1
View File
@@ -49,7 +49,7 @@ pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
/// The addresses of each asset's objects in one document revision (no tree walks per frame).
static KEYS: RefCell<(Revision, HashMap<u64, Look>)> = RefCell::new(Default::default());
static CACHE: RefCell<HashMap<(Look, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
static CACHE: crate::graphics::TexCache<HashMap<(Look, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
}
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
if !ui.is_rect_visible(r) {
+10 -2
View File
@@ -116,6 +116,12 @@ struct Cache {
view: View,
generation: u64,
renderer: Option<vectorcraft_render::Renderer>,
drawn: crate::graphics::TexCache<Drawn>,
}
/// The preview as drawn: for what, and its texture.
#[derive(Default)]
struct Drawn {
key: Option<Key>,
tex: Option<egui::TextureHandle>,
}
@@ -268,8 +274,10 @@ fn preview_area(ui: &mut Ui, t: &Tokens, h: Highlight, overprints: Overprints) {
let ppp = ui.ctx().pixels_per_point();
CACHE.with(|c| {
let mut c = c.borrow_mut();
let Cache { shot, view, renderer, key, tex, .. } = &mut *c;
let Cache { shot, view, renderer, drawn, .. } = &mut *c;
let Some(shot) = shot.as_ref() else { return };
let mut drawn = drawn.borrow_mut();
let Drawn { key, tex } = &mut *drawn;
let visible = view.visible(shot.fit);
if resp.double_clicked() {
*view = View::default();
@@ -420,7 +428,7 @@ mod tests {
let mut app = app();
frame(&mut app);
assert!(CACHE.with(|c| c.borrow().shot.is_some()), "a first look refreshes");
assert!(CACHE.with(|c| c.borrow().tex.is_some()), "the preview is drawn");
assert!(CACHE.with(|c| c.borrow().drawn.borrow().tex.is_some()), "the preview is drawn");
let v = command(&mut app, &json!({"highlight": "allAffected"})).unwrap();
assert_eq!(v["counts"]["allAffected"], 2);
assert_eq!(app.ui.open_panel.as_deref(), Some(ID));
+1 -2
View File
@@ -2,7 +2,6 @@
//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the
//! selected text objects).
use std::cell::RefCell;
use std::collections::HashMap;
use egui::{Color32, Sense, Ui, vec2};
@@ -29,7 +28,7 @@ const SUBSETS: [(&str, u32, u32); 7] = [
];
thread_local! {
static TEX: RefCell<HashMap<(u32, u32, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
static TEX: crate::graphics::TexCache<HashMap<(u32, u32, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
}
/// Antialiased coverage mask (nonzero winding) of `path` in a `w`×`h` pixel grid.
+1 -1
View File
@@ -68,7 +68,7 @@ const UV: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
/// Thumbnails by (look hash, pixels, preview shape, Override Character Color on type).
static THUMBS: RefCell<HashMap<(u64, u32, Preview, bool), TextureHandle>> = RefCell::new(HashMap::new());
static THUMBS: crate::graphics::TexCache<HashMap<(u64, u32, Preview, bool), TextureHandle>> = crate::graphics::TexCache::default();
}
// ---------- thumbnails ----------
+1 -1
View File
@@ -714,7 +714,7 @@ pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r:
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: RefCell<HashMap<(usize, u64), egui::TextureHandle>> = RefCell::new(HashMap::new());
static CACHE: crate::graphics::TexCache<HashMap<(usize, u64), egui::TextureHandle>> = crate::graphics::TexCache::default();
}
if !ui.is_rect_visible(r) {
return true;
+8 -1
View File
@@ -45,7 +45,7 @@ pub mod transparency;
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Node, StrokeLayer};
use vectorcraft_doc::{LiveShape, Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use crate::theme::Tokens;
@@ -57,6 +57,13 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
first_node(app).cloned()
}
/// The radius the Live Corners of live rectangle `n` show in the panels: that of the corners the
/// panels set (the Direct-Selected ones, else all four), blank when they differ.
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node, live: &LiveShape) -> Option<f64> {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
live.corner_style(live.picked_corners(partial)).0
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)
+2 -3
View File
@@ -2,8 +2,6 @@
//! document revision) with the red view box you drag to pan, zoom out/in buttons, a zoom slider
//! and a zoom field.
use std::cell::RefCell;
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_geom::{Point, Rect as DRect};
@@ -13,6 +11,7 @@ use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
#[derive(Default)]
struct Cache {
renderer: Option<vectorcraft_render::Renderer>,
key: Option<(usize, u64, u32, u32, bool)>,
@@ -20,7 +19,7 @@ struct Cache {
}
thread_local! {
static CACHE: RefCell<Cache> = const { RefCell::new(Cache { renderer: None, key: None, tex: None }) };
static CACHE: crate::graphics::TexCache<Cache> = crate::graphics::TexCache::default();
}
/// Map a document point into the thumbnail rect (region → rect, aspect preserved).
+3 -3
View File
@@ -4,7 +4,7 @@ use egui::Ui;
use serde_json::json;
use vectorcraft_doc::{NodeKind, Unit};
use super::{first_selected, pstate, set_pstate};
use super::{corner_radius, first_selected, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, dim_label, divider, section_header};
use crate::{VectorcraftApp, icons};
@@ -317,10 +317,10 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
{
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
vectorcraft_doc::LiveShape::Rectangle { .. } => {
ui.horizontal(|ui| {
dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = widgets::num_field(ui, "radius", Some(radii[0]), units, 80.0) {
if let Some(r) = widgets::num_field(ui, "radius", corner_radius(app, &n, live), units, 80.0) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
});
+1 -2
View File
@@ -169,11 +169,10 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) {
use std::cell::RefCell;
use std::collections::HashMap;
type Key = (String, Vec<usize>, String, u32);
thread_local! {
static CACHE: RefCell<HashMap<Key, Option<egui::TextureHandle>>> = RefCell::new(HashMap::new());
static CACHE: crate::graphics::TexCache<HashMap<Key, Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
}
let Paint::Pattern { pattern, .. } = paint else { return };
let Some(st) = app.session.active() else { return };
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::widgets::{self, PanelDrag, menu_item};
fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option<egui::TextureHandle> {
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: RefCell<HashMap<(usize, String, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
static CACHE: crate::graphics::TexCache<HashMap<(usize, String, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
}
let sym = doc.symbols.iter().find(|s| s.name == name)?;
let px = (size * ui.ctx().pixels_per_point()).round().max(8.0) as u32;
+3 -3
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use super::{first_selected, pstate, set_pstate};
use super::{corner_radius, first_selected, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
@@ -170,11 +170,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
{
widgets::divider(ui);
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
vectorcraft_doc::LiveShape::Rectangle { .. } => {
widgets::subheader(ui, tl!("Rectangle Properties:"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = widgets::num_field(ui, "xfp-radius", Some(radii[0]), units, 80.0) {
if let Some(r) = widgets::num_field(ui, "xfp-radius", corner_radius(app, &n, live), units, 80.0) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
});
+19 -14
View File
@@ -51,16 +51,26 @@ pub enum DropTarget {
pub struct PlaceState {
/// Web: the bytes of the files picked for the Place dialog, by name.
picked: Vec<(String, Vec<u8>)>,
/// The loaded cursor's thumbnails by file name: decoded when the cursor is loaded, uploaded
/// when first drawn.
/// The loaded cursor's thumbnails by file name.
thumbs: Vec<(String, Thumb)>,
/// The Control bar's `image.info`, for (document uid, revision, image id).
info: Option<((u64, u64, u64), Value)>,
}
enum Thumb {
Decoded(egui::ColorImage),
Uploaded(egui::TextureHandle),
/// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and
/// again in a new graphics context).
struct Thumb {
image: std::sync::Arc<egui::ColorImage>,
tex: Option<egui::TextureHandle>,
}
impl PlaceState {
/// The UI moved to a new egui context: the thumbnails are uploaded again when drawn.
pub(crate) fn forget_textures(&mut self) {
for (_, th) in &mut self.thumbs {
th.tex = None;
}
}
}
/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the
@@ -230,7 +240,7 @@ pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let png = vectorcraft_format::base64_decode(f["thumbnailBase64"].as_str()?)?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()?.to_rgba8();
let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
Some((f["name"].as_str()?.to_string(), Thumb::Decoded(color)))
Some((f["name"].as_str()?.to_string(), Thumb { image: std::sync::Arc::new(color), tex: None }))
})
.collect();
Ok(r)
@@ -243,15 +253,10 @@ pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egu
let name = opts["name"].as_str().unwrap_or_default();
let t = Tokens::get(ctx);
let tex = app.place.thumbs.iter_mut().find(|(n, _)| n == name).map(|(n, th)| {
if let Thumb::Decoded(img) = th {
*th = Thumb::Uploaded(ctx.load_texture(format!("place-thumb-{n}"), std::mem::take(img), egui::TextureOptions::LINEAR));
}
match th {
Thumb::Uploaded(tex) => Some((tex.id(), tex.size_vec2())),
Thumb::Decoded(_) => None,
}
let tex = th.tex.get_or_insert_with(|| ctx.load_texture(format!("place-thumb-{n}"), th.image.clone(), egui::TextureOptions::LINEAR));
(tex.id(), tex.size_vec2())
});
let (id, size) = tex.flatten().unzip();
let (id, size) = tex.unzip();
let size = size.unwrap_or(egui::vec2(THUMB as f32, THUMB as f32 * 0.75));
let k = THUMB as f32 / size.x.max(size.y).max(1.0);
let r = egui::Rect::from_min_size(pos + THUMB_OFFSET, size * k);
+6 -2
View File
@@ -95,9 +95,13 @@ pub(super) fn blue_png() -> Vec<u8> {
png
}
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`.
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`,
/// in the test's one context (in a new one the app starts its fonts over).
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
thread_local! {
static CTX: egui::Context = egui::Context::default();
}
let ctx = CTX.with(Clone::clone);
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())).textures_delta.clear();
}
+1 -1
View File
@@ -1269,7 +1269,7 @@ pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64)
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
static CACHE: crate::graphics::TexCache<HashMap<String, egui::TextureHandle>> = crate::graphics::TexCache::default();
}
let ppp = ui.ctx().pixels_per_point() as f64;
let (w, h) = (size.x as f64, size.y as f64);
+5
View File
@@ -253,6 +253,11 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt
`adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard",
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
Corners: double-clicking a Live Corners widget with the Selection or Direct Selection tool, or `ui.corners {id?,
corners?}`, opens the `corners` dialog for a live rectangle's corners (the Direct-Selected ones, else all four; fields
`id`, `corners`: indices 0–3 clockwise from the top-left, `kind`: round, invertedRound or chamfer, `radius` in points;
`kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`,
opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's
normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them.
+9
View File
@@ -22,6 +22,15 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files dropped on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
2. The shell puts a fresh `<canvas>` in place of the dead one (a canvas whose WebGL context is lost can't get another) and starts the same `WebRunner` again on it, with new graphics and a new egui context, handing over the same `VectorcraftApp`: documents, undo history, selection and panels stay as they were. Its `Services` are made again for the new context, and the status bar says the graphics were restarted.
3. Textures belong to the egui context they were uploaded to. The app notices the new context (`VectorcraftApp::adopt_context`) and uploads everything again: fonts and theme, the canvas, the place cursor's thumbnails, and every per-thread texture cache (thumbnails, previews, font samples), which are `graphics::TexCache`s for that reason. A new texture cache must be a `TexCache` (or live in the context's `ctx.data`), or it shows stale or wrong pictures after a restart.
4. If no new graphics can be had (no adapter, WebGL blocked after repeated losses) or the graphics were lost more than three times, the shell gives up: the dead canvas is removed, the app lets go of its recovery area but not its copies (`recovery::leave`: its heartbeat and Web Lock go, so the reloaded page offers the copies at once). The Web Locks are started once, before the first frame, and kept across graphics restarts, so a restarted tab stays the owner of its area and the page says to reload.
To try it, in the browser console: for WebGL2 (`?webgl`), `document.querySelector('canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()`. For WebGPU, keep the device the page creates (wrap `GPUAdapter.prototype.requestDevice` before the page loads) and call its `destroy()`.
The desktop app can't make a new device for its window (eframe can't), so on a lost device (a driver reset) it logs the reason, writes the recovery copies, and says in the status bar to save and restart. Once the copies are written, closing the window skips the Save Changes question, which it can't show: the next launch offers the changes back.
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
+16
View File
@@ -1039,6 +1039,22 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
{"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}}
```
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of live rectangles (one undo step):
`radius` (pt) and `kind` (`round`, `invertedRound` or `chamfer`) go to the `corners` given (0 top-left, 1 top-right,
2 bottom-right, 3 bottom-left), else to the corners holding a Direct-Selected anchor (`select.anchors`), else to all
four. Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't
round, `kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as
that changes. With the Selection or Direct Selection tool, dragging a corner widget rounds the corners whose widgets
show (all four, or the Direct-Selected ones), Alt-clicking one cycles their kind and double-clicking one opens Corners
(`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[0,3],"radius":8,"kind":"chamfer"}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel