graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: 2D images stand in the viewport, sized in pixels or real units
The displayed page is a textured quad in the 3D scene (cce-ui's SceneImage),
at its physical size in world units about the page node's Position, where a
pane of its own used to take the viewport's place. The display flag is
exclusive within its context, so a level shows one image and one geometry.
The page node is the 2D generator: a Units row (inches, millimetres,
centimetres, pixels) that its size and every node downstream are written
in, raster presets, an opacity and a position. page_shape draws rectangles,
ellipses, lines and polygons, turned, filled and stroked.
Commands: frame_image and view_image_pixels turn the camera square to the
image; new_image and the add_image_* family make an image and put shapes
and text on it, placed and sized from the image in its own unit. Frame All
holds the image.
Needs cce-ui 764954b (claude/2d-features); the git pin is not moved here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 101 +++++++----
nodes/page.json | 13 +-
nodes/page_border.json | 4 +-
nodes/page_grid.json | 4 +-
nodes/page_shape.json | 24 +++
nodes/page_text.json | 6 +-
src/app.rs | 106 +++++++-----
src/command.rs | 12 ++
src/image_tools.rs | 330 ++++++++++++++++++++++++++++++++++++
src/main.rs | 448 ++++++++++++++++++++++++++++++++++++++++++++++++-
src/page.rs | 404 +++++++++++++++++++++++++++++++++++++++-----
src/render.rs | 82 +++++----
src/shortcut.rs | 8 +
src/slots.rs | 6 +-
14 files changed, 1369 insertions(+), 179 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c24265b..ee1ec6d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1540,11 +1540,71 @@ operands on ONE grid so the two fields line up sample for sample.
### The 2D page context
`src/page.rs` is a second context, not a second kind of geometry node. Its
-currency is a `Page` — a printed sheet: inches, a DPI, and straight-alpha RGBA
-pixels — its origin is the top-left corner with y running DOWN, and nothing in
-it has a point id, an attribute or a normal. Four nodes compose one: `page`
-(the sheet: preset or custom size, orientation, resolution, colour),
-`page_grid`, `page_border` and `page_text`.
+currency is a `Page` — an image: a physical size, a DPI, and straight-alpha
+RGBA pixels — its origin is the top-left corner with y running DOWN, and
+nothing in it has a point id, an attribute or a normal. Five nodes compose
+one: `page` (the generator: preset or custom size, units, orientation,
+resolution, colour, opacity, position), `page_grid`, `page_border`,
+`page_text` and `page_shape`.
+
+**The generator's size is in pixels or in real units** (since 2026-09-29).
+The `page` node's `Units` row — Inches, Millimetres, Centimetres, Pixels —
+is what its Width and Height are written in, and what EVERY node downstream
+is written in: the page carries its `PageUnit`, and the resolver converts
+each length through `Page::len` before it draws. A property of the page
+and not of each node, because a chain whose text was placed in pixels and
+whose border was inset in inches is a chain nobody can read. Inside, a page
+is still inches (`Page::size`), and a pixel image's physical size is its
+pixels over its Resolution. A node with no Units row is in inches, which is
+every save from before it. The presets are the four paper sizes and three
+raster ones (`HD`, `4K`, `Square`), which are their pixels whatever Units
+says and are not turned by Orientation. The length rows are `float` — a
+number with no range — where they were sliders over a range in inches: a
+slider clamps, and no one range holds both 0.25 inches and 1920 pixels.
+
+**`page_shape`** draws a rectangle (with a corner radius), an ellipse, a
+line or a polygon of N sides, turned by Rotation, filled and stroked, each
+with an opacity. Coverage comes from the signed DISTANCE to the outline in
+pixels (`Page::shape`), so a turned edge and a circle's are clean lines; the
+stroke is centred on the outline. A line is its stroke: as long as its
+Width, as thick as its Stroke Width.
+
+**The viewport shows the image, standing in the scene** (since
+2026-09-29). The displayed page is uploaded as a texture and staged as a
+`cce_ui::vk::SceneImage` — a textured quad in the 3D pass, unlit, depth
+tested against the geometry, seen from both sides — in the XY plane about
+the page node's `Position`, facing +Z, at its PHYSICAL size: the World Unit
+says what one world unit is, and a sheet 215.9 mm wide is 215.9 of them
+when that is a millimetre (`PageShown::world_size`, the one place a length
+is converted INTO world units). It is staged after the furniture and the
+markers and before the geometry, whose fill may be translucent over it, and
+the shader discards a texel that shows nothing so a transparent page does
+not hide what is behind it. `State::page_shown` is what the stage pass
+places it by. Until then a pane of its own (`PAGE_IDX`, an `ImageView`)
+took the viewport's rect whenever the level held a page, so a picture and
+a model could not be seen together. The path tracer does not draw it.
+
+**The display flag is exclusive within its CONTEXT**
+(`set_child_geometry_visible`): the page nodes and the geometry nodes each
+have one, so a level shows one image and one geometry. One flag over both
+is what made showing a picture hide the model.
+
+**The image commands** (`src/image_tools.rs`, all registry rows):
+`frame_image` (Ctrl+Shift+F, and a viewport-menu row while an image shows)
+turns the active camera square to the image and fits it to the pane;
+`view_image_pixels` does the same at the distance where one image pixel
+covers one display pixel. A plane square to the view axis is scaled by a
+perspective and not distorted, so head-on the image is exact. The Default
+Camera is turned by setting its orbit to what cancels its base ray's own
+yaw and pitch; a camera node has Position, Pivot and Rotation rewritten,
+the Rotation taking up whatever orbit the viewport widget holds, which
+`get_matrices` applies to every camera. Frame All holds the image's
+corners beside the geometry. `new_image` adds a page node, shown and
+selected; `add_image_rectangle` / `_ellipse` / `_line` / `_polygon` /
+`_text` add a shape or text node wired after the selected image node (else
+the shown one, else a new image), placed at the image's middle and sized
+from it IN THE IMAGE'S UNIT, shown and selected. Added to the middle of a
+chain the node is inserted: what read the target reads the new node.
The two contexts do not mix, and `is_page_node` is the one place that says so.
A page node contributes nothing to the viewport's geometry and a geometry node
@@ -1560,9 +1620,7 @@ size × DPI, and `write_png` puts that in the pHYs chunk, so a printer lays the
file out at the size it was composed at instead of guessing 96. pHYs is pixels
per metre — the only unit PNG offers — so the DPI round-trips through a
conversion and comes back a hair off (300 stores as 11811 px/m, reads as
-299.9994). Inches rather than millimetres because paper is specified in inches
-by the family this came from; the geometry graph's World Unit declaration does
-not reach here.
+299.9994).
Rect coverage is exact area, not a test of the pixel centre. A printed grid is
mostly hairlines, and a binary fill snaps every rule to whole pixels, so a
@@ -1573,32 +1631,15 @@ the cell size lands exactly on the first's, which is the only reason to draw
two. Text shapes and rasterizes through cosmic-text, the toolkit's own font
stack, with system fonts loaded because a page names its font by family.
-**The preview pane** (`PAGE_IDX`, an `ImageView`) takes the viewport's rect
-when the displayed level holds a page, and the viewport stands down — the same
-rule the viewport already follows about showing its editor's level. Three
-things were needed to make a new pane actually appear, and missing any one of
-them looks identical to the others:
-
-- A `PAGE_IDX` arm in `paint_widget`. The fall-through branch serves LEGACY
- widgets — it emits a plate and the widget's legacy views — so a modern-paint
- widget whose whole look lives in `Paint::paint` lands there and draws
- nothing. The pane was visible, correctly placed and blank.
-- The viewport's key in the `draw_order` sort. The viewport is full-bleed and
- the other panes float OVER it, so a pane taking its rect must take its depth;
- drawn last, it covered the collapsed stubs and their labels ghosted through
- from the later text pass.
-- An entry in `test_widget_roster_indices_are_dense`, which is hand-listed and
- fails loudly — the one of the three that tells you itself.
-
-The GPU image is owned by `State::page_image` and freed when replaced;
-`ImageView` only borrows the id. **A replacement renderer invalidates that id.** There is no reconnect
+The GPU image is owned by `State::page_image` and freed when replaced.
+**A replacement renderer invalidates that id.** There is no reconnect
callback: the runner calls `renderer_init` once per renderer, so the first call
is this process's own and every later one is a replacement — remembering is the
only way to tell them apart (`State::seen_renderer`, via
`renderer_handed_over`, which is split out of the callback so it can be tested
without a live `VkRenderer`). Images uploaded outside that callback are not
replayed, so a cached id names nothing and its draws are skipped in SILENCE:
-the page pane just goes blank. The id is dropped and `page_dirty` asks the next
+the image just goes from the scene. The id is dropped and `page_dirty` asks the next
tick to recompose and re-upload — the raster is cheap to rebuild from the node
graph, and no id can be carried across renderers. Found by cce-1f's audit of
clients caching vk image ids.
@@ -1613,13 +1654,13 @@ on 2026-09-19 — with the fix disabled the sheet vanishes at the fault, with it
the sheet survives.
`gem_graph`, the source family's
-everything-at-once node, is deliberately not ported: it is these four chained,
+everything-at-once node, is deliberately not ported: it is these nodes chained,
and that collapse is the whole premise of "fifty operators, ten nodes".
### The pane plates: one material, one relief block
Every plate the designer draws — the network panel, params, spreadsheet,
-playbar, page pane, and every collapsed stub — goes through
+playbar, and every collapsed stub — goes through
`append_widget_plate_radii` in `render.rs`, and every one of those widgets
answers `color()` with `cce_ui::colors::param_plate_fill`, which is
`Material::pane()`: the toolkit's PANE rung. So there is ONE material for
diff --git a/nodes/page.json b/nodes/page.json
index 0d4a9ec..d6f8da0 100644
--- a/nodes/page.json
+++ b/nodes/page.json
@@ -4,11 +4,14 @@
"inputs": 0,
"outputs": 1,
"params": [
- { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,Custom", "default": "Letter" },
- { "name": "Width", "type": "slider", "default": "8.5", "min": 0.5, "max": 48.0, "step": 0.25, "show_when": "Preset == Custom" },
- { "name": "Height", "type": "slider", "default": "11.0", "min": 0.5, "max": 48.0, "step": 0.25, "show_when": "Preset == Custom" },
- { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait" },
+ { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square,Custom", "default": "Letter" },
+ { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches" },
+ { "name": "Width", "type": "float", "default": "8.5", "show_when": "Preset == Custom" },
+ { "name": "Height", "type": "float", "default": "11.0", "show_when": "Preset == Custom" },
+ { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "show_when": "Preset == Letter|A4|Legal|Tabloid" },
{ "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6 },
- { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0 }
+ { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0 },
+ { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01 },
+ { "name": "Position", "type": "float3", "default": "0.00:0.00:0.00", "min": -1000.0, "max": 1000.0 }
]
}
diff --git a/nodes/page_border.json b/nodes/page_border.json
index 91aa49a..0da56f4 100644
--- a/nodes/page_border.json
+++ b/nodes/page_border.json
@@ -5,8 +5,8 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Width", "type": "slider", "default": "0.06", "min": 0.001, "max": 2.0, "step": 0.005 },
- { "name": "Inset", "type": "slider", "default": "0.40", "min": 0.0, "max": 4.0, "step": 0.05 },
+ { "name": "Width", "type": "float", "default": "0.06" },
+ { "name": "Inset", "type": "float", "default": "0.40" },
{ "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 }
]
}
diff --git a/nodes/page_grid.json b/nodes/page_grid.json
index c59e65f..000f5d5 100644
--- a/nodes/page_grid.json
+++ b/nodes/page_grid.json
@@ -5,8 +5,8 @@
"outputs": 1,
"params": [
{ "name": "Input", "type": "node", "default": "" },
- { "name": "Cell Size", "type": "slider", "default": "0.25", "min": 0.01, "max": 4.0, "step": 0.01 },
- { "name": "Line Width", "type": "slider", "default": "0.01", "min": 0.001, "max": 0.25, "step": 0.001 },
+ { "name": "Cell Size", "type": "float", "default": "0.25" },
+ { "name": "Line Width", "type": "float", "default": "0.01" },
{ "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
{ "name": "Fill Cells", "type": "toggle", "default": "false" },
{ "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true" }
diff --git a/nodes/page_shape.json b/nodes/page_shape.json
new file mode 100644
index 0000000..b1ddf49
--- /dev/null
+++ b/nodes/page_shape.json
@@ -0,0 +1,24 @@
+{
+ "name": "Page Shape",
+ "type": "page_shape",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+ { "name": "Input", "type": "node", "default": "" },
+ { "name": "Shape", "type": "choice:Rectangle,Ellipse,Line,Polygon", "default": "Rectangle" },
+ { "name": "X", "type": "float", "default": "4.25" },
+ { "name": "Y", "type": "float", "default": "5.50" },
+ { "name": "Width", "type": "float", "default": "3.00" },
+ { "name": "Height", "type": "float", "default": "2.00", "show_when": "Shape != Line" },
+ { "name": "Rotation", "type": "slider", "default": "0", "min": -180.0, "max": 180.0, "step": 1.0 },
+ { "name": "Corner Radius", "type": "float", "default": "0.00", "show_when": "Shape == Rectangle" },
+ { "name": "Sides", "type": "spinbox", "default": "3", "min": 3, "max": 64, "step": 1, "show_when": "Shape == Polygon" },
+ { "name": "Fill", "type": "toggle", "default": "true", "show_when": "Shape != Line" },
+ { "name": "Fill Color", "type": "float3", "default": "0.20:0.45:0.85", "min": 0.0, "max": 1.0, "show_when": "Shape != Line && Fill == true" },
+ { "name": "Fill Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Shape != Line && Fill == true" },
+ { "name": "Stroke", "type": "toggle", "default": "true" },
+ { "name": "Stroke Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "show_when": "Stroke == true" },
+ { "name": "Stroke Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Stroke == true" },
+ { "name": "Stroke Width", "type": "float", "default": "0.02", "show_when": "Stroke == true" }
+ ]
+}
diff --git a/nodes/page_text.json b/nodes/page_text.json
index 22a722c..3629811 100644
--- a/nodes/page_text.json
+++ b/nodes/page_text.json
@@ -7,10 +7,10 @@
{ "name": "Input", "type": "node", "default": "" },
{ "name": "Text", "type": "text", "default": "Title" },
{ "name": "Font", "type": "text", "default": "" },
- { "name": "Size", "type": "slider", "default": "0.25", "min": 0.02, "max": 4.0, "step": 0.01 },
+ { "name": "Size", "type": "float", "default": "0.25" },
{ "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
- { "name": "X", "type": "slider", "default": "4.25", "min": 0.0, "max": 48.0, "step": 0.05 },
- { "name": "Y", "type": "slider", "default": "0.80", "min": 0.0, "max": 48.0, "step": 0.05 },
+ { "name": "X", "type": "float", "default": "4.25" },
+ { "name": "Y", "type": "float", "default": "0.80" },
{ "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center" },
{ "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top" },
{ "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05 }
diff --git a/src/app.rs b/src/app.rs
index db7dce8..c327670 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -34,7 +34,7 @@ use wayland_client::{
Connection, QueueHandle, Proxy,
};
-use cce_ui::widget::{Adapted, Breadcrumb, ImageView, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Label, Dropdown};
+use cce_ui::widget::{Adapted, Breadcrumb, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Label, Dropdown};
use cce_ui::widget::UiContext;
use crate::playbar::Playbar;
use crate::viewport_3d::Viewport3D;
@@ -179,13 +179,21 @@ impl FsNode {
/// flag). Disabling touches only the named child. Every toggle route
/// (keyboard `e`, the graph widgets' click toggles, MCP/context-menu
/// ToggleGeometry) must go through here or the invariant silently rots.
+ ///
+ /// Exclusive within its CONTEXT, since 2026-09-29: the page nodes and the
+ /// geometry nodes each have a display flag of their own, so a level shows
+ /// one image and one geometry — a picture behind the model drawn over
+ /// it. Until then a page took the viewport's pane whole and one flag did.
pub fn set_child_geometry_visible(&mut self, slot: usize, visible: bool) {
if slot >= self.children.len() {
return;
}
if visible {
+ let page = crate::page::is_page_node(&self.children[slot].node_type);
for (i, child) in self.children.iter_mut().enumerate() {
- child.geometry_visible = i == slot;
+ if crate::page::is_page_node(&child.node_type) == page {
+ child.geometry_visible = i == slot;
+ }
}
} else {
self.children[slot].geometry_visible = false;
@@ -2029,9 +2037,12 @@ pub struct State {
/// Solved simulation states, kept across frames so playing forward costs one
/// step per frame instead of re-solving from the start frame every redraw.
pub sim_cache: crate::geometry::SimCache,
- /// The GPU image behind the page pane. Owned here — `ImageView` only
- /// borrows an id — so replacing a page frees the one it replaces.
+ /// The GPU image of the page the viewport shows. Owned here, so
+ /// replacing a page frees the one it replaces.
pub page_image: Option<u32>,
+ /// The page that image is of: what the scene pass places it by and the
+ /// framing commands fit the camera to. None when the level shows none.
+ pub page_shown: Option<crate::page::PageShown>,
/// Whether `renderer_init` has run before. There is no separate reconnect
/// callback: the runner calls `renderer_init` once per renderer, so the
/// first call is this process's own and every later one is a REPLACEMENT
@@ -4492,20 +4503,28 @@ impl State {
/// 1 so the distance is authoritative. For the Default Camera (no node
/// to write) only the zoom is fitted — its pivot is fixed at the origin.
pub fn frame_all(&mut self) {
- if self.rt_sphere_verts.is_empty() {
+ // The image the level shows is part of the scene, so it is part of
+ // what Frame All holds: its four corners, beside the geometry.
+ let image = self.image_world_corners().map(|c| c.map(Vec3::from_array));
+ let points = || {
+ self.rt_sphere_verts
+ .iter()
+ .map(|v| Vec3::from_array(v.position))
+ .chain(image.into_iter().flatten())
+ };
+ if points().next().is_none() {
return;
}
let mut min = Vec3::splat(f32::MAX);
let mut max = Vec3::splat(f32::MIN);
- for v in &self.rt_sphere_verts {
- let p = Vec3::from_array(v.position);
+ for p in points() {
min = min.min(p);
max = max.max(p);
}
let center = (min + max) * 0.5;
let mut radius = 0.0f32;
- for v in &self.rt_sphere_verts {
- radius = radius.max((Vec3::from_array(v.position) - center).length());
+ for p in points() {
+ radius = radius.max((p - center).length());
}
let radius = radius.max(0.05);
@@ -5206,6 +5225,12 @@ impl State {
};
let sep = ViewportMenuAction::Separator;
+ // The image's two camera rows, under the scene's, while one shows.
+ if self.page_shown.is_some() {
+ row(&mut options, &mut actions, label("frame_image", "Frame Image").to_string(), ViewportMenuAction::Command("frame_image"));
+ row(&mut options, &mut actions, label("view_image_pixels", "View Image Pixels 1:1").to_string(), ViewportMenuAction::Command("view_image_pixels"));
+ }
+
// Guides: the scene furniture that is not the geometry.
row(&mut options, &mut actions, "-".into(), sep);
row(&mut options, &mut actions, format!("{} {}", mark(self.viewport().show_grid), label("toggle_grid", "Show Grid")), ViewportMenuAction::Command("toggle_grid"));
@@ -6182,18 +6207,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
bc.set_raised(true);
bc
},
- page_view: {
- // Contain, never crop: a page is a document, and a document
- // shown with its margins cut off is a different document. No
- // upscale past 1:1 either — a 72 DPI sheet blown up to fill
- // the pane would look like the composition is soft when it is
- // the preview that is.
- let mut v = ImageView::new()
- .with_fit(cce_ui::scene::layout::FitMode::Contain { max_upscale: 1.0 })
- .with_bg([0.12, 0.12, 0.13, 1.0]);
- v.set_visible(false);
- v
- },
dialog: crate::dialog::Dialog::new(),
});
@@ -6300,6 +6313,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
network_menu_actions: Vec::new(),
sim_cache: crate::geometry::SimCache::default(),
page_image: None,
+ page_shown: None,
seen_renderer: false,
deselected_cell: None,
orbit_drag: None,
@@ -7402,27 +7416,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
self.apply_detached_panes();
- // The 2D page context takes the viewport's rect whenever the displayed
- // level holds a page, and the viewport stands down: one pane, one
- // thing in it. Placed here, after every layout branch has run, rather
- // than inside each of them — the rect it wants is always exactly the
- // viewport's, so there is nothing per-branch to decide.
- //
- // The ImageView's own image is the flag. Composing a page is
- // expensive and happens in rebuild_scene_geometry; layout runs on
- // every resize, and a second copy of "is a page showing" would be a
- // second thing to keep true.
- let showing_page = self.slots.page_view.image.is_some();
- self.positions[PAGE_IDX] = if showing_page {
- self.positions[VIEWPORT_IDX]
- } else {
- (0.0, 0.0, 0.0, 0.0)
- };
- self.slots.page_view.set_visible(showing_page && self.slots.viewport.visible());
- if showing_page {
- self.slots.viewport.set_visible(false);
- }
-
self.apply_collapsed_panes();
// Last of all: the dialog floats over whatever the branches above
// produced, so its rect depends on the window and nothing else.
@@ -8037,6 +8030,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
Action::LayoutNodes => {
self.layout_current_level();
}
+ Action::FrameImage => {
+ self.frame_image();
+ }
+ Action::ViewImagePixels => {
+ self.view_image_pixels();
+ }
+ Action::NewImage => {
+ self.new_image();
+ }
+ Action::AddToImage(layer) => {
+ self.add_to_image(layer);
+ }
Action::FrameCursor => {
self.frame_cursor();
}
@@ -10434,9 +10439,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if let Some(old) = self.page_image.take() {
cce_ui::vk::free_image(old);
}
- self.slots.page_view.set_image(None);
// Re-uploaded on the next tick, not here: this runs before the frame
- // has settled, and rebuild_page relays the panes.
+ // has settled.
self.page_dirty = true;
true
}
@@ -10743,6 +10747,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if self.overlay_point_count > 0 {
draws.push(SceneDraw { mesh: meshes.overlay_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
}
+ // The page the level shows stands in the scene as an
+ // image: after the furniture and the markers, which are
+ // opaque and may show through it, and before the
+ // geometry, whose fill may be translucent over it.
+ let image_slot = draws.len() as u32;
if self.vertex_count_spheres > 0 {
// With wires coming, the fill is pushed back by its
// slope-scaled offset so the lattice reads solid.
@@ -10794,6 +10803,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
renderer.stage_scene((sx, sy, cw, ch), draws);
+ if let (Some(image), Some(shown)) = (self.page_image, &self.page_shown) {
+ renderer.stage_scene_images(vec![cce_ui::vk::SceneImage {
+ image,
+ corners: shown.world_corners(self.world_unit_mm()),
+ mvp,
+ opacity: 1.0,
+ before: image_slot,
+ }]);
+ }
}
// Update viewport cache
diff --git a/src/command.rs b/src/command.rs
index c46a969..352462a 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -163,6 +163,18 @@ pub const COMMANDS: &[Command] = &[
Command { id: "layout_nodes", label: "Layout Nodes", context: Context::Network, run: Run::Key(Action::LayoutNodes), default_chord: Some("Ctrl+Shift+l") },
Command { id: "frame_all", label: "Frame All", context: Context::Network, run: Run::Key(Action::FrameAll), default_chord: Some("Shift+f") },
+ // --- Images ---
+ // The 2D context's commands (`src/image_tools.rs`). The two camera rows
+ // are the viewport's; the rest make nodes, so they are the network's.
+ Command { id: "frame_image", label: "Frame Image", context: Context::Viewport, run: Run::Key(Action::FrameImage), default_chord: Some("Ctrl+Shift+f") },
+ Command { id: "view_image_pixels", label: "View Image Pixels 1:1", context: Context::Viewport, run: Run::Key(Action::ViewImagePixels), default_chord: None },
+ Command { id: "new_image", label: "New Image", context: Context::Network, run: Run::Key(Action::NewImage), default_chord: None },
+ Command { id: "add_image_rectangle", label: "Add Rectangle to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Rectangle)), default_chord: None },
+ Command { id: "add_image_ellipse", label: "Add Ellipse to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Ellipse)), default_chord: None },
+ Command { id: "add_image_line", label: "Add Line to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Line)), default_chord: None },
+ Command { id: "add_image_polygon", label: "Add Polygon to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Polygon)), default_chord: None },
+ Command { id: "add_image_text", label: "Add Text to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Text)), default_chord: None },
+
// --- Network ---
// The add-node palette. Tab opens it inline (like Escape's cascade, and
// like `deselect` above, the row ships unbound rather than duplicating a
diff --git a/src/image_tools.rs b/src/image_tools.rs
new file mode 100644
index 0000000..078234f
--- /dev/null
+++ b/src/image_tools.rs
@@ -0,0 +1,330 @@
+//! Working with 2D images from the viewport and the palette: the commands
+//! that turn the camera to an image, and the ones that make an image and put
+//! shapes and text on it.
+//!
+//! An image is a page (`src/page.rs`) — a `page` node and whatever draws on
+//! it — and the viewport shows the displayed one as a quad standing in the
+//! scene. Everything here is a registry command, so each is in the palette,
+//! bindable and scriptable over MCP's `run_command`.
+
+use crate::app::{FsNode, State};
+use crate::page::{is_page_node, PageShown};
+use glam::Vec3;
+
+/// What an Add … to Image command puts on the image.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ImageLayer {
+ Rectangle,
+ Ellipse,
+ Line,
+ Polygon,
+ Text,
+}
+
+impl ImageLayer {
+ fn template(self) -> &'static str {
+ match self {
+ ImageLayer::Text => "Page Text",
+ _ => "Page Shape",
+ }
+ }
+
+ fn label(self) -> &'static str {
+ match self {
+ ImageLayer::Rectangle => "Rectangle",
+ ImageLayer::Ellipse => "Ellipse",
+ ImageLayer::Line => "Line",
+ ImageLayer::Polygon => "Polygon",
+ ImageLayer::Text => "Text",
+ }
+ }
+}
+
+/// The vertical field of view `Viewport3D::get_matrices` projects with,
+/// halved.
+const HALF_FOV: f32 = 0.45;
+
+/// How much of the pane a framed image leaves as margin, as a factor on the
+/// distance: the image spans ~93% of the axis that binds.
+const FRAME_MARGIN: f32 = 1.08;
+
+/// The distance at which an image `size` world units across fills a pane of
+/// `aspect`, seen head-on. The projection is a perspective, and a plane
+/// square to the view axis is scaled by it and not distorted, so one distance
+/// fits both axes and the nearer fit of the two is the one that binds.
+pub fn fit_distance(size: [f32; 2], aspect: f32) -> f32 {
+ let t = HALF_FOV.tan();
+ let by_height = size[1] / (2.0 * t);
+ let by_width = size[0] / (2.0 * t * aspect.max(1e-4));
+ by_height.max(by_width) * FRAME_MARGIN
+}
+
+/// The distance at which one pixel of an image `image_px` tall and `height`
+/// world units tall covers one pixel of a pane `pane_px` tall.
+pub fn pixel_distance(height: f32, image_px: u32, pane_px: u32) -> f32 {
+ let world_per_px = height / image_px.max(1) as f32;
+ world_per_px * pane_px.max(1) as f32 / (2.0 * HALF_FOV.tan())
+}
+
+fn set_param(node: &mut FsNode, name: &str, value: String) {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+ p.set_text(value);
+ }
+}
+
+/// A length for a parameter row: what the page's unit is read to. Pixels
+/// are whole, the rest two decimals.
+fn row_number(v: f32, unit: crate::page::PageUnit) -> String {
+ if unit == crate::page::PageUnit::Pixels {
+ format!("{}", v.round())
+ } else {
+ format!("{v:.2}")
+ }
+}
+
+impl State {
+ /// The pane's aspect as the stage pass has it.
+ fn viewport_aspect(&self) -> f32 {
+ if self.square_viewport {
+ 1.0
+ } else {
+ self.last_viewport_width.max(1) as f32 / self.last_viewport_height.max(1) as f32
+ }
+ }
+
+ /// Put the active camera square to the image's plane, `dist` out from
+ /// `center` along +Z — the side an image faces.
+ ///
+ /// The Default Camera has a fixed base ray and an orbit over it, so the
+ /// orbit is set to what cancels the ray's own yaw and pitch and the zoom
+ /// to what makes the distance. A camera node is rewritten — Position,
+ /// Pivot and Rotation — as Frame All rewrites one; its Rotation takes up
+ /// whatever orbit the viewport widget is holding, which `get_matrices`
+ /// applies to every camera. Returns the distance reached, which is short
+ /// of the one asked for where the zoom's clamp refuses it.
+ fn face_image(&mut self, center: Vec3, dist: f32) -> f32 {
+ let camera_name = self.active_camera.clone();
+ let (orbit_x, orbit_y) = (self.viewport().rotation_x, self.viewport().rotation_y);
+ let mut reached = None;
+ if camera_name != "Default Camera" {
+ let dir = self.current_dir_mut();
+ if let Some(node) =
+ dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name)
+ {
+ let fmt3 = |v: Vec3| format!("{:.3}:{:.3}:{:.3}", v.x, v.y, v.z);
+ set_param(node, "Pivot", fmt3(center));
+ set_param(node, "Position", fmt3(center + Vec3::Z * dist));
+ set_param(
+ node,
+ "Rotation",
+ format!("{:.3}:{:.3}:0.000", orbit_x.to_degrees(), orbit_y.to_degrees()),
+ );
+ reached = Some(dist);
+ }
+ }
+ let reached = match reached {
+ Some(d) => {
+ let vp = self.viewport_mut();
+ vp.zoom = 1.0;
+ vp.pending_yaw = 0.0;
+ vp.pending_pitch = 0.0;
+ vp.reset_velocity();
+ d
+ }
+ None => {
+ let base = Vec3::new(2.5, 1.8, 2.5);
+ let vp = self.viewport_mut();
+ vp.pivot = center;
+ vp.rotation_y = base.x.atan2(base.z);
+ vp.rotation_x = (base.y / base.length()).asin();
+ vp.zoom = (dist / base.length())
+ .clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
+ vp.reset_velocity();
+ vp.zoom * base.length()
+ }
+ };
+ self.viewport_dirty = true;
+ self.sync_nodes();
+ self.sync_parameters_pane();
+ reached
+ }
+
+ /// The image the viewport shows, or a status line saying there is none.
+ fn image_to_frame(&mut self) -> Option<PageShown> {
+ let shown = self.page_shown.clone();
+ if shown.is_none() {
+ self.update_status_text("No image is shown: turn on an image node's display flag.");
+ }
+ shown
+ }
+
+ /// `Frame Image`: turn the camera square to the image and fit it to the
+ /// pane.
+ pub fn frame_image(&mut self) -> bool {
+ let Some(shown) = self.image_to_frame() else { return false };
+ let size = shown.world_size(self.world_unit_mm());
+ let dist = fit_distance(size, self.viewport_aspect());
+ let reached = self.face_image(Vec3::from_array(shown.origin), dist);
+ if (reached - dist).abs() > dist * 1e-3 {
+ self.update_status_text("Framed the image as far as the zoom goes.");
+ } else {
+ self.update_status_text("Framed the image.");
+ }
+ true
+ }
+
+ /// `View Image Pixels 1:1`: turn the camera square to the image at the
+ /// distance where one of its pixels covers one of the display's.
+ pub fn view_image_pixels(&mut self) -> bool {
+ let Some(shown) = self.image_to_frame() else { return false };
+ let size = shown.world_size(self.world_unit_mm());
+ let dist = pixel_distance(size[1], shown.pixels.1, self.last_viewport_height);
+ let reached = self.face_image(Vec3::from_array(shown.origin), dist);
+ if (reached - dist).abs() > dist * 1e-3 {
+ self.update_status_text("Image pixels 1:1 is past what the zoom reaches.");
+ } else {
+ self.update_status_text("Image pixels 1:1.");
+ }
+ true
+ }
+
+ /// The scene's image in world space, for a bound that has to hold it.
+ pub(crate) fn image_world_corners(&self) -> Option<[[f32; 3]; 4]> {
+ let unit = self.world_unit_mm();
+ self.page_shown.as_ref().map(|s| s.world_corners(unit))
+ }
+
+ /// Add a node from a template to the current level, at the nearest free
+ /// cell to `at`, and return its slot. The display flag is left as the
+ /// template has it cleared, for the caller to decide.
+ fn add_template_node(&mut self, template: &str, at: (f32, f32)) -> Option<usize> {
+ let idx = self.node_templates.iter().position(|t| {
+ t.label.eq_ignore_ascii_case(template) || t.node.name.eq_ignore_ascii_case(template)
+ })?;
+ let mut node = self.node_templates[idx].node.clone();
+ crate::app::regenerate_node_ids(&mut node);
+ node.position = self.find_empty_cell(at.0, at.1, None);
+ node.name = self.get_lowest_unused_name(&node.name);
+ node.geometry_visible = false;
+ self.current_dir_mut().children.push(node);
+ Some(self.current_dir().children.len() - 1)
+ }
+
+ /// Show `slot`, select it, and bring everything that reads the tree up
+ /// to date — the tail every command here ends on.
+ fn show_and_select(&mut self, slot: usize) {
+ self.current_dir_mut().set_child_geometry_visible(slot, true);
+ if let Some(cell) = self.current_dir().children.get(slot).map(|n| n.position) {
+ self.grid_cursor_col = cell.0 as i32;
+ self.grid_cursor_row = cell.1 as i32;
+ }
+ self.deselected_cell = None;
+ self.sync_nodes();
+ self.graph_mut().set_selected_node(Some(slot));
+ self.rebuild_positions();
+ self.apply_layout();
+ self.update_panel_bounds();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ }
+
+ /// `New Image`: a page node at the grid cursor, shown and selected.
+ pub fn new_image(&mut self) -> Option<usize> {
+ let at = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
+ let slot = self.add_template_node("Page", at)?;
+ self.show_and_select(slot);
+ Some(slot)
+ }
+
+ /// The node an Add … to Image command draws on: the selected node when
+ /// it belongs to an image, else the image the level shows.
+ fn image_target(&self) -> Option<usize> {
+ let dir = self.current_dir();
+ let is_image = |slot: &usize| {
+ dir.children.get(*slot).is_some_and(|n| is_page_node(&n.node_type))
+ };
+ self.selected_slots().into_iter().find(is_image).or_else(|| {
+ dir.children
+ .iter()
+ .enumerate()
+ .filter(|(_, c)| is_page_node(&c.node_type) && c.geometry_visible)
+ .map(|(i, _)| i)
+ .next_back()
+ })
+ }
+
+ /// `Add Rectangle to Image` and its family: a shape or text node wired
+ /// after the image's node, sized and placed from the image itself, shown
+ /// and selected so its rows are what the params pane holds next.
+ ///
+ /// With no image at the level one is made first. A target in the MIDDLE
+ /// of a chain has the new node inserted after it: what read the target
+ /// reads the new node.
+ pub fn add_to_image(&mut self, layer: ImageLayer) -> Option<usize> {
+ let target = match self.image_target() {
+ Some(slot) => slot,
+ None => self.new_image()?,
+ };
+ let (target_name, target_pos) = {
+ let n = &self.current_dir().children[target];
+ (n.name.clone(), n.position)
+ };
+ let Some(page) = crate::page::resolve_page(
+ &self.fs_root,
+ &self.current_dir().children[target],
+ &mut Vec::new(),
+ ) else {
+ self.update_status_text(&format!("{target_name} draws on no image: wire it to one."));
+ return None;
+ };
+
+ let slot = self.add_template_node(layer.template(), (target_pos.0, target_pos.1 + 1.0))?;
+ let new_name = self.current_dir().children[slot].name.clone();
+ for (i, sibling) in self.current_dir_mut().children.iter_mut().enumerate() {
+ if i == slot || !is_page_node(&sibling.node_type) && sibling.node_type != "export" {
+ continue;
+ }
+ for p in sibling.params.iter_mut() {
+ if p.name == "Input" && p.text().trim() == target_name {
+ p.set_text(new_name.clone());
+ }
+ }
+ }
+
+ // In the page's unit, from the page's size: the middle of the image,
+ // a third of its width, and type a twentieth of its height tall.
+ let unit = page.unit;
+ let (w, h) = (page.in_unit(page.size[0]), page.in_unit(page.size[1]));
+ let num = |v: f32| row_number(v, unit);
+ let node = &mut self.current_dir_mut().children[slot];
+ set_param(node, "Input", target_name);
+ set_param(node, "X", num(w * 0.5));
+ set_param(node, "Y", num(h * 0.5));
+ match layer {
+ ImageLayer::Text => {
+ set_param(node, "Text", "Text".to_string());
+ set_param(node, "Size", num((h * 0.05).max(page.in_unit(1.0 / page.scale()))));
+ set_param(node, "Vertical", "Middle".to_string());
+ }
+ shape => {
+ let side = w.min(h) / 3.0;
+ set_param(node, "Shape", shape.label().to_string());
+ set_param(node, "Width", num(if shape == ImageLayer::Line { w / 3.0 } else { side }));
+ set_param(node, "Height", num(side));
+ // A hairline that survives the raster: a four-hundredth of
+ // the short side, and never under a pixel and a half.
+ let stroke = (page.size[0].min(page.size[1]) / 400.0).max(1.5 / page.scale());
+ let stroke = page.in_unit(stroke);
+ let stroke = if unit == crate::page::PageUnit::Pixels {
+ format!("{}", stroke.round().max(1.0))
+ } else {
+ format!("{stroke:.3}")
+ };
+ set_param(node, "Stroke Width", stroke);
+ }
+ }
+ self.show_and_select(slot);
+ self.update_status_text(&format!("Added {} to {}.", layer.label().to_lowercase(), new_name));
+ Some(slot)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index e750192..31fe4b4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -38,6 +38,7 @@ pub mod mold;
pub mod hull;
pub mod scatter;
pub mod page;
+pub mod image_tools;
pub mod expr;
pub mod thumbnail;
@@ -859,7 +860,7 @@ mod tests {
SPLITTER2_IDX, PARAM_IDX, CANVAS_IDX, LEFT_MENUBAR_IDX,
RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, STATUS_IDX, BREADCRUMB_IDX,
SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
- NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX, PAGE_IDX,
+ NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX,
DIALOG_IDX,
];
assert_eq!(roster.len(), WIDGET_COUNT, "roster length vs WIDGET_COUNT");
@@ -7194,10 +7195,8 @@ mod tests {
assert!(!state.seen_renderer, "a fresh State has not been given a renderer");
assert!(!state.page_dirty);
- // Stand in for a composed page: an id owned by State and borrowed by
- // the view.
+ // Stand in for a composed page: an id owned by State.
state.page_image = Some(7);
- state.slots.page_view.set_image(Some((7, 100, 100)));
// The FIRST renderer is this process's own — nothing to invalidate,
// and dropping the image here would throw away a page that is fine.
@@ -7210,7 +7209,6 @@ mod tests {
// blank forever.
assert!(state.renderer_handed_over(), "the second renderer must read as a replacement");
assert_eq!(state.page_image, None, "the dead id was kept");
- assert!(state.slots.page_view.image.is_none(), "the view still borrows a dead id");
assert!(state.page_dirty, "nothing would re-upload the page");
}
@@ -14448,4 +14446,444 @@ mod tests {
last = marks(&state);
}
}
+
+ // --- The 2D context in the viewport -----------------------------------
+
+ /// A page node for the image tests: text params, as a hand-built node
+ /// has them.
+ fn image_node(id: &str, ty: &str, params: &[(&str, &str)]) -> FsNode {
+ FsNode {
+ id: id.to_string(),
+ name: id.to_string(),
+ node_type: ty.to_string(),
+ children: vec![],
+ params: params
+ .iter()
+ .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
+ .collect(),
+ geometry_visible: true,
+ bypassed: false,
+ position: (0.0, 0.0),
+ inputs: 1,
+ outputs: 1,
+ }
+ }
+
+ fn image_root(children: Vec<FsNode>) -> FsNode {
+ let mut root = image_node("root", "node", &[]);
+ root.children = children;
+ root
+ }
+
+ /// The generator's size is in the unit its Units row names: pixels are
+ /// pixels exactly, a metric sheet is its millimetres, and a named raster
+ /// size is that many pixels whatever the row says.
+ #[test]
+ fn an_image_is_sized_in_pixels_or_in_real_units() {
+ use crate::page::{resolve_page, PageUnit};
+ let make = |params: &[(&str, &str)]| {
+ let root = image_root(vec![image_node("page1", "page", params)]);
+ resolve_page(&root, &root.children[0], &mut Vec::new()).expect("no page")
+ };
+
+ let px = make(&[("Preset", "Custom"), ("Units", "Pixels"), ("Width", "640"), ("Height", "360"), ("Resolution", "96")]);
+ assert_eq!((px.width, px.height), (640, 360), "a pixel size is that many pixels");
+ assert_eq!(px.unit, PageUnit::Pixels);
+ assert!((px.size[0] - 640.0 / 96.0).abs() < 1e-4, "its physical size is its pixels over its resolution");
+
+ let mm = make(&[("Preset", "Custom"), ("Units", "Millimetres"), ("Width", "210"), ("Height", "297"), ("Resolution", "100")]);
+ assert!((mm.size[0] - 210.0 / 25.4).abs() < 1e-4 && (mm.size[1] - 297.0 / 25.4).abs() < 1e-4);
+ assert_eq!((mm.width, mm.height), (827, 1169), "A4 in millimetres at 100 DPI");
+
+ let cm = make(&[("Preset", "Custom"), ("Units", "Centimetres"), ("Width", "2.54"), ("Height", "5.08"), ("Resolution", "50")]);
+ assert_eq!((cm.width, cm.height), (50, 100));
+
+ let hd = make(&[("Preset", "HD"), ("Units", "Inches"), ("Orientation", "Landscape"), ("Resolution", "72")]);
+ assert_eq!((hd.width, hd.height), (1920, 1080), "a raster preset is its pixels, and is not turned");
+
+ // A page from before the Units row is in inches, as it was.
+ let old = make(&[("Preset", "Custom"), ("Width", "2"), ("Height", "1"), ("Resolution", "50")]);
+ assert_eq!((old.width, old.height, old.unit), (100, 50, PageUnit::Inches));
+
+ // Opacity is the sheet's alpha, and Position where it stands.
+ let clear = make(&[("Preset", "Custom"), ("Width", "1"), ("Height", "1"), ("Resolution", "10"), ("Opacity", "0.25"), ("Position", "1.00:2.00:3.00")]);
+ assert!((clear.pixels[0][3] - 0.25).abs() < 1e-6);
+ assert_eq!(clear.origin, [1.0, 2.0, 3.0]);
+ }
+
+ /// A node drawing on an image is written in the image's unit: the same
+ /// rows on a pixel image and on an inch sheet put ink in different
+ /// places, and on each where the unit says.
+ #[test]
+ fn what_draws_on_an_image_is_in_the_images_unit() {
+ use crate::page::resolve_page;
+ let chain = |units: &str, w: &str, h: &str, dpi: &str| {
+ let root = image_root(vec![
+ image_node("page1", "page", &[("Preset", "Custom"), ("Units", units), ("Width", w), ("Height", h), ("Resolution", dpi), ("Color", "1.00:1.00:1.00")]),
+ image_node(
+ "shape1",
+ "page_shape",
+ &[
+ ("Input", "page1"), ("Shape", "Rectangle"), ("X", "100"), ("Y", "50"),
+ ("Width", "20"), ("Height", "10"), ("Fill", "true"),
+ ("Fill Color", "1.00:0.00:0.00"), ("Stroke", "false"),
+ ],
+ ),
+ ]);
+ resolve_page(&root, &root.children[1], &mut Vec::new()).expect("no page")
+ };
+ let red = |p: &crate::page::Page, x: u32, y: u32| {
+ let c = p.pixels[(y * p.width + x) as usize];
+ c[0] > 0.9 && c[1] < 0.1
+ };
+
+ let px = chain("Pixels", "200", "100", "96");
+ assert!(red(&px, 100, 50), "no ink at the rectangle's centre");
+ assert!(red(&px, 91, 46) && red(&px, 109, 54), "the rectangle is not 20 x 10 pixels");
+ assert!(!red(&px, 112, 50) && !red(&px, 100, 57), "ink outside the rectangle");
+
+ // The same rows in millimetres, on a sheet 200 mm wide at 127 DPI:
+ // five pixels to the millimetre, so the ink is five times as far in.
+ let mm = chain("Millimetres", "200", "100", "127");
+ assert_eq!((mm.width, mm.height), (1000, 500));
+ assert!(red(&mm, 500, 250) && red(&mm, 545, 270), "the rectangle is not 20 x 10 millimetres");
+ assert!(!red(&mm, 100, 50) && !red(&mm, 560, 250), "ink outside the rectangle");
+
+ // And in inches the rectangle is off a 4 x 2 inch sheet altogether.
+ let inch = chain("Inches", "4", "2", "50");
+ assert!(inch.pixels.iter().all(|c| c[1] > 0.9), "a rectangle 100 inches out drew on the sheet");
+ }
+
+ /// The four outlines, turned and stroked: ink inside the outline and
+ /// none outside it.
+ #[test]
+ fn image_shapes_put_ink_inside_their_outline() {
+ use crate::page::{Page, ShapeKind, ShapeSpec};
+ let sheet = || Page::new([2.0, 2.0], 100, [1.0, 1.0, 1.0, 1.0]);
+ let ink = |p: &Page, x: u32, y: u32| p.pixels[(y * p.width + x) as usize][1] < 0.5;
+ let spec = |kind, size: [f32; 2], rotation| ShapeSpec {
+ kind,
+ center: [1.0, 1.0],
+ size,
+ rotation,
+ corner_radius: 0.0,
+ sides: 3,
+ fill: Some([1.0, 0.0, 0.0, 1.0]),
+ stroke: None,
+ };
+
+ // An ellipse fills its middle and not the corners of its box.
+ let mut p = sheet();
+ p.shape(&spec(ShapeKind::Ellipse, [1.0, 0.5], 0.0));
+ assert!(ink(&p, 100, 100) && ink(&p, 145, 100) && ink(&p, 100, 120));
+ assert!(!ink(&p, 148, 122), "the ellipse filled its box's corner");
+ assert!(!ink(&p, 100, 128), "the ellipse is taller than it was asked to be");
+
+ // Turned a quarter, a wide rectangle is a tall one.
+ let mut p = sheet();
+ p.shape(&spec(ShapeKind::Rectangle, [1.0, 0.2], 90.0));
+ assert!(ink(&p, 100, 145) && ink(&p, 100, 55), "the turned rectangle is not tall");
+ assert!(!ink(&p, 145, 100), "the turned rectangle is still wide");
+
+ // A rounded corner leaves the box's corner clear.
+ let mut p = sheet();
+ p.shape(&ShapeSpec { corner_radius: 0.25, ..spec(ShapeKind::Rectangle, [1.0, 1.0], 0.0) });
+ assert!(ink(&p, 100, 100) && ink(&p, 52, 100) && !ink(&p, 52, 52), "the corner was not rounded");
+
+ // A triangle, its first corner at the top: ink under the apex, none
+ // beside it.
+ let mut p = sheet();
+ p.shape(&spec(ShapeKind::Polygon, [1.0, 1.0], 0.0));
+ assert!(ink(&p, 100, 60) && ink(&p, 100, 110));
+ assert!(!ink(&p, 60, 60) && !ink(&p, 140, 60), "ink beside the triangle's apex");
+
+ // A stroke alone draws the outline and leaves the middle.
+ let mut p = sheet();
+ p.shape(&ShapeSpec {
+ fill: None,
+ stroke: Some(([0.0, 0.0, 0.0, 1.0], 0.04)),
+ ..spec(ShapeKind::Rectangle, [1.0, 1.0], 0.0)
+ });
+ assert!(ink(&p, 50, 100) && ink(&p, 100, 150), "no ink on the outline");
+ assert!(!ink(&p, 100, 100) && !ink(&p, 40, 100), "ink off the outline");
+
+ // A line is as long as its Width and as thick as its stroke.
+ let mut p = sheet();
+ p.shape(&ShapeSpec {
+ stroke: Some(([0.0, 0.0, 0.0, 1.0], 0.06)),
+ ..spec(ShapeKind::Line, [1.0, 0.0], 0.0)
+ });
+ assert!(ink(&p, 55, 100) && ink(&p, 145, 100) && ink(&p, 100, 102));
+ assert!(!ink(&p, 100, 105) && !ink(&p, 155, 100), "the line is thicker or longer than asked");
+
+ // An edge that is not on the pixel grid is covered in part, which is
+ // what keeps a turned edge from being a staircase.
+ let mut p = sheet();
+ p.shape(&spec(ShapeKind::Rectangle, [1.005, 1.0], 0.0));
+ let edge = p.pixels[(100 * p.width + 49) as usize][1];
+ assert!(edge > 0.05 && edge < 0.95, "the edge pixel is all or nothing: {edge}");
+ }
+
+ /// The page nodes and the geometry nodes each have a display flag of
+ /// their own: showing an image leaves the geometry shown, and the other
+ /// way about.
+ #[test]
+ fn the_display_flag_is_exclusive_within_its_context() {
+ let mut dir = image_root(vec![
+ image_node("sphere1", "sphere", &[]),
+ image_node("page1", "page", &[]),
+ image_node("box1", "box", &[]),
+ image_node("text1", "page_text", &[]),
+ ]);
+ for c in &mut dir.children {
+ c.geometry_visible = false;
+ }
+ let flags = |d: &FsNode| d.children.iter().map(|c| c.geometry_visible).collect::<Vec<_>>();
+
+ dir.set_child_geometry_visible(0, true);
+ dir.set_child_geometry_visible(1, true);
+ assert_eq!(flags(&dir), [true, true, false, false], "showing the image hid the geometry");
+ dir.set_child_geometry_visible(3, true);
+ assert_eq!(flags(&dir), [true, false, false, true], "two images are shown");
+ dir.set_child_geometry_visible(2, true);
+ assert_eq!(flags(&dir), [false, false, true, true], "showing geometry hid the image");
+ }
+
+ /// A State showing one image of `w` x `h` pixels at `dpi`, in a pane of
+ /// 1200 x 800.
+ fn state_showing_image(w: u32, h: u32, dpi: u32) -> State {
+ let mut state = State::new(false);
+ state.last_viewport_width = 1200;
+ state.last_viewport_height = 800;
+ let slot = state.new_image().expect("the Page template is missing");
+ let node = &mut state.current_dir_mut().children[slot];
+ for (name, value) in [
+ ("Preset", "Custom".to_string()),
+ ("Units", "Pixels".to_string()),
+ ("Width", w.to_string()),
+ ("Height", h.to_string()),
+ ("Resolution", dpi.to_string()),
+ ] {
+ node.params.iter_mut().find(|p| p.name == name).expect("a page row is missing").set_text(value);
+ }
+ state.rebuild_scene_geometry();
+ state
+ }
+
+ /// The image's corners through the camera as the stage pass builds it:
+ /// NDC x and y of top-left, top-right, bottom-right, bottom-left.
+ fn image_corners_in_view(state: &State) -> [[f32; 2]; 4] {
+ let (pos, rot, pivot) = state.active_camera_pose();
+ let aspect = state.last_viewport_width as f32 / state.last_viewport_height as f32;
+ let (proj, view, model) = state.viewport().get_matrices(aspect, Some(pos), Some(rot), Some(pivot));
+ let mvp = proj * view * model;
+ state.image_world_corners().expect("no image is shown").map(|c| {
+ let p = mvp.project_point3(Vec3::from_array(c));
+ [p.x, p.y]
+ })
+ }
+
+ /// The level's image reaches the viewport: composed, uploaded, and
+ /// placed at its physical size in world units.
+ #[test]
+ fn a_shown_image_stands_in_the_scene_at_its_size() {
+ let mut state = state_showing_image(400, 200, 100);
+ let shown = state.page_shown.clone().expect("the image did not reach the viewport");
+ assert!(state.page_image.is_some(), "nothing was uploaded");
+ assert_eq!(shown.pixels, (400, 200));
+ assert!(state.viewport_dirty, "nothing asks the scene to stage the image");
+
+ // 4 x 2 inches, in a world of millimetres and in one of inches.
+ let mm = shown.world_size(1.0);
+ assert!((mm[0] - 101.6).abs() < 1e-3 && (mm[1] - 50.8).abs() < 1e-3, "{mm:?}");
+ let inch = shown.world_size(25.4);
+ assert!((inch[0] - 4.0).abs() < 1e-4 && (inch[1] - 2.0).abs() < 1e-4, "{inch:?}");
+ // The top edge is up, the image faces +Z about its origin.
+ let c = shown.world_corners(25.4);
+ assert_eq!(c[0], [-2.0, 1.0, 0.0]);
+ assert_eq!(c[2], [2.0, -1.0, 0.0]);
+
+ // Hidden, it leaves the scene and frees its image.
+ let slot = state.current_dir().children.iter().position(|n| n.id == shown.node_id).unwrap();
+ state.current_dir_mut().set_child_geometry_visible(slot, false);
+ state.viewport_dirty = false;
+ state.rebuild_scene_geometry();
+ assert!(state.page_shown.is_none() && state.page_image.is_none());
+ assert!(state.viewport_dirty, "the scene keeps drawing an image that is gone");
+ }
+
+ /// Frame Image turns the camera square to the image and fits it: the
+ /// corners land symmetric about the view's centre, inside the pane, and
+ /// the axis that binds is nearly full. With the Default Camera, from any
+ /// orbit, and with a camera node.
+ #[test]
+ fn frame_image_faces_the_image_and_fits_it() {
+ let framed = |state: &State, what: &str| {
+ let [tl, tr, br, bl] = image_corners_in_view(state);
+ for (a, b) in [(tl[0], -tr[0]), (tl[1], tr[1]), (bl[0], -br[0]), (tl[1], -bl[1]), (tl[0], bl[0])] {
+ assert!((a - b).abs() < 1e-3, "{what}: the image is not seen head-on: {tl:?} {tr:?} {br:?} {bl:?}");
+ }
+ assert!(tl[0] < 0.0 && tl[1] > 0.0, "{what}: the image is seen from behind or upside down");
+ let reach = tr[0].max(tr[1]);
+ assert!(reach <= 1.0 && reach > 0.85, "{what}: the image spans {reach} of the pane");
+ };
+
+ // Wide, in a pane less wide than it: the width binds.
+ let mut state = state_showing_image(400, 100, 100);
+ state.viewport_mut().rotation_x = 0.3;
+ state.viewport_mut().rotation_y = -1.1;
+ state.viewport_mut().zoom = 3.0;
+ assert!(state.run_command("frame_image"), "frame_image is not a command");
+ framed(&state, "default camera, wide image");
+ let [_, tr, _, _] = image_corners_in_view(&state);
+ assert!(tr[0] > tr[1], "a wide image is bound by its width");
+
+ // Tall: the height binds.
+ let mut state = state_showing_image(100, 400, 100);
+ state.frame_image();
+ framed(&state, "default camera, tall image");
+
+ // Off the origin, the camera goes to it.
+ let mut state = state_showing_image(200, 200, 100);
+ let slot = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+ state.current_dir_mut().children[slot]
+ .params
+ .iter_mut()
+ .find(|p| p.name == "Position")
+ .unwrap()
+ .set_text("3.00:-2.00:1.00");
+ state.rebuild_scene_geometry();
+ state.frame_image();
+ framed(&state, "default camera, image off the origin");
+
+ // A camera node is rewritten, whatever orbit the widget holds.
+ let mut state = state_showing_image(300, 200, 100);
+ assert!(state.current_dir().children.iter().any(|c| c.name == "camera1"), "the bundled project has no camera1");
+ state.set_active_camera("camera1");
+ state.viewport_mut().rotation_x = 0.2;
+ state.viewport_mut().rotation_y = 0.7;
+ state.frame_image();
+ framed(&state, "camera node");
+
+ // With no image shown the command says so and moves nothing.
+ let mut state = State::new(false);
+ let zoom = state.viewport().zoom;
+ assert!(!state.frame_image());
+ assert_eq!(state.viewport().zoom, zoom);
+ }
+
+ /// View Image Pixels 1:1 puts one pixel of the image on one of the pane.
+ #[test]
+ fn view_image_pixels_is_one_pixel_to_one() {
+ let mut state = state_showing_image(300, 200, 100);
+ assert!(state.run_command("view_image_pixels"));
+ let [tl, tr, _, bl] = image_corners_in_view(&state);
+ let wide = (tr[0] - tl[0]) * 0.5 * state.last_viewport_width as f32;
+ let tall = (tl[1] - bl[1]) * 0.5 * state.last_viewport_height as f32;
+ assert!((wide - 300.0).abs() < 0.5 && (tall - 200.0).abs() < 0.5, "the image covers {wide} x {tall} px");
+ }
+
+ /// Frame All holds the image as it holds the geometry: a scene that is
+ /// an image alone is still framed.
+ #[test]
+ fn frame_all_holds_the_image() {
+ let mut state = state_showing_image(400, 400, 100);
+ state.rt_sphere_verts.clear();
+ let slot = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+ state.current_dir_mut().children[slot]
+ .params
+ .iter_mut()
+ .find(|p| p.name == "Position")
+ .unwrap()
+ .set_text("5.00:0.00:0.00");
+ state.rebuild_scene_geometry();
+ state.rt_sphere_verts.clear();
+ state.frame_all();
+ let (_, _, pivot) = state.active_camera_pose();
+ assert!((pivot.x - 5.0).abs() < 1e-2, "Frame All did not go to the image: pivot {pivot:?}");
+ for c in image_corners_in_view(&state) {
+ assert!(c[0].abs() < 1.0 && c[1].abs() < 1.0, "a corner is out of the pane: {c:?}");
+ }
+ }
+
+ /// The viewport menu offers the image's camera rows while one shows,
+ /// and not otherwise.
+ #[test]
+ fn the_viewport_menu_frames_an_image_that_is_shown() {
+ let state = State::new(false);
+ let (rows, _) = state.viewport_menu_rows();
+ assert!(!rows.iter().any(|r| r == "Frame Image"), "a row for an image that is not there");
+ let state = state_showing_image(100, 100, 100);
+ let (rows, actions) = state.viewport_menu_rows();
+ let at = rows.iter().position(|r| r == "Frame Image").expect("no Frame Image row");
+ assert_eq!(actions[at], crate::app::ViewportMenuAction::Command("frame_image"));
+ assert!(rows.iter().any(|r| r == "View Image Pixels 1:1"));
+ }
+
+ /// The Add … to Image commands: from nothing they make the image too;
+ /// the new node is wired after what it draws on, shown and selected,
+ /// placed and sized from the image in the image's unit; and added to the
+ /// middle of a chain it is inserted there.
+ #[test]
+ fn adding_to_an_image_wires_a_node_after_it() {
+ let mut state = State::new(false);
+ // No cell of the bundled project's is in the way down here.
+ state.grid_cursor_col = 40;
+ state.grid_cursor_row = 40;
+ assert!(state.run_command("add_image_ellipse"), "add_image_ellipse is not a command");
+
+ let names = |s: &State, ty: &str| {
+ s.current_dir().children.iter().filter(|c| c.node_type == ty).map(|c| c.name.clone()).collect::<Vec<_>>()
+ };
+ assert_eq!(names(&state, "page"), ["page1"], "no image was made for the shape");
+ assert_eq!(names(&state, "page_shape"), ["page_shape1"]);
+ let text_of = |s: &State, node: &str, row: &str| {
+ let n = s.current_dir().children.iter().find(|c| c.name == node).unwrap();
+ n.params.iter().find(|p| p.name == row).unwrap_or_else(|| panic!("{node} has no {row}")).text().to_string()
+ };
+ assert_eq!(text_of(&state, "page_shape1", "Input"), "page1");
+ assert_eq!(text_of(&state, "page_shape1", "Shape"), "Ellipse");
+ // Letter, in inches: the middle of the sheet.
+ assert_eq!(text_of(&state, "page_shape1", "X"), "4.25");
+ assert_eq!(text_of(&state, "page_shape1", "Y"), "5.50");
+
+ let flag = |s: &State, node: &str| s.current_dir().children.iter().find(|c| c.name == node).unwrap().geometry_visible;
+ assert!(flag(&state, "page_shape1") && !flag(&state, "page1"), "the new node is not what shows");
+ let shape_slot = state.current_dir().children.iter().position(|c| c.name == "page_shape1").unwrap();
+ assert_eq!(state.selected_slots(), [shape_slot], "the new node is not selected");
+ let shown = state.page_shown.clone().expect("nothing is shown");
+ assert_eq!(shown.node_id, state.current_dir().children[shape_slot].id);
+
+ // Make the sheet small and in pixels, then add text to the PAGE: it
+ // goes between the page and the shape.
+ let page_slot = state.current_dir().children.iter().position(|c| c.name == "page1").unwrap();
+ {
+ let node = &mut state.current_dir_mut().children[page_slot];
+ for (row, v) in [("Preset", "Custom"), ("Units", "Pixels"), ("Width", "300"), ("Height", "200"), ("Resolution", "96")] {
+ node.params.iter_mut().find(|p| p.name == row).unwrap().set_text(v);
+ }
+ }
+ state.graph_mut().set_selected_node(Some(page_slot));
+ assert!(state.run_command("add_image_text"));
+ assert_eq!(text_of(&state, "page_text1", "Input"), "page1");
+ assert_eq!(text_of(&state, "page_shape1", "Input"), "page_text1", "the text was not inserted into the chain");
+ assert_eq!(text_of(&state, "page_text1", "X"), "150");
+ assert_eq!(text_of(&state, "page_text1", "Y"), "100");
+ assert_eq!(text_of(&state, "page_text1", "Size"), "10");
+
+ // The whole chain still composes, from its end.
+ let end = state.current_dir().children.iter().find(|c| c.name == "page_shape1").unwrap();
+ let page = crate::page::resolve_page(&state.fs_root, end, &mut Vec::new()).expect("the chain is broken");
+ assert_eq!((page.width, page.height), (300, 200));
+
+ // A rectangle, a line and a polygon are the same node under another
+ // Shape.
+ for (cmd, shape) in [("add_image_rectangle", "Rectangle"), ("add_image_line", "Line"), ("add_image_polygon", "Polygon")] {
+ let before = names(&state, "page_shape").len();
+ assert!(state.run_command(cmd), "{cmd} is not a command");
+ let all = names(&state, "page_shape");
+ assert_eq!(all.len(), before + 1, "{cmd} added nothing");
+ assert_eq!(text_of(&state, all.last().unwrap(), "Shape"), shape);
+ }
+ }
}
diff --git a/src/page.rs b/src/page.rs
index 2cf7329..edba2bf 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -1,21 +1,21 @@
-//! The 2D page context — a printed sheet, composited from layers.
+//! The 2D page context — an image, composited from layers.
//!
//! This is a SECOND context, deliberately not the geometry graph. Its currency
-//! is a [`Page`] rather than a `Detail`, its coordinates are inches rather than
-//! world units, its origin is the top-left corner with y running DOWN, and
-//! nothing in it has a point id, an attribute or a normal. The geometry graph
-//! describes a thing you will make; a page describes a thing you will print.
-//! Smuggling one into the other means a `Detail` that is secretly a raster and
-//! a viewport that has to guess which it is holding, so they stay apart: page
-//! nodes resolve through [`resolve_page`], never through
-//! `generate_single_node_geometry_with_errors`, and contribute no geometry to
-//! the viewport at all.
+//! is a [`Page`] rather than a `Detail`, its origin is the top-left corner
+//! with y running DOWN, and nothing in it has a point id, an attribute or a
+//! normal. Smuggling one into the other means a `Detail` that is secretly a
+//! raster, so they stay apart: page nodes resolve through [`resolve_page`],
+//! never through `generate_single_node_geometry_with_errors`, and contribute
+//! no geometry. What the viewport shows of a page is the page itself, as a
+//! textured quad standing in the scene ([`PageShown::world_corners`]).
//!
-//! Inches, not millimetres, because the page's own reason for existing is
-//! paper, and paper is specified in inches by the sources this came from (8.5 ×
-//! 11 is the default everywhere in the family). The World Unit declaration that
-//! governs the geometry graph does not reach here — a sheet is a sheet at any
-//! model scale.
+//! A page is stored in inches, because its first reason for existing was
+//! paper. What its nodes are WRITTEN in is the page's [`PageUnit`] — inches,
+//! millimetres, centimetres or pixels — chosen on the `page` node and carried
+//! by the page to every node downstream, so a shape on a 1920 × 1080 image is
+//! placed in pixels and the same node on a Letter sheet in inches. In the
+//! scene a page is its physical size: the World Unit says what one world unit
+//! is, and a sheet 215.9 mm wide is 215.9 of them when that is a millimetre.
//!
//! **Resolution is a property of the page, not of the export.** A page carries
//! its DPI, the raster is that many pixels per inch, and the PNG says so in its
@@ -61,6 +61,65 @@ pub struct Page {
pub width: u32,
pub height: u32,
pub pixels: Vec<[f32; 4]>,
+ /// What the lengths on this page's nodes are written in.
+ pub unit: PageUnit,
+ /// Where the page's CENTRE stands in the scene, in world units.
+ pub origin: [f32; 3],
+}
+
+/// What a page's lengths are written in.
+///
+/// A property of the PAGE, set on the node that makes it, and not of each
+/// node drawing on it: a chain whose text was placed in pixels and whose
+/// border was inset in inches is a chain nobody can read.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum PageUnit {
+ Inches,
+ Millimetres,
+ Centimetres,
+ Pixels,
+}
+
+impl PageUnit {
+ /// The unit a `Units` row names. Anything else is inches, which is what
+ /// a page saved before the row existed was written in.
+ pub fn parse(name: &str) -> PageUnit {
+ match name.trim().to_ascii_lowercase().as_str() {
+ "millimetres" | "millimeters" | "mm" => PageUnit::Millimetres,
+ "centimetres" | "centimeters" | "cm" => PageUnit::Centimetres,
+ "pixels" | "px" => PageUnit::Pixels,
+ _ => PageUnit::Inches,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ PageUnit::Inches => "in",
+ PageUnit::Millimetres => "mm",
+ PageUnit::Centimetres => "cm",
+ PageUnit::Pixels => "px",
+ }
+ }
+
+ /// A length in this unit as inches, on a raster of `px_per_inch`.
+ pub fn to_inches(self, value: f32, px_per_inch: f32) -> f32 {
+ match self {
+ PageUnit::Inches => value,
+ PageUnit::Millimetres => value / 25.4,
+ PageUnit::Centimetres => value / 2.54,
+ PageUnit::Pixels => value / px_per_inch.max(1e-6),
+ }
+ }
+
+ /// The other way: inches as a length in this unit.
+ pub fn from_inches(self, inches: f32, px_per_inch: f32) -> f32 {
+ match self {
+ PageUnit::Inches => inches,
+ PageUnit::Millimetres => inches * 25.4,
+ PageUnit::Centimetres => inches * 2.54,
+ PageUnit::Pixels => inches * px_per_inch,
+ }
+ }
}
/// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
@@ -87,7 +146,25 @@ impl Page {
width = ((width as f64 * scale) as u32).max(1);
height = ((height as f64 * scale) as u32).max(1);
}
- Page { size, dpi, width, height, pixels: vec![color; (width * height) as usize] }
+ Page {
+ size,
+ dpi,
+ width,
+ height,
+ pixels: vec![color; (width * height) as usize],
+ unit: PageUnit::Inches,
+ origin: [0.0; 3],
+ }
+ }
+
+ /// A length written in the page's unit, in inches.
+ pub fn len(&self, value: f32) -> f32 {
+ self.unit.to_inches(value, self.scale())
+ }
+
+ /// Inches as a length in the page's unit — what a node's row would say.
+ pub fn in_unit(&self, inches: f32) -> f32 {
+ self.unit.from_inches(inches, self.scale())
}
/// Pixels per inch as a float, measured from the raster rather than read
@@ -240,6 +317,198 @@ impl Page {
}
}
+/// The page the viewport is showing, without its pixels: enough to place
+/// the image in the scene and to fit a camera to it.
+#[derive(Clone, PartialEq, Debug)]
+pub struct PageShown {
+ /// The displayed page node's id.
+ pub node_id: String,
+ /// Physical size in inches.
+ pub size: [f32; 2],
+ /// The raster's size in pixels.
+ pub pixels: (u32, u32),
+ /// The page's centre in the scene, world units.
+ pub origin: [f32; 3],
+}
+
+impl PageShown {
+ /// The image's size in the scene, in world units of `world_unit_mm`
+ /// millimetres each. The World Unit is a declaration, and a page is the
+ /// one thing in the scene that knows its own physical size, so this is
+ /// the one place a length is converted INTO world units.
+ pub fn world_size(&self, world_unit_mm: f32) -> [f32; 2] {
+ let k = 25.4 / world_unit_mm.max(1e-6);
+ [self.size[0] * k, self.size[1] * k]
+ }
+
+ /// The image's corners in the scene — top-left, top-right, bottom-right,
+ /// bottom-left — standing in the XY plane about its origin and facing
+ /// +Z. The image's y runs down and the world's up, so the top edge is +Y.
+ pub fn world_corners(&self, world_unit_mm: f32) -> [[f32; 3]; 4] {
+ let [w, h] = self.world_size(world_unit_mm);
+ let [x, y, z] = self.origin;
+ let (hw, hh) = (w * 0.5, h * 0.5);
+ [
+ [x - hw, y + hh, z],
+ [x + hw, y + hh, z],
+ [x + hw, y - hh, z],
+ [x - hw, y - hh, z],
+ ]
+ }
+}
+
+/// Which outline a shape is.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum ShapeKind {
+ Rectangle,
+ Ellipse,
+ /// A straight stroke `size[0]` long, drawn in the stroke colour at the
+ /// stroke width, through the centre.
+ Line,
+ /// A polygon of `sides` corners on the ellipse the box holds, the first
+ /// at the top.
+ Polygon,
+}
+
+impl ShapeKind {
+ pub fn parse(name: &str) -> ShapeKind {
+ match name.trim().to_ascii_lowercase().as_str() {
+ "ellipse" => ShapeKind::Ellipse,
+ "line" => ShapeKind::Line,
+ "polygon" => ShapeKind::Polygon,
+ _ => ShapeKind::Rectangle,
+ }
+ }
+}
+
+/// One shape, in INCHES from the page's top-left corner, as every drawing
+/// operation here is; the node converts from the page's unit.
+pub struct ShapeSpec {
+ pub kind: ShapeKind,
+ pub center: [f32; 2],
+ /// The box the shape fills, before it is turned.
+ pub size: [f32; 2],
+ /// Degrees, clockwise as the page is looked at.
+ pub rotation: f32,
+ /// Rectangle only.
+ pub corner_radius: f32,
+ /// Polygon only.
+ pub sides: u32,
+ /// None draws no fill.
+ pub fill: Option<[f32; 4]>,
+ /// Colour and width; the stroke is centred on the outline, half of it
+ /// inside the shape and half out, as a drawing program's is.
+ pub stroke: Option<([f32; 4], f32)>,
+}
+
+/// Signed distance from `p` to a closed polygon, negative inside.
+fn polygon_distance(p: [f32; 2], corners: &[[f32; 2]]) -> f32 {
+ let n = corners.len();
+ let mut d = f32::MAX;
+ let mut inside = false;
+ for i in 0..n {
+ let (a, b) = (corners[i], corners[(i + 1) % n]);
+ let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
+ let (wx, wy) = (p[0] - a[0], p[1] - a[1]);
+ let t = ((wx * ex + wy * ey) / (ex * ex + ey * ey).max(1e-12)).clamp(0.0, 1.0);
+ let (dx, dy) = (wx - ex * t, wy - ey * t);
+ d = d.min(dx * dx + dy * dy);
+ // Even-odd crossing of the ray to the right of `p`.
+ if (a[1] > p[1]) != (b[1] > p[1]) {
+ let x = a[0] + (p[1] - a[1]) / (b[1] - a[1]) * ex;
+ if x > p[0] {
+ inside = !inside;
+ }
+ }
+ }
+ if inside { -d.sqrt() } else { d.sqrt() }
+}
+
+impl Page {
+ /// Draw one shape onto the page.
+ ///
+ /// Coverage comes from the signed DISTANCE to the outline, in pixels: a
+ /// pixel whose centre is half a pixel inside is covered, half a pixel
+ /// outside is not, and between them it is covered in proportion. That is
+ /// what makes a turned rectangle's edge, and a circle's, a clean line
+ /// where a test of the pixel centre leaves a staircase.
+ pub fn shape(&mut self, spec: &ShapeSpec) {
+ let s = self.scale();
+ let (cx, cy) = (spec.center[0] * s, spec.center[1] * s);
+ let line = spec.kind == ShapeKind::Line;
+ // A line is its stroke: a box as long as the line and as thick as
+ // the stroke is wide, filled with the stroke's colour.
+ let (fill, stroke) = if line {
+ (spec.stroke.map(|(c, _)| c), None)
+ } else {
+ (spec.fill, spec.stroke.filter(|(_, w)| *w > 0.0))
+ };
+ let thickness = spec.stroke.map(|(_, w)| w).unwrap_or(0.0) * s;
+ let hw = (spec.size[0].abs() * s * 0.5).max(0.0);
+ let hh = if line { (thickness * 0.5).max(0.5) } else { spec.size[1].abs() * s * 0.5 };
+ if fill.is_none() && stroke.is_none() {
+ return;
+ }
+ let stroke_half = stroke.map(|(_, w)| w * s * 0.5).unwrap_or(0.0);
+
+ let corners: Vec<[f32; 2]> = if spec.kind == ShapeKind::Polygon {
+ let n = spec.sides.clamp(3, 64);
+ (0..n)
+ .map(|i| {
+ let a = std::f32::consts::TAU * i as f32 / n as f32;
+ [hw * a.sin(), -hh * a.cos()]
+ })
+ .collect()
+ } else {
+ Vec::new()
+ };
+ let radius = spec.corner_radius.max(0.0).min(spec.size[0].abs().min(spec.size[1].abs()) * 0.5) * s;
+ let distance = |p: [f32; 2]| -> f32 {
+ match spec.kind {
+ ShapeKind::Rectangle | ShapeKind::Line => {
+ let r = if line { 0.0 } else { radius };
+ let (qx, qy) = (p[0].abs() - (hw - r), p[1].abs() - (hh - r));
+ (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() + qx.max(qy).min(0.0) - r
+ }
+ ShapeKind::Ellipse => {
+ let (a, b) = (hw.max(1e-3), hh.max(1e-3));
+ let k0 = ((p[0] / a).powi(2) + (p[1] / b).powi(2)).sqrt();
+ let k1 = ((p[0] / (a * a)).powi(2) + (p[1] / (b * b)).powi(2)).sqrt();
+ if k1 < 1e-9 { -a.min(b) } else { k0 * (k0 - 1.0) / k1 }
+ }
+ ShapeKind::Polygon => polygon_distance(p, &corners),
+ }
+ };
+
+ let (sin, cos) = spec.rotation.to_radians().sin_cos();
+ let reach = (hw * hw + hh * hh).sqrt() + stroke_half + 1.0;
+ let i0 = (cx - reach).floor().max(0.0) as i64;
+ let j0 = (cy - reach).floor().max(0.0) as i64;
+ let i1 = ((cx + reach).ceil() as i64).min(self.width as i64);
+ let j1 = ((cy + reach).ceil() as i64).min(self.height as i64);
+ for j in j0..j1 {
+ for i in i0..i1 {
+ let (dx, dy) = (i as f32 + 0.5 - cx, j as f32 + 0.5 - cy);
+ // Into the shape's own frame: the page turned back.
+ let p = [dx * cos + dy * sin, -dx * sin + dy * cos];
+ let d = distance(p);
+ if let Some(color) = fill {
+ let c = (0.5 - d).clamp(0.0, 1.0);
+ if c > 0.0 {
+ self.blend(i as u32, j as u32, color, c);
+ }
+ }
+ if let Some((color, _)) = stroke {
+ let c = (0.5 - (d.abs() - stroke_half)).clamp(0.0, 1.0);
+ if c > 0.0 {
+ self.blend(i as u32, j as u32, color, c);
+ }
+ }
+ }
+ }
+ }
+}
+
/// Where a run of text sits in the box it is given.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum HAlign {
@@ -393,7 +662,7 @@ use glam::Vec3;
pub fn is_page_node(node_type: &str) -> bool {
matches!(
node_type.to_ascii_lowercase().as_str(),
- "page" | "page_grid" | "page_border" | "page_text"
+ "page" | "page_grid" | "page_border" | "page_text" | "page_shape"
)
}
@@ -412,6 +681,23 @@ fn preset_size(name: &str) -> Option<[f32; 2]> {
}
}
+/// Named raster sizes, in PIXELS, landscape as screens are. What such an
+/// image measures in inches is these over the page's Resolution.
+fn preset_pixels(name: &str) -> Option<[f32; 2]> {
+ match name.trim().to_ascii_lowercase().as_str() {
+ "hd" => Some([1920.0, 1080.0]),
+ "4k" => Some([3840.0, 2160.0]),
+ "square" => Some([1024.0, 1024.0]),
+ _ => None,
+ }
+}
+
+/// A colour row and an opacity row as one straight-alpha colour.
+fn color_with(node: &FsNode, name: &str, fallback: Vec3, opacity: &str) -> [f32; 4] {
+ let c = node_param_vec3(node, name, fallback);
+ [c.x, c.y, c.z, node_param_f32(node, opacity, 1.0).clamp(0.0, 1.0)]
+}
+
fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
let c = node_param_vec3(node, name, fallback);
[c.x, c.y, c.z, 1.0]
@@ -441,20 +727,28 @@ pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -
let kind = target.node_type.to_ascii_lowercase();
if kind == "page" {
let preset = node_param_str(target, "Preset", "Letter");
- let size = preset_size(&preset).unwrap_or([
- node_param_f32(target, "Width", 8.5),
- node_param_f32(target, "Height", 11.0),
- ]);
+ let unit = PageUnit::parse(&node_param_str(target, "Units", "Inches"));
+ let dpi = node_param_f32(target, "Resolution", 300.0).round().clamp(1.0, 2400.0);
+ // A named size is what it is whatever the Units row says; the row
+ // is what Width and Height — and every node downstream — are in.
+ let size = preset_size(&preset)
+ .or_else(|| preset_pixels(&preset).map(|[w, h]| [w / dpi, h / dpi]))
+ .unwrap_or_else(|| {
+ [
+ unit.to_inches(node_param_f32(target, "Width", 8.5), dpi),
+ unit.to_inches(node_param_f32(target, "Height", 11.0), dpi),
+ ]
+ });
// Landscape is the same sheet turned, not a different sheet: swap the
- // axes rather than asking for a second pair of numbers.
- let size = if node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape")
- {
- [size[1], size[0]]
- } else {
- size
- };
- let dpi = node_param_f32(target, "Resolution", 300.0).round().max(1.0) as u32;
- return Some(Page::new(size, dpi, color_of(target, "Color", Vec3::ONE)));
+ // axes rather than asking for a second pair of numbers. A raster
+ // preset is named as it lies, and Custom says its own two numbers.
+ let turned = preset_size(&preset).is_some()
+ && node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
+ let size = if turned { [size[1], size[0]] } else { size };
+ let mut page = Page::new(size, dpi as u32, color_with(target, "Color", Vec3::ONE, "Opacity"));
+ page.unit = unit;
+ page.origin = node_param_vec3(target, "Position", Vec3::ZERO).to_array();
+ return Some(page);
}
// Everything else composites onto its input, so a chain with no page at
@@ -479,26 +773,54 @@ pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -
[0.0; 4]
};
page.grid(
- node_param_f32(target, "Cell Size", 0.25),
- node_param_f32(target, "Line Width", 0.01),
+ page.len(node_param_f32(target, "Cell Size", 0.25)),
+ page.len(node_param_f32(target, "Line Width", 0.01)),
cell_color,
color_of(target, "Line Color", Vec3::ZERO),
);
}
"page_border" => page.border(
- node_param_f32(target, "Width", 0.06),
- node_param_f32(target, "Inset", 0.4),
+ page.len(node_param_f32(target, "Width", 0.06)),
+ page.len(node_param_f32(target, "Inset", 0.4)),
color_of(target, "Color", Vec3::ZERO),
),
+ "page_shape" => {
+ let spec = ShapeSpec {
+ kind: ShapeKind::parse(&node_param_str(target, "Shape", "Rectangle")),
+ center: [
+ page.len(node_param_f32(target, "X", 0.0)),
+ page.len(node_param_f32(target, "Y", 0.0)),
+ ],
+ size: [
+ page.len(node_param_f32(target, "Width", 1.0)),
+ page.len(node_param_f32(target, "Height", 1.0)),
+ ],
+ rotation: node_param_f32(target, "Rotation", 0.0),
+ corner_radius: page.len(node_param_f32(target, "Corner Radius", 0.0)),
+ sides: node_param_f32(target, "Sides", 3.0).round().max(3.0) as u32,
+ fill: toggle_of(target, "Fill")
+ .then(|| color_with(target, "Fill Color", Vec3::splat(0.5), "Fill Opacity")),
+ stroke: toggle_of(target, "Stroke").then(|| {
+ (
+ color_with(target, "Stroke Color", Vec3::ZERO, "Stroke Opacity"),
+ page.len(node_param_f32(target, "Stroke Width", 0.02)),
+ )
+ }),
+ };
+ page.shape(&spec);
+ }
"page_text" => {
let text = node_param_str(target, "Text", "");
let font = node_param_str(target, "Font", "");
let spec = TextSpec {
text: &text,
font: &font,
- size: node_param_f32(target, "Size", 0.25),
+ size: page.len(node_param_f32(target, "Size", 0.25)),
color: color_of(target, "Color", Vec3::ZERO),
- at: [node_param_f32(target, "X", 4.25), node_param_f32(target, "Y", 0.8)],
+ at: [
+ page.len(node_param_f32(target, "X", 4.25)),
+ page.len(node_param_f32(target, "Y", 0.8)),
+ ],
halign: match node_param_str(target, "Horizontal", "Center").as_str() {
"Left" => HAlign::Left,
"Right" => HAlign::Right,
@@ -549,12 +871,16 @@ fn with_fonts<R>(
/// the LAST visible page node wins — the one furthest down the roster, which
/// is the one most recently added.
pub fn displayed_page(root: &FsNode, level: &FsNode) -> Option<Page> {
- let target = level
+ resolve_page(root, displayed_page_node(level)?, &mut Vec::new())
+}
+
+/// The node [`displayed_page`] draws.
+pub fn displayed_page_node(level: &FsNode) -> Option<&FsNode> {
+ level
.children
.iter()
.filter(|c| is_page_node(&c.node_type) && c.geometry_visible)
- .next_back()?;
- resolve_page(root, target, &mut Vec::new())
+ .next_back()
}
/// The font stack, for tests that draw text without a node behind them.
diff --git a/src/render.rs b/src/render.rs
index 968945f..e7209ef 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -127,13 +127,7 @@ impl State {
let mut draw_order: Vec<usize> = (0..WIDGET_COUNT).collect();
draw_order.sort_by_key(|&i| {
- // PAGE_IDX shares the viewport's layer, not the roster's tail.
- // The viewport is full-bleed and the other panes float OVER it, so
- // a pane that takes the viewport's rect has to take its depth too
- // — drawn last it covers the collapsed stubs and the corner dots,
- // which then show through as ghost text from the later label pass.
let base_key = if i == VIEWPORT_IDX
- || i == crate::slots::PAGE_IDX
|| i == NETWORK_PANEL_IDX
|| i == crate::slots::NETWORK_PANEL2_IDX
{
@@ -327,17 +321,6 @@ impl State {
let (wx, wy, ww2, wh2) = w.rect();
append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
w.paint_self(&self.ui_context, pc);
- } else if idx == crate::slots::PAGE_IDX {
- // Modern-paint pane, like the spreadsheet: the designer authors the
- // plate (span-widened radii, focus tint) and ImageView::paint fits
- // the sheet into it. The fall-through branch below serves LEGACY
- // widgets — it emits a plate and the widget's legacy views — so a
- // widget whose whole look lives in Paint::paint lands there and
- // draws nothing at all, which is exactly what this pane did before
- // the branch existed: visible, correctly placed, and blank.
- let (wx, wy, ww2, wh2) = w.rect();
- append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
- w.paint_self(&self.ui_context, pc);
} else if idx == VIEWPORT_IDX {
// The scene viewer's lip is the window's own root plate edge: the
// 3D canvas is full-bleed (CANVAS_IDX covers the window; the other
@@ -1015,12 +998,6 @@ impl State {
if !self.show_viewport {
return;
}
- // The readout describes the 3D world's scale on screen. A page is not
- // in that world — it is a sheet of paper measured in inches — so over
- // a page the number is not merely irrelevant, it is wrong.
- if self.slots.page_view.image.is_some() {
- return;
- }
let (vx, vy, vw, vh) = self.last_scene_view_rect;
if vw <= 0.0 || vh <= 0.0 {
return;
@@ -1105,35 +1082,45 @@ impl State {
}
/// Compose the 2D page the displayed level holds, if it holds one, and
- /// hand it to the page pane.
+ /// hand it to the scene as an image.
///
/// The page context's counterpart to the geometry rebuild, and it runs on
/// the same trigger for the same reason: a parameter changed, so what the
- /// pane shows is stale. The raster goes to the GPU as an image the widget
- /// only BORROWS — the id is owned here and freed when it is replaced, so a
- /// page that is being scrubbed does not leak a texture per frame.
+ /// viewport shows is stale. The raster goes to the GPU as an image whose
+ /// id is owned here and freed when it is replaced, so a page that is
+ /// being scrubbed does not leak a texture per frame. The stage pass draws
+ /// it as a quad standing in the scene (`stage_frame`), where until
+ /// 2026-09-29 a pane of its own took the viewport's place.
pub(crate) fn rebuild_page(&mut self) {
- let page = crate::page::displayed_page(&self.fs_root, self.viewport_editor_dir());
+ let level = self.viewport_editor_dir();
+ let node_id = crate::page::displayed_page_node(level).map(|n| n.id.clone());
+ let page = crate::page::displayed_page(&self.fs_root, level);
if let Some(old) = self.page_image.take() {
cce_ui::vk::free_image(old);
}
- match page {
- Some(page) => {
- let (w, h) = (page.width, page.height);
- let id = cce_ui::vk::upload_rgba(page.to_rgba8(), w, h);
- self.page_image = Some(id);
- self.slots.page_view.set_image(Some((id, w, h)));
- self.update_status_text(&format!(
- "Page: {:.2} x {:.2} in at {} DPI ({}x{})",
- page.size[0], page.size[1], page.dpi, w, h
- ));
- }
- None => self.slots.page_view.set_image(None),
+ let had_page = self.page_shown.take().is_some();
+ if let (Some(page), Some(node_id)) = (page, node_id) {
+ let (w, h) = (page.width, page.height);
+ self.page_image = Some(cce_ui::vk::upload_rgba(page.to_rgba8(), w, h));
+ self.update_status_text(&format!(
+ "Image: {} x {} {} at {} DPI ({}x{} px)",
+ trim_number(page.in_unit(page.size[0])),
+ trim_number(page.in_unit(page.size[1])),
+ page.unit.label(),
+ page.dpi,
+ w,
+ h
+ ));
+ self.page_shown = Some(crate::page::PageShown {
+ node_id,
+ size: page.size,
+ pixels: (w, h),
+ origin: page.origin,
+ });
+ }
+ if had_page || self.page_shown.is_some() {
+ self.viewport_dirty = true;
}
- // Visibility and placement follow the image, and both are decided in
- // rebuild_positions.
- self.rebuild_positions();
- self.apply_layout();
}
pub(crate) fn rebuild_scene_geometry(&mut self) {
@@ -1385,3 +1372,10 @@ pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geome
}
wires
}
+
+/// A length for a status line: two decimals, without the zeros a whole
+/// number of pixels would trail.
+fn trim_number(v: f32) -> String {
+ let s = format!("{v:.2}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+}
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 26d0ca2..db40e54 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -72,6 +72,14 @@ pub enum Action {
ToggleNetworkPlate,
/// Clear the node selection.
Deselect,
+ /// Turn the camera square to the image the viewport shows and fit it.
+ FrameImage,
+ /// The same, at one image pixel to one display pixel.
+ ViewImagePixels,
+ /// A new image (a page node), shown and selected.
+ NewImage,
+ /// A shape or text node on the selected or shown image.
+ AddToImage(crate::image_tools::ImageLayer),
}
#[derive(Debug, Clone)]
diff --git a/src/slots.rs b/src/slots.rs
index 677cccb..70b7868 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -9,7 +9,7 @@
//! that assert each slot's concrete type are hand-written, below the macro.
use cce_ui::widget::{
- Adapted, Breadcrumb, Graph, ImageView, MenuBar, ParametersBg, Splitter, Spreadsheet,
+ Adapted, Breadcrumb, Graph, MenuBar, ParametersBg, Splitter, Spreadsheet,
StatusBar, WidgetHost,
};
@@ -113,10 +113,6 @@ widget_roster! {
NETWORK_PANEL2_IDX: network_panel2: PassivePlate,
CONTENT2_IDX: content2: Graph,
BREADCRUMB2_IDX: breadcrumb2: Breadcrumb,
- // The 2D page context's surface, sharing the viewport's rect and shown in
- // its place when the displayed level holds a page. Appended, like the
- // second network editor, so established slot indexes stay stable.
- PAGE_IDX: page_view: ImageView,
// The Alt+D dialog: plate, query line and the one list of commands and
// settings, its controls painted from the toolkit's own stamps. Appended,
// like every slot since the second network editor, so established